From 551c59413ebdc14d1e25c7440fd4c2bb1a1ee702 Mon Sep 17 00:00:00 2001 From: Zihao Yang <71203337+luckyyzh@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:37:06 +0800 Subject: [PATCH] fix: make qmd memory search opt-in (#6) Co-authored-by: luckyyzh --- .env.example | 6 + README.md | 32 ++++- package.json | 3 +- scripts/check-managed-profile.mjs | 6 + scripts/configure-pi-memory.mjs | 191 +++++++++++++++++++++++++++ scripts/configure-pi-memory.test.mjs | 45 +++++++ scripts/install-managed-packages.mjs | 3 + scripts/profile.mjs | 1 + scripts/run.mjs | 5 + 9 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 scripts/configure-pi-memory.mjs create mode 100644 scripts/configure-pi-memory.test.mjs diff --git a/.env.example b/.env.example index f59f3d4..dd6d49b 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,12 @@ ANTHROPIC_API_KEY= OPENAI_API_KEY= GEMINI_API_KEY= +# pi-memory runs in lightweight Markdown-only mode by default. This keeps +# durable facts, daily logs, scratchpad, and recovery without qmd/model downloads. +# Set both values below only after installing and configuring qmd yourself. +# PI_MEMORY_NO_SEARCH=0 +# PI_MEMORY_QMD_UPDATE=background + # Optional: move mutable runtime data while retaining the same isolation model. # PI_AGENT_DATA_DIR=D:\path\to\pi-agent-data diff --git a/README.md b/README.md index 2ba157e..de67ba7 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ data/workspaces/default/ 默认工作目录 | `pi-mcp-adapter@2.15.0` | 用一个紧凑代理工具接入 MCP | 模型使用 `mcp` 搜索并调用 MCP;`/mcp` 查看状态。外部宿主配置发现默认关闭 | | `@playwright/mcp@0.0.78` | 真实网页导航、点击、表单、快照和截图 | 通过 MCP 按需启动,空闲 5 分钟退出;使用无痕 Headless 系统 Edge | | `pi-lens@3.8.73` | LSP、AST、符号检索和项目诊断 | 模型按需激活代码智能工具;可用 `/lens-health`、`/lens-tools`、`/lens-map` 检查 | -| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 模型按需使用 memory 工具;文件位于 `data/agent/memory/`,默认不启用向量索引 | +| `pi-memory@0.4.0` | Markdown 长期记忆、日志、临时工作区和恢复记录 | 默认轻量模式保留读写、状态和恢复工具,但不注册依赖 qmd 的 `memory_search`;文件位于 `data/agent/memory/` | | `pi-subagents@0.37.2` | 创建研究、规划或执行子代理 | 简单任务不委派,跨模块或可并行复杂任务自动判断;也可使用 `/run`、`/parallel` | | `pi-smart-fetch@0.3.17` | 抓取单个或批量 URL 内容 | 模型按需使用 `web_fetch` / `batch_web_fetch`,也提供给研究子代理 | | `@ayulab/pi-rewind@0.4.6` | 每轮前后创建代码检查点 | `/rewind` 恢复代码、会话或两者;`/checkpoint` 管理存储。自动恢复文件默认关闭 | @@ -127,6 +127,19 @@ data/workspaces/default/ 默认工作目录 Playwright 不下载独立 Chromium。首次 `setup` 只缓存 MCP 的 Node.js 包;浏览器执行使用系统 Edge。 +#### 记忆模式 + +默认设置 `PI_MEMORY_NO_SEARCH=1` 和 `PI_MEMORY_QMD_UPDATE=off`。这不会削弱 Markdown 记忆、每日日志、scratchpad 或恢复记录,但会跳过 qmd 探测、安装提示和 `memory_search` 工具,因此全新安装不会自动下载 qmd 的本地模型。集成补丁会在 `setup` 以及每次启动前自动检查并重放,受管插件重装后无需手工修改。 + +如果确实需要跨全部记忆文件的关键词、语义或深度搜索,请先自行安装并配置 qmd,然后在 `.env` 中设置: + +```dotenv +PI_MEMORY_NO_SEARCH=0 +PI_MEMORY_QMD_UPDATE=background +``` + +随后运行 `npm run memory:configure` 或直接重启应用。qmd 及其模型不随本仓库分发。 + ### Profile、迁移和自定义扩展 只创建缺失的项目 Profile 文件: @@ -151,6 +164,7 @@ npm run profile:migrate -- --from D:\old-pi\agent --skills-from D:\old-skills ```powershell npm run setup # 完整安装、构建和 Profile 插件校验 npm run profile:packages # 安装缺失或版本不匹配的受管插件 +npm run memory:configure # 重新应用受管 pi-memory 轻量集成 npm run dev # 127.0.0.1:30141 开发服务 npm run restart # Windows:停止本项目旧开发进程并重新启动 npm run dev:lan # 局域网监听;仅在可信网络使用 @@ -296,7 +310,7 @@ Versions are pinned in `config/settings.default.json` and `config/mcp.default.js | `pi-mcp-adapter@2.15.0` | Compact MCP proxy | The model searches and invokes MCP through `mcp`; `/mcp` shows status. Host-config discovery is off | | `@playwright/mcp@0.0.78` | Real navigation, clicks, forms, snapshots, screenshots | Starts on demand, exits after five idle minutes, uses isolated headless system Edge | | `pi-lens@3.8.73` | LSP, AST, symbols, project diagnostics | The model activates code intelligence on demand; inspect with `/lens-health`, `/lens-tools`, `/lens-map` | -| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | The model uses memory tools on demand; files live under `data/agent/memory/`; vector indexing is off by default | +| `pi-memory@0.4.0` | Markdown durable facts, logs, scratchpad, recovery | Lightweight mode retains read/write, status, and recovery tools but does not register qmd-dependent `memory_search`; files live under `data/agent/memory/` | | `pi-subagents@0.37.2` | Research, planning, execution subagents | Simple tasks stay local; complex or parallel work may delegate automatically; `/run` and `/parallel` remain available | | `pi-smart-fetch@0.3.17` | Single and batched URL retrieval | Provides `web_fetch` and `batch_web_fetch` to the main and research agents | | `@ayulab/pi-rewind@0.4.6` | Per-turn code checkpoints | `/rewind` restores code, conversation, or both; `/checkpoint` manages storage. Automatic file restore is off | @@ -306,6 +320,19 @@ Versions are pinned in `config/settings.default.json` and `config/mcp.default.js Playwright never downloads a standalone Chromium in this project. Setup caches only its Node package; browser execution uses system Edge. +#### Memory modes + +The managed launcher defaults to `PI_MEMORY_NO_SEARCH=1` and `PI_MEMORY_QMD_UPDATE=off`. Markdown memory, daily logs, scratchpad, and recovery stay available, while qmd detection, its installation notice, and the `memory_search` tool are skipped. A deterministic integration patch is checked during setup and before every launch, so reinstalling managed packages needs no manual repair. + +To opt into keyword, semantic, or deep search across every memory file, install and configure qmd separately, then add the following to `.env`: + +```dotenv +PI_MEMORY_NO_SEARCH=0 +PI_MEMORY_QMD_UPDATE=background +``` + +Run `npm run memory:configure` or restart the application afterward. qmd and its local models are not bundled with this repository. + ### Profile, migration, and extension ```powershell @@ -323,6 +350,7 @@ Migration copies without deleting source data and removes absolute resource path ```powershell npm run setup # Full install, build, and managed-profile validation npm run profile:packages # Install missing or version-mismatched managed plugins +npm run memory:configure # Reapply the managed pi-memory lightweight integration npm run dev # Development server on 127.0.0.1:30141 npm run restart # Windows: stop this project's old dev process and restart npm run dev:lan # Listen on the LAN; trusted networks only diff --git a/package.json b/package.json index d308dae..13daca7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "setup": "node scripts/setup.mjs", "profile:init": "node scripts/init-profile.mjs", "profile:packages": "node scripts/install-managed-packages.mjs", + "memory:configure": "node scripts/configure-pi-memory.mjs", "profile:migrate": "node scripts/migrate-profile.mjs", "check": "node scripts/check-integration.mjs", "check:profile": "node scripts/check-managed-profile.mjs", @@ -31,7 +32,7 @@ "storage:clean": "node scripts/storage-maintenance.mjs clean", "typecheck": "node pi-web/node_modules/typescript/bin/tsc --noEmit -p pi-web/tsconfig.json", "lint": "npm --prefix pi-web run lint", - "test:managed": "node --test scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs" + "test:managed": "node --test scripts/configure-pi-memory.test.mjs scripts/migrate-profile.test.mjs scripts/storage-maintenance.test.mjs pi-web/lib/app-runtime.test.mjs pi-web/lib/project-trust.test.mjs pi-web/lib/searxng-extension.test.mjs pi-web/lib/skill-lock.test.mjs pi-web/lib/skill-updates.test.mjs" }, "engines": { "node": ">=22.19.0" diff --git a/scripts/check-managed-profile.mjs b/scripts/check-managed-profile.mjs index ad68b74..25b0fb7 100644 --- a/scripts/check-managed-profile.mjs +++ b/scripts/check-managed-profile.mjs @@ -49,6 +49,12 @@ const missing = [ ...requiredTools.filter((name) => !tools.has(name)).map((name) => `tool:${name}`), ...requiredCommands.filter((name) => !commands.has(name)).map((name) => `command:/${name}`), ]; +if (process.env.PI_MEMORY_NO_SEARCH === "1" && tools.has("memory_search")) { + missing.push("unexpected tool:memory_search in lightweight memory mode"); +} +if (process.env.PI_MEMORY_NO_SEARCH !== "1" && !tools.has("memory_search")) { + missing.push("tool:memory_search"); +} if (missing.length > 0) { console.error(`Managed Pi profile is incomplete: ${missing.join(", ")}`); diff --git a/scripts/configure-pi-memory.mjs b/scripts/configure-pi-memory.mjs new file mode 100644 index 0000000..8f1a9d5 --- /dev/null +++ b/scripts/configure-pi-memory.mjs @@ -0,0 +1,191 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { agentDir as defaultAgentDir } from "./profile.mjs"; + +const supportedVersion = "0.4.0"; + +function replaceOnce(source, before, after, label) { + if (source.includes(after)) return source; + + const first = source.indexOf(before); + const last = source.lastIndexOf(before); + if (first < 0 || first !== last) { + throw new Error(`Cannot apply pi-memory lightweight patch: ${label}`); + } + return source.slice(0, first) + after + source.slice(first + before.length); +} + +export function patchPiMemorySource(input) { + let source = input.replaceAll("\r\n", "\n"); + + source = replaceOnce( + source, + `\t\tqmdAvailable = await detectQmd(); +\t\tif (!qmdAvailable) { +\t\t\tif (ctx.hasUI) { +\t\t\t\tctx.ui.notify(qmdInstallInstructions(), "info"); +\t\t\t} +\t\t\trefreshMemorySnapshot("session_start"); +\t\t\treturn; +\t\t}`, + `\t\tconst searchEnabled = process.env.PI_MEMORY_NO_SEARCH !== "1"; +\t\tqmdAvailable = searchEnabled ? await detectQmd() : false; +\t\tif (!qmdAvailable) { +\t\t\tif (ctx.hasUI && searchEnabled) { +\t\t\t\tctx.ui.notify(qmdInstallInstructions(), "info"); +\t\t\t} +\t\t\trefreshMemorySnapshot("session_start"); +\t\t\treturn; +\t\t}`, + "session-start qmd probe", + ); + + source = replaceOnce( + source, + `\t\t\tsnapshotCaveat = +\t\t\t\t\`Snapshot \${snapshotReason} at \${snapshotTakenAt}. \` + +\t\t\t\t"Use memory_read / memory_search for the authoritative latest state; " + +\t\t\t\t"recent writes may also be visible in tool-call history.";`, + `\t\t\tsnapshotCaveat = +\t\t\t\t\`Snapshot \${snapshotReason} at \${snapshotTakenAt}. \` + +\t\t\t\t(process.env.PI_MEMORY_NO_SEARCH === "1" +\t\t\t\t\t? "Use memory_read for the authoritative latest state; " +\t\t\t\t\t: "Use memory_read / memory_search for the authoritative latest state; ") + +\t\t\t\t"recent writes may also be visible in tool-call history.";`, + "snapshot search guidance", + ); + + source = replaceOnce( + source, + `\t\t\t"- Things to fix later or keep in mind \\u2192 scratchpad tool", +\t\t\t"- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).", +\t\t\t"- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.", +\t\t\t'- If someone says "remember this," write it immediately.',`, + `\t\t\t"- Things to fix later or keep in mind \\u2192 scratchpad tool", +\t\t\t...(process.env.PI_MEMORY_NO_SEARCH === "1" +\t\t\t\t? [] +\t\t\t\t: [ +\t\t\t\t\t\t"- Use memory_search to find past context across all memory files (keyword, semantic, or deep search).", +\t\t\t\t\t\t"- Use #tags (e.g. #decision, #preference) and [[links]] (e.g. [[auth-strategy]]) in memory content to improve future search recall.", +\t\t\t\t\t]), +\t\t\t'- If someone says "remember this," write it immediately.',`, + "memory prompt search guidance", + ); + + source = replaceOnce( + source, + `\t// --- memory_search tool --- +\tpi.registerTool({`, + `\t// --- memory_search tool (optional in lightweight mode) --- +\tif (process.env.PI_MEMORY_NO_SEARCH !== "1") { +\t\tpi.registerTool({`, + "memory_search registration start", + ); + + source = replaceOnce( + source, + `\t\t\t\t\tisError: true, +\t\t\t\t\tdetails: {}, +\t\t\t\t}; +\t\t\t} +\t\t}, +\t}); + +\t// --- memory_status tool (doctor) ---`, + `\t\t\t\t\tisError: true, +\t\t\t\t\tdetails: {}, +\t\t\t\t}; +\t\t\t} +\t\t\t}, +\t\t}); +\t} + +\t// --- memory_status tool (doctor) ---`, + "memory_search registration end", + ); + + source = replaceOnce( + source, + `\t\t\tconst qmdOk = qmdAvailable || (await detectQmd());`, + `\t\t\tconst searchEnabled = process.env.PI_MEMORY_NO_SEARCH !== "1"; +\t\t\tconst qmdOk = searchEnabled && (qmdAvailable || (await detectQmd()));`, + "memory status qmd probe", + ); + + source = replaceOnce( + source, + `\t\t\t\t"## Search (qmd)", +\t\t\t\t\`- qmd available: \${mark(qmdOk)}\`, +\t\t\t]; + +\t\t\tif (qmdOk) {`, + `\t\t\t\t"## Search (qmd)", +\t\t\t\tsearchEnabled +\t\t\t\t\t? \`- qmd available: \${mark(qmdOk)}\` +\t\t\t\t\t: "- Disabled by PI_MEMORY_NO_SEARCH=1 (lightweight mode)", +\t\t\t]; + +\t\t\tif (!searchEnabled) { +\t\t\t\t// Core Markdown memory remains fully available in lightweight mode. +\t\t\t} else if (qmdOk) {`, + "memory status lightweight result", + ); + + source = replaceOnce( + source, + `\t\t\t\t\`- PI_MEMORY_QMD_UPDATE: \${getQmdUpdateMode()}\`, +\t\t\t\t\`- PI_MEMORY_DIR: \${process.env.PI_MEMORY_DIR ? "set" : "default"}\`,`, + `\t\t\t\t\`- PI_MEMORY_NO_SEARCH: \${process.env.PI_MEMORY_NO_SEARCH ?? "0"}\`, +\t\t\t\t\`- PI_MEMORY_QMD_UPDATE: \${getQmdUpdateMode()}\`, +\t\t\t\t\`- PI_MEMORY_DIR: \${process.env.PI_MEMORY_DIR ? "set" : "default"}\`,`, + "memory status configuration", + ); + + return source; +} + +export function isPiMemoryLiteConfigured(source) { + return ( + source.includes('qmdAvailable = searchEnabled ? await detectQmd() : false;') && + source.includes('if (process.env.PI_MEMORY_NO_SEARCH !== "1") {\n\t\tpi.registerTool({') && + source.includes("Disabled by PI_MEMORY_NO_SEARCH=1 (lightweight mode)") + ); +} + +export function configurePiMemory({ agentDir = defaultAgentDir, quiet = false } = {}) { + const packageDir = join(agentDir, "npm", "node_modules", "pi-memory"); + const packageJsonPath = join(packageDir, "package.json"); + const sourcePath = join(packageDir, "index.ts"); + + if (!existsSync(packageJsonPath) || !existsSync(sourcePath)) { + return { status: "missing", sourcePath }; + } + + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (packageJson.version !== supportedVersion) { + throw new Error( + `Unsupported pi-memory version ${packageJson.version ?? "unknown"}; expected ${supportedVersion}.`, + ); + } + + const source = readFileSync(sourcePath, "utf8"); + const patched = patchPiMemorySource(source); + if (patched === source) { + if (!quiet) console.log("pi-memory lightweight integration ready."); + return { status: "ready", sourcePath }; + } + + writeFileSync(sourcePath, patched, "utf8"); + if (!quiet) console.log("Configured pi-memory lightweight integration."); + return { status: "patched", sourcePath }; +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + const result = configurePiMemory(); + if (result.status === "missing") { + console.error("pi-memory is not installed. Run npm run setup first."); + process.exit(1); + } +} diff --git a/scripts/configure-pi-memory.test.mjs b/scripts/configure-pi-memory.test.mjs new file mode 100644 index 0000000..ff8d235 --- /dev/null +++ b/scripts/configure-pi-memory.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + configurePiMemory, + isPiMemoryLiteConfigured, + patchPiMemorySource, +} from "./configure-pi-memory.mjs"; +import { agentDir, managedEnvironment } from "./profile.mjs"; + +test("managed profile defaults pi-memory to lightweight mode", () => { + const defaultEnvironment = managedEnvironment({ ...process.env, PI_MEMORY_NO_SEARCH: undefined }); + const searchEnvironment = managedEnvironment({ ...process.env, PI_MEMORY_NO_SEARCH: "0" }); + + assert.equal(defaultEnvironment.PI_MEMORY_NO_SEARCH, "1"); + assert.equal(searchEnvironment.PI_MEMORY_NO_SEARCH, "0"); +}); + +test("installed pi-memory integration is patched and idempotent", (context) => { + const sourcePath = join(agentDir, "npm", "node_modules", "pi-memory", "index.ts"); + if (!existsSync(sourcePath)) { + context.skip("managed pi-memory is not installed; npm run setup installs it before CI tests"); + return; + } + + configurePiMemory({ quiet: true }); + const source = readFileSync(sourcePath, "utf8"); + assert.equal(isPiMemoryLiteConfigured(source), true); + assert.equal(patchPiMemorySource(source), source); +}); + +test("integration rejects an unreviewed pi-memory version", () => { + const temporaryAgentDir = mkdtempSync(join(tmpdir(), "pi-memory-version-")); + const packageDir = join(temporaryAgentDir, "npm", "node_modules", "pi-memory"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ version: "9.9.9" }), "utf8"); + writeFileSync(join(packageDir, "index.ts"), "export default function () {}\n", "utf8"); + + assert.throws( + () => configurePiMemory({ agentDir: temporaryAgentDir, quiet: true }), + /Unsupported pi-memory version 9\.9\.9/, + ); +}); diff --git a/scripts/install-managed-packages.mjs b/scripts/install-managed-packages.mjs index 409bb2d..858924d 100644 --- a/scripts/install-managed-packages.mjs +++ b/scripts/install-managed-packages.mjs @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { configurePiMemory } from "./configure-pi-memory.mjs"; import { agentDir, managedEnvironment, rootDir } from "./profile.mjs"; const settings = JSON.parse( @@ -46,3 +47,5 @@ for (const source of packages) { if (result.error) throw result.error; if (result.status !== 0) process.exit(result.status ?? 1); } + +configurePiMemory({ agentDir }); diff --git a/scripts/profile.mjs b/scripts/profile.mjs index 6734604..67fea26 100644 --- a/scripts/profile.mjs +++ b/scripts/profile.mjs @@ -124,6 +124,7 @@ export function managedEnvironment(baseEnv = process.env) { USERPROFILE: homeDir, PI_MEMORY_DIR: baseEnv.PI_MEMORY_DIR?.trim() || memoryDir, PI_MEMORY_SNAPSHOT: baseEnv.PI_MEMORY_SNAPSHOT?.trim() || "stable", + PI_MEMORY_NO_SEARCH: baseEnv.PI_MEMORY_NO_SEARCH?.trim() || "1", PI_MEMORY_QMD_UPDATE: baseEnv.PI_MEMORY_QMD_UPDATE?.trim() || "off", PI_RETRY_STALL_TIMEOUT_MS: baseEnv.PI_RETRY_STALL_TIMEOUT_MS?.trim() || "180000", npm_config_cache: baseEnv.npm_config_cache?.trim() || join(dataDir, "cache", "npm"), diff --git a/scripts/run.mjs b/scripts/run.mjs index 7f82865..3b4a00b 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { join } from "node:path"; +import { configurePiMemory } from "./configure-pi-memory.mjs"; import { managedEnvironment, rootDir } from "./profile.mjs"; import { isLocalPortListening, @@ -21,6 +22,10 @@ if (!npmCliPath) { } const childEnvironment = managedEnvironment(); +const memoryConfiguration = configurePiMemory({ quiet: true }); +if (memoryConfiguration.status === "missing") { + console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory"); +} if (await isLocalPortListening(30141)) { console.log("[storage] skipped automatic maintenance because Pi Web is already running"); } else {