fix: make qmd memory search opt-in (#6)

Co-authored-by: luckyyzh <luckyyzh@users.noreply.github.com>
This commit is contained in:
Zihao Yang
2026-07-31 23:37:06 +08:00
committed by GitHub
co-authored by luckyyzh
parent 3b48681852
commit 551c59413e
9 changed files with 289 additions and 3 deletions
+6
View File
@@ -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
+30 -2
View File
@@ -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
+2 -1
View File
@@ -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"
+6
View File
@@ -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(", ")}`);
+191
View File
@@ -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);
}
}
+45
View File
@@ -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/,
);
});
+3
View File
@@ -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 });
+1
View File
@@ -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"),
+5
View File
@@ -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 {