@modusensus/dsh-mneme 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +11 -3
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +8 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +298 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1153 -106
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/graph/anchoring.js +7 -2
  13. package/lib/index.js +50 -4
  14. package/lib/inject.js +15 -3
  15. package/lib/lang.js +54 -0
  16. package/lib/quality-filter.js +48 -12
  17. package/lib/service.js +72 -8
  18. package/lib/settings.js +8 -1
  19. package/lib/store.js +657 -0
  20. package/lib/tools.js +127 -1
  21. package/lib/wiki/inject.js +164 -0
  22. package/lib/wiki/partition.js +200 -0
  23. package/lib/wiki/plan.js +214 -0
  24. package/lib/wiki/queries.js +220 -0
  25. package/lib/wiki/render.js +187 -0
  26. package/lib/wiki/scan.js +371 -0
  27. package/lib/wiki/t0.js +463 -0
  28. package/lib/wiki/t1.js +496 -0
  29. package/lib/write-admission.js +5 -3
  30. package/package.json +4 -2
  31. package/src/api-standalone.js +17 -21
  32. package/src/api.js +298 -12
  33. package/src/bootstrap-http.js +105 -0
  34. package/src/bootstrap.js +5 -1
  35. package/src/choro-command.js +104 -0
  36. package/src/config-keys.js +144 -0
  37. package/src/config.js +28 -0
  38. package/src/graph/anchoring.js +7 -2
  39. package/src/index.js +50 -4
  40. package/src/inject.js +15 -3
  41. package/src/lang.js +54 -0
  42. package/src/quality-filter.js +48 -12
  43. package/src/service.js +72 -8
  44. package/src/settings.js +8 -1
  45. package/src/store.js +657 -0
  46. package/src/tools.js +127 -1
  47. package/src/wiki/inject.js +164 -0
  48. package/src/wiki/partition.js +200 -0
  49. package/src/wiki/plan.js +214 -0
  50. package/src/wiki/queries.js +220 -0
  51. package/src/wiki/render.js +187 -0
  52. package/src/wiki/scan.js +371 -0
  53. package/src/wiki/t0.js +463 -0
  54. package/src/wiki/t1.js +496 -0
  55. package/src/write-admission.js +5 -3
  56. package/test/api-routes-gaps.test.js +131 -1
  57. package/test/api.test.js +7 -1
  58. package/test/bootstrap.test.js +8 -0
  59. package/test/choro-command.test.js +218 -0
  60. package/test/client.test.js +820 -19
  61. package/test/config-keys-routes.test.js +233 -0
  62. package/test/contrast.test.js +14 -7
  63. package/test/graph-boosted-relations.test.js +189 -0
  64. package/test/graph-multipath.test.js +269 -0
  65. package/test/inject.test.js +51 -0
  66. package/test/llm-audit.test.js +29 -1
  67. package/test/quality-backfill.test.js +290 -0
  68. package/test/quality-filter.test.js +113 -4
  69. package/test/service.test.js +1 -1
  70. package/test/wiki-api.test.js +153 -0
  71. package/test/wiki-inject.test.js +265 -0
  72. package/test/wiki-partition.test.js +112 -0
  73. package/test/wiki-plan.test.js +80 -0
  74. package/test/wiki-queries.test.js +187 -0
  75. package/test/wiki-scan.test.js +208 -0
  76. package/test/wiki-store.test.js +190 -0
  77. package/test/wiki-t0.test.js +380 -0
  78. package/test/wiki-t1.test.js +235 -0
  79. package/test/wiki-tools.test.js +121 -0
package/lib/wiki/t1.js ADDED
@@ -0,0 +1,496 @@
1
+ // dsh-mneme/src/wiki/t1.js
2
+ // chorography T1 生成轮(spec §6 步 9–12 + 降级表;实现计划 P14–P22):
3
+ // 逐模块一轮 headless 会话,产出四段语义正文 + 溯源 claims,写回骨架页。
4
+ //
5
+ // 与 T0 的分工:T0 是确定性骨架(零模型、必有产出),T1 是花钱的语义层(逐模块、
6
+ // 可跳过、可失败)。T1 **只手动触发**(CLI `--t1`),没有定时器、没有 hook——
7
+ // 降级表第一行「headless 根本起不来」在这里是环境级中止,不是页级失败。
8
+ //
9
+ // 成本纪律(spec 步 11 的三重上限,P15):工具调用次数 / 输出字节 / 墙钟超时,
10
+ // 全部在 `--json` NDJSON 事件流上边读边计数——超限即刻 kill 子进程,不是事后
11
+ // 解析(事后解析意味着钱已经花完)。
12
+ //
13
+ // 输出契约(P16):章程要求模型只输出一个 JSON 对象;解析失败、或 claims 的
14
+ // sourcePath 不在该模块文件清单里 → 该轮 failed(fail_count++,连续两次 →
15
+ // status=failed,骨架原样保留)。**编造的锚点比没有更伤**,所以正文与锚点要么
16
+ // 同过要么同拒。
17
+ //
18
+ // 边界(P22,如实声明):headless 会话的工具集由宿主管,章程里「只读、不修改
19
+ // 文件」是软约束不是安全边界。T1 的硬保证只有三条:人发起(CLI 显式 --t1)、
20
+ // 本进程写路径断言在 .chorography/** 之内、越界动作 git diff 可见。
21
+ import { spawn } from "node:child_process";
22
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
23
+ import { join, dirname, sep, resolve } from "node:path";
24
+ import { tmpdir } from "node:os";
25
+ import { realpathSync } from "node:fs";
26
+ import { extractHumanBlocks, injectHumanBlocks, T1_SECTION_START, T1_SECTION_END } from "./render.js";
27
+
28
+ // 标记的单一来源在 render.js(页面格式的家);这里转出去,调用方与测试继续从
29
+ // t1.js 取,不必知道它住在哪。
30
+ export { T1_SECTION_START, T1_SECTION_END };
31
+ export const T1_FAIL_THRESHOLD = 2;
32
+ export const DEFAULT_T1_MODEL = "xdpool/deepseek-v4.1-flash";
33
+
34
+ // 三重上限缺省(实现计划 P15):单模块预算——timeout 墙钟、工具调用次数、
35
+ // 累计输出字节。真实成本实测见 V1(10s/次任务),这里给的是上限不是预期。
36
+ export const DEFAULT_LIMITS = Object.freeze({
37
+ timeoutMs: 240000,
38
+ maxToolCalls: 40,
39
+ maxOutputBytes: 512000,
40
+ });
41
+
42
+ export class T1Refusal extends Error {
43
+ constructor(code, message) {
44
+ super(message);
45
+ this.name = "T1Refusal";
46
+ this.code = code;
47
+ }
48
+ }
49
+
50
+ /** 环境级失败 vs 模块级失败的判据(P18):runner 用 kind 区分,编排层据此分叉。 */
51
+ export function isEnvironmentFailure(result) {
52
+ return result?.kind === "environment";
53
+ }
54
+
55
+ /* --------------------------- 真实 headless runner --------------------------- */
56
+
57
+ // V1 实测配方(2026-11-05):绕过 .cmd wrapper(Git Bash 下引号会碎)直接调 exe;
58
+ // 模型路由走 --patch 临时 overlay 指池模型(bili-scroll 同款,零配置改动)。
59
+ // exe 定位:env DSH_EXE > process.execPath(本身是 Harness exe,即已在宿主 CLI 内)>
60
+ // 标准安装位。定位不到 → environment 失败(降级表第一行),不猜测。
61
+ export function makeHeadlessRunner({ profile = "headless", onOverlay = null } = {}) {
62
+ return async function runSession({ cwd, task, model, timeoutMs, maxToolCalls, maxOutputBytes }) {
63
+ const exe = locateDshExe();
64
+ if (!exe) {
65
+ return {
66
+ ok: false,
67
+ kind: "environment",
68
+ reason: "找不到 DeepSeek Harness 主程序(设 DSH_EXE 环境变量,或用 --dsh-exe 显式指定)",
69
+ };
70
+ }
71
+ const cliJs = locateCliJs(exe);
72
+ if (!cliJs) {
73
+ return { ok: false, kind: "environment", reason: `在 ${dirname(exe)} 下找不到 dsh-desktop-host 的 cli.js` };
74
+ }
75
+ const [provider, modelName] = String(model).split("/");
76
+ const overlayPath = writeModelOverlay(provider ?? "xdpool", modelName ?? "deepseek-v4.1-flash", onOverlay);
77
+ let proc = null;
78
+ try {
79
+ const args = [
80
+ "--expose-internals", cliJs,
81
+ "--profile", profile,
82
+ "--patch", overlayPath,
83
+ "--json",
84
+ "headless",
85
+ task,
86
+ ];
87
+ proc = spawn(exe, args, {
88
+ cwd,
89
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
90
+ stdio: ["ignore", "pipe", "pipe"],
91
+ });
92
+ return await consumeHeadlessStream(proc, { timeoutMs, maxToolCalls, maxOutputBytes });
93
+ } catch (err) {
94
+ return { ok: false, kind: "environment", reason: String(err?.message ?? err) };
95
+ } finally {
96
+ if (proc && proc.exitCode === null && proc.signalCode === null) proc.kill();
97
+ try { rmSync(dirname(overlayPath), { recursive: true, force: true }); } catch { /* 临时目录 */ }
98
+ }
99
+ };
100
+ }
101
+
102
+ function locateDshExe() {
103
+ if (process.env.DSH_EXE && existsSync(process.env.DSH_EXE)) return process.env.DSH_EXE;
104
+ const exePath = process.execPath ?? "";
105
+ if (basename(exePath).toLowerCase() === "deepseek harness.exe") return exePath;
106
+ const standard = join(process.env["ProgramFiles"] ?? "C:\\Program Files", "DeepSeek Harness", "DeepSeek Harness.exe");
107
+ return existsSync(standard) ? standard : null;
108
+ }
109
+
110
+ function locateCliJs(exe) {
111
+ // cli.js 固定在 exe 相对路径的 app.asar 内部。**不能 existsSync 校验**:CLI 进程
112
+ // 是普通 node,没有 asar 支持 stat 不进包内——而 exe 自己(V1 实测)加载这个
113
+ // 路径没问题。位置是安装器定的,直接给出去。
114
+ return join(dirname(exe), "resources", "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "lib", "cli.js");
115
+ }
116
+
117
+ // overlay 只含 provider/model 名(红线 4:不落 key);一次性临时目录,finally 里删。
118
+ function writeModelOverlay(provider, model, onOverlay) {
119
+ const dir = mkdtempSync(join(tmpdir(), "mneme-choro-t1-"));
120
+ const p = join(dir, "model-overlay.yml");
121
+ const body = [
122
+ "- id: agent-default-model",
123
+ ' name: "@deepseek-ai/dsh-agent-default-model"',
124
+ " config:",
125
+ ` provider: ${provider}`,
126
+ ` model: ${model}`,
127
+ "",
128
+ ].join("\n");
129
+ writeFileSync(p, body, "utf8");
130
+ if (onOverlay) onOverlay(p);
131
+ return p;
132
+ }
133
+
134
+ // NDJSON 事件流消费:三重上限在读取循环里计数,超限 kill。headless --json 的事件
135
+ // 形状以宿主实现为准——工具调用按「事件名含 tool_call/tool_use」的宽匹配计数,
136
+ // 计不出就退化为只用 timeout/bytes 两道上限(如实降级,不假装第三道存在)。
137
+ function consumeHeadlessStream(proc, { timeoutMs, maxToolCalls, maxOutputBytes }) {
138
+ return new Promise((resolvePromise) => {
139
+ let out = "";
140
+ let bytes = 0;
141
+ let toolCalls = 0;
142
+ let scannedLines = 0; // 已计过数的完整行数——每块只扫新增部分,否则事件被平方级重复计数(实测 11s 撞 40 上限的假象就是这么来的)
143
+ let timedOut = false;
144
+ const timer = setTimeout(() => {
145
+ timedOut = true;
146
+ proc.kill();
147
+ }, timeoutMs);
148
+ const finish = (result) => {
149
+ clearTimeout(timer);
150
+ resolvePromise(result);
151
+ };
152
+ proc.stdout.on("data", (chunk) => {
153
+ bytes += chunk.length;
154
+ if (bytes > maxOutputBytes) {
155
+ proc.kill();
156
+ finish({ ok: false, kind: "module", reason: `输出超过 ${maxOutputBytes} 字节上限` });
157
+ return;
158
+ }
159
+ out += chunk.toString("utf8");
160
+ // headless --json 的真实事件形状(V1 实测):{type:"tool_call"|"tool_result"|
161
+ // "status"|"text"|"final"|"session", ...}。计数只认 tool_call——tool_result
162
+ // 是同一对调用的另一半,一起数等于把上限折半。
163
+ const lines = out.split("\n");
164
+ const complete = lines.length - 1; // 末行可能是半截,留给下一块
165
+ for (let i = scannedLines; i < complete; i++) {
166
+ try {
167
+ const event = JSON.parse(lines[i]);
168
+ if (event?.type === "tool_call") {
169
+ toolCalls++;
170
+ if (toolCalls > maxToolCalls) {
171
+ proc.kill();
172
+ finish({ ok: false, kind: "module", reason: `工具调用超过 ${maxToolCalls} 次上限` });
173
+ return;
174
+ }
175
+ }
176
+ } catch { /* 非 JSON 行(最终答案等)直接进 out */ }
177
+ }
178
+ scannedLines = complete;
179
+ });
180
+ proc.stderr.on("data", () => { /* 诊断进 stderr,不影响结果 */ });
181
+ proc.on("error", (err) => finish({ ok: false, kind: "environment", reason: `spawn 失败:${String(err?.message ?? err)}` }));
182
+ proc.on("close", (code) => {
183
+ if (timedOut) { finish({ ok: false, kind: "module", reason: `超过 ${timeoutMs}ms 墙钟上限` }); return; }
184
+ // 最终答案:--json 流里最后一个非事件行;流解析失败就取 stdout 去掉事件行后的文本。
185
+ const text = extractFinalText(out);
186
+ if (code !== 0) {
187
+ const envHint = /MISSING_CREDENTIAL|NO_ADAPTER|UNKNOWN_MODEL|EADDR|cannot find/i.test(out) ? "environment" : "module";
188
+ finish({ ok: false, kind: envHint, reason: `headless 退出码 ${code}:${(text || "").slice(0, 200) || "(无输出)"}` });
189
+ return;
190
+ }
191
+ if (!text.trim()) { finish({ ok: false, kind: "module", reason: "headless 没有产出可解析的最终回答" }); return; }
192
+ finish({ ok: true, text });
193
+ });
194
+ });
195
+ }
196
+
197
+ function extractFinalText(ndjsonOut) {
198
+ // 主路径:final 事件的 text(V1 实测 {type:"final", text:"…"})。兜底:首个 text
199
+ // 事件 → 非 JSON 行(流没按事件形状来时的最后防线)。
200
+ let textEvent = "";
201
+ const nonEvents = [];
202
+ for (const line of ndjsonOut.split("\n")) {
203
+ if (line.trim() === "") continue;
204
+ try {
205
+ const event = JSON.parse(line);
206
+ if (event?.type === "final" && typeof event.text === "string") textEvent = event.text;
207
+ else if (event?.type === "text" && typeof event.text === "string") textEvent = textEvent || event.text;
208
+ } catch {
209
+ nonEvents.push(line);
210
+ }
211
+ }
212
+ if (textEvent.trim()) return textEvent.trim();
213
+ return nonEvents.join("\n").trim();
214
+ }
215
+
216
+ /* ------------------------------- 编排层 ------------------------------- */
217
+
218
+ /**
219
+ * @param {object} opts
220
+ * @param {object} opts.store createStore 产物
221
+ * @param {Function} [opts.runSession] 注入的会话执行器(P14);缺省真实 headless runner
222
+ * @param {object} [opts.limits] 三重上限
223
+ * @param {() => string} [opts.now]
224
+ */
225
+ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, now = () => new Date().toISOString() }) {
226
+ const runner = runSession ?? makeHeadlessRunner();
227
+
228
+ /**
229
+ * @param {object} args
230
+ * @param {string} args.repoRoot 仓库路径(realpath 后必须与已登记仓库一致)
231
+ * @param {string} [args.model] "provider/model"
232
+ * @param {string} [args.moduleSlug] 只跑一个模块
233
+ * @param {boolean} [args.retryFailed] 把 failed 页也拉进来重试(成功后 fail_count 清零)
234
+ * @param {boolean} [args.dryRun] 只列将跑的模块与章程预览,零写入零会话
235
+ * @param {string} [args.dshExe] 透传给真实 runner 的 exe 定位(CLI --dsh-exe)
236
+ */
237
+ async function runT1({ repoRoot, model = DEFAULT_T1_MODEL, moduleSlug = null, retryFailed = false, dryRun = false, dshExe = null, limits: limitsOverride = null }) {
238
+ const cap = { ...limits, ...limitsOverride };
239
+ const rootPath = realpathSync(resolve(repoRoot));
240
+ const repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath);
241
+ if (!repo) {
242
+ throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(dsh-mneme-choro <repo> --apply)`);
243
+ }
244
+
245
+ // 选模块:skeleton 必跑;failed 只在 retryFailed 时;enriched 跳过(重富化留待显式 --module)。
246
+ const pages = store.db.prepare(
247
+ `SELECT p.id, p.status, p.fail_count, m.slug, m.title, m.file_count
248
+ FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
249
+ WHERE m.repo_id = ?
250
+ ORDER BY m.slug`
251
+ ).all(repo.id);
252
+ const candidates = pages.filter((p) =>
253
+ p.status === "skeleton"
254
+ || (retryFailed && p.status === "failed")
255
+ || (moduleSlug !== null && p.slug === moduleSlug)
256
+ ).filter((p) => (moduleSlug !== null ? p.slug === moduleSlug : true));
257
+ const skipped = pages
258
+ .filter((p) => !candidates.some((c) => c.slug === p.slug))
259
+ .map((p) => ({ slug: p.slug, reason: p.status === "enriched" ? "already-enriched" : p.status === "failed" ? "failed(用 --retry-failed 重试)" : `status=${p.status}` }));
260
+
261
+ if (dryRun) {
262
+ return {
263
+ dryRun: true,
264
+ repoName: repo.name,
265
+ model,
266
+ candidates: candidates.map((p) => ({ slug: p.slug, title: p.title, fileCount: p.file_count, status: p.status })),
267
+ skipped,
268
+ limits: cap,
269
+ };
270
+ }
271
+
272
+ // 模块文件清单从 DB 指纹侧拿不到(M0 没存文件集),从骨架页正文解析——页面
273
+ // 本来就是文件清单的真源渲染物,这样 T1 与面板看到的内容永远一致。
274
+ const receipt = {
275
+ dryRun: false,
276
+ repoName: repo.name,
277
+ model,
278
+ enriched: [],
279
+ failed: [],
280
+ skipped,
281
+ t1Unavailable: null,
282
+ };
283
+ const runnerWithExe = dshExe
284
+ ? async (call) => {
285
+ process.env.DSH_EXE = dshExe;
286
+ return runner(call);
287
+ }
288
+ : runner;
289
+
290
+ for (const candidate of candidates) {
291
+ const slug = candidate.slug;
292
+ const pageRow = store.choroPageBySlug(repo.id, slug);
293
+ if (!pageRow) { receipt.failed.push({ slug, reason: "page-row-missing" }); continue; }
294
+
295
+ const pageFile = join(rootPath, pageRow.rel_path);
296
+ if (!existsSync(pageFile)) {
297
+ receipt.failed.push({ slug, reason: "page-missing(先重跑 T0)" });
298
+ continue;
299
+ }
300
+ const files = parseSkeletonFileList(readFileSync(pageFile, "utf8"));
301
+ const task = buildCharter(repo.name, slug, candidate.title, files);
302
+ const result = await runnerWithExe({
303
+ cwd: rootPath, task, model,
304
+ timeoutMs: cap.timeoutMs, maxToolCalls: cap.maxToolCalls, maxOutputBytes: cap.maxOutputBytes,
305
+ });
306
+
307
+ // 降级表第一行:环境级失败 → 整档中止,页状态不动,receipt 带原因。
308
+ if (!result.ok && isEnvironmentFailure(result)) {
309
+ receipt.t1Unavailable = result.reason;
310
+ break;
311
+ }
312
+ if (!result.ok) {
313
+ const after = store.recordChoroPageFailure(pageRow.id, T1_FAIL_THRESHOLD);
314
+ receipt.failed.push({ slug, reason: result.reason, failCount: after.fail_count, marked: after.status === "failed" });
315
+ continue;
316
+ }
317
+
318
+ // 输出契约(P16):严格 JSON + claims 锚点对账。
319
+ let parsed;
320
+ try {
321
+ parsed = parseT1Output(result.text);
322
+ } catch (err) {
323
+ const after = store.recordChoroPageFailure(pageRow.id, T1_FAIL_THRESHOLD);
324
+ receipt.failed.push({ slug, reason: `输出契约不满足:${String(err?.message ?? err)}`, failCount: after.fail_count, marked: after.status === "failed" });
325
+ continue;
326
+ }
327
+ const allowed = new Set(files);
328
+ const badClaims = parsed.claims.filter((c) => !allowed.has(normPath(c.sourcePath)));
329
+ if (badClaims.length > 0) {
330
+ const after = store.recordChoroPageFailure(pageRow.id, T1_FAIL_THRESHOLD);
331
+ receipt.failed.push({
332
+ slug,
333
+ reason: `claims 引用了模块外的文件:${badClaims.map((c) => c.sourcePath).slice(0, 3).join("、")}`,
334
+ failCount: after.fail_count,
335
+ marked: after.status === "failed",
336
+ });
337
+ continue;
338
+ }
339
+
340
+ // 富化 + 双落(P19/P20)。写路径断言复用 t0 的机械面语义:只许动
341
+ // .chorography/** 下的页面与 evidence。
342
+ const generatedAt = now();
343
+ enrichPageFile(pageFile, parsed.sections);
344
+ writeEvidenceFile(rootPath, slug, parsed.claims, generatedAt);
345
+ store.saveChoroPageClaims(pageRow.id, parsed.claims.map((c) => ({ ...c, createdAt: generatedAt })));
346
+ store.markChoroPageEnriched(pageRow.id, generatedAt);
347
+ receipt.enriched.push({ slug, sections: Object.keys(parsed.sections), claims: parsed.claims.length });
348
+ }
349
+ return receipt;
350
+ }
351
+
352
+ return { runT1 };
353
+ }
354
+
355
+ /* ------------------------------- 内部件 ------------------------------- */
356
+
357
+ function normPath(p) {
358
+ return String(p ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
359
+ }
360
+
361
+ /** 从骨架页「文件清单」表解析文件清单(T1 章程与 claims 对账的基准)。 */
362
+ function parseSkeletonFileList(pageText) {
363
+ const files = [];
364
+ let inTable = false;
365
+ for (const line of pageText.split("\n")) {
366
+ if (/^## /.test(line)) inTable = line.startsWith("## 文件清单");
367
+ else if (inTable && /^\|\s*([^|]+)\s*\|/.test(line) && !/^\|\s*文件/.test(line) && !/^\|\s*---/.test(line)) {
368
+ files.push(RegExp.$1.trim());
369
+ }
370
+ }
371
+ return files;
372
+ }
373
+
374
+ function buildCharter(repoName, slug, title, files) {
375
+ return [
376
+ `你在为仓库 ${repoName} 的仓库地志模块页「${title}」(slug=${slug})补写语义段落。`,
377
+ "规则:只读。不修改、不创建、不删除任何文件;不执行任何脚本。",
378
+ `该模块包含这些文件(claims 只许引用它们):`,
379
+ ...files.map((f) => `- ${f}`),
380
+ "读这些文件后,只输出一个 JSON 对象(不要 markdown 围栏、不要任何多余文字):",
381
+ '{"architecture":"该模块的职责与内部结构(<=200字)","convention":"它在仓库里遵守的约定(命名/错误处理/风格)","techStack":"该模块用到的关键库与技术点","setup":"构建/测试命令里与该模块相关的部分","claims":[{"statement":"一句话结论","kind":"architecture","sourcePath":"相对路径","startLine":1,"endLine":1}]}',
382
+ 'kind 只允许 architecture / convention / tech-stack / setup;sourcePath 必须用上面列出的相对路径(正斜杠);行号必须真实。',
383
+ 'claims 绝不允许引用上面清单之外的任何文件路径——跨模块的结论写进段落文字里即可,不带锚点。清单里的路径一个字都不要改写(不要加 ../../、不要换斜杠)。',
384
+ "页面骨架里已有的文件清单、入口、依赖信息不要复述。",
385
+ ].join("\n");
386
+ }
387
+
388
+ // 输出契约解析(P16):容忍模型偶尔把 JSON 包进围栏/前言——先剥围栏,再取首个
389
+ // `{` 到末个 `}` 之间的内容;之后 schema 校验(四段全字符串、claims 形状与 kind 枚举)。
390
+ function parseT1Output(text) {
391
+ let t = String(text ?? "").trim();
392
+ t = t.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
393
+ const start = t.indexOf("{");
394
+ const end = t.lastIndexOf("}");
395
+ if (start === -1 || end === -1 || end <= start) throw new Error("没有找到 JSON 对象");
396
+ let obj;
397
+ try {
398
+ obj = JSON.parse(t.slice(start, end + 1));
399
+ } catch (err) {
400
+ throw new Error(`JSON 解析失败:${String(err?.message ?? err).slice(0, 120)}`);
401
+ }
402
+ const sections = {};
403
+ for (const key of ["architecture", "convention", "techStack", "setup"]) {
404
+ const v = obj[key];
405
+ if (typeof v !== "string" || v.trim() === "") throw new Error(`缺段落 ${key} 或不是字符串`);
406
+ sections[key] = v.trim();
407
+ }
408
+ if (!Array.isArray(obj.claims)) throw new Error("缺 claims 数组");
409
+ const kinds = new Set(["architecture", "convention", "tech-stack", "setup"]);
410
+ const claims = obj.claims.map((c, i) => {
411
+ if (typeof c?.statement !== "string" || !c.statement.trim()) throw new Error(`claims[${i}] 缺 statement`);
412
+ if (!kinds.has(c?.kind)) throw new Error(`claims[${i}] kind 非法:${String(c?.kind)}`);
413
+ if (typeof c?.sourcePath !== "string" || !c.sourcePath.trim()) throw new Error(`claims[${i}] 缺 sourcePath`);
414
+ return {
415
+ statement: c.statement.trim(),
416
+ kind: c.kind,
417
+ sourcePath: normPath(c.sourcePath),
418
+ startLine: Number.isInteger(c.startLine) ? c.startLine : null,
419
+ endLine: Number.isInteger(c.endLine) ? c.endLine : null,
420
+ confidence: typeof c.confidence === "string" ? c.confidence : null,
421
+ };
422
+ });
423
+ return { sections, claims };
424
+ }
425
+
426
+ // 富化(P19):剥掉旧 T1 段 → 新段插在「## 生成说明」之前 → 保留区照旧页尾回填。
427
+ function enrichPageFile(pageFile, sections) {
428
+ let content = readFileSync(pageFile, "utf8");
429
+ // 剥旧 T1 段(含标记本身)
430
+ const wrap = new RegExp(`${escapeRe(T1_SECTION_START)}[\\s\\S]*?${escapeRe(T1_SECTION_END)}\\n?`, "g");
431
+ content = content.replace(wrap, "");
432
+ const block = [
433
+ "",
434
+ T1_SECTION_START,
435
+ "## 架构",
436
+ "",
437
+ sections.architecture,
438
+ "",
439
+ "## 约定",
440
+ "",
441
+ sections.convention,
442
+ "",
443
+ "## 技术栈",
444
+ "",
445
+ sections.techStack,
446
+ "",
447
+ "## 构建与测试",
448
+ "",
449
+ sections.setup,
450
+ "",
451
+ T1_SECTION_END,
452
+ "",
453
+ ].join("\n");
454
+ const anchor = content.indexOf("## 生成说明");
455
+ const base = anchor === -1
456
+ ? `${content.replace(/\n*$/, "\n")}\n${block}`
457
+ : content.slice(0, anchor) + block.replace(/^\n/, "") + content.slice(anchor);
458
+ const { blocks } = extractHumanBlocks(base);
459
+ const finalContent = blocks.length > 0 ? injectHumanBlocks(stripHumanBlocks(base), blocks) : base;
460
+ writeFileSync(pageFile, finalContent, "utf8");
461
+ }
462
+
463
+ // injectHumanBlocks 会把块接回页尾——剥掉正文里已有的块再交给它,保证块只在页尾出现一次。
464
+ function stripHumanBlocks(text) {
465
+ const re = new RegExp(`${escapeRe("<!-- CHORO:HUMAN:START -->")}[\\s\\S]*?${escapeRe("<!-- CHORO:HUMAN:END -->")}\\n?`, "g");
466
+ return text.replace(re, "");
467
+ }
468
+
469
+ function writeEvidenceFile(repoRoot, slug, claims, generatedAt) {
470
+ const rel = `${".chorography"}/evidence/${slug}.json`;
471
+ const abs = insideChoro(repoRoot, rel);
472
+ mkdirp(dirname(abs));
473
+ writeFileSync(abs, `${JSON.stringify({ slug, generatedAt, claims }, null, 2)}\n`, "utf8");
474
+ }
475
+
476
+ function insideChoro(rootPath, relPath) {
477
+ const abs = resolve(rootPath, relPath.split("/").join(sep));
478
+ const choroRoot = resolve(rootPath, ".chorography");
479
+ if (!(abs === choroRoot || abs.startsWith(choroRoot + sep))) {
480
+ throw new T1Refusal("write-outside-chorography", `写路径越出 .chorography/:${relPath}(红线 1)`);
481
+ }
482
+ return abs;
483
+ }
484
+
485
+ function mkdirp(dir) {
486
+ mkdirSync(dir, { recursive: true });
487
+ }
488
+
489
+ function basename(p) {
490
+ const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
491
+ return i === -1 ? p : p.slice(i + 1);
492
+ }
493
+
494
+ function escapeRe(text) {
495
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
496
+ }
@@ -107,9 +107,11 @@ export function extractTopicKeys(memory) {
107
107
  * 理由)。两个 reason 分开报是为了可解释:`blank` 是真的什么都没写(提交一个空表单),
108
108
  * `noise` 是写了但只有标点/空白(`...`、`---`)——两者的修法不同。
109
109
  *
110
- * 为什么不用 quality-filter 的 `repetitive`(dedupRatio < 0.3)一起判:那是**写入后**
111
- * 的评分扣分项,扣分可以错(只影响排序与归档),硬拒不能错。一条字符多样性低的正常
112
- * 记忆(同一种分隔符排出的长清单)会被它判成 repetitive,本判据不背这个误杀面。
110
+ * 为什么不用 quality-filter 的 `repetitive`(trigram 重复率 > 0.7)一起判:那是**写入
111
+ * 后**的评分扣分项,扣分可以错(只影响排序与归档),硬拒不能错。判据本身的误杀面已经
112
+ * 收窄了(本次改动换掉了按长度衰减的旧度量),但「谁该进库」与「进库后谁该排后面」
113
+ * 仍是两个决定:前者要用户改不了,后者只要排序可解释。重复片段里也可能藏着一条真
114
+ * 事实(同一句规则被追加两遍),这种东西该留、只是排后面。
113
115
  *
114
116
  * @param {{title?: string, content?: string}|null|undefined} memory
115
117
  * @returns {"blank"|"noise"|null} 命中即返回 reason,否则 null
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -16,6 +16,7 @@
16
16
  "icon": "assets/icon.svg",
17
17
  "bin": {
18
18
  "dsh-mneme": "bin/cli.mjs",
19
+ "dsh-mneme-choro": "bin/dsh-mneme-choro.mjs",
19
20
  "dsh-mneme-mcp": "bin/dsh-mneme-mcp.mjs",
20
21
  "dsh-mneme-serve": "bin/dsh-mneme-serve.mjs"
21
22
  },
@@ -106,7 +107,8 @@
106
107
  ]
107
108
  },
108
109
  "dependencies": {
109
- "morphicons": "^1.7.1"
110
+ "morphicons": "^1.7.1",
111
+ "yaml": "^2.9.1"
110
112
  },
111
113
  "overrides": {
112
114
  "adm-zip": "0.6.1",
@@ -18,6 +18,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
18
18
  import { homedir } from "node:os";
19
19
  import { TYPES } from "./store.js";
20
20
  import { bootstrapFromDirectory } from "./bootstrap.js";
21
+ import { parseBootstrapJson, readBootstrapDir, bootstrapReply } from "./bootstrap-http.js";
21
22
  import { PACKAGE_VERSION } from "./version-check.js";
22
23
  import { parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
23
24
  import { parseShareBody, shareReply, parseReceiveBody, receiveReply, parseUndoBody, undoReply } from "./share-http.js";
@@ -763,34 +764,29 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
763
764
  // --- POST /bootstrap: cold-start memories from repo files (#220) -------
764
765
  // 显式收 dir(插件没有工作区根目录概念,不猜路径);确定性解析零 LLM;
765
766
  // 幂等骑 saveWithDedupe 的 (type, title, scope) 去重 + _overwrite 刷新。
767
+ //
768
+ // 解析、校验、答复形状全走 bootstrap-http.js:#386 第 6 条给宿主内路由补了
769
+ // 第二张脸,按 #411 的纪律,两张皮只许剩鉴权与路径不同。这里刻意**不**再
770
+ // 自己 JSON.parse 一遍——真要写第二份,test/reclaim-http.test.js 那类静态锁
771
+ // 会转红,而漂移在两边都只表现为「某个错误码不一样了」。
766
772
  if (req.method === "POST" && pathname === "/bootstrap") {
767
773
  void readBody(req, res).then((text) => {
768
774
  if (text === null) return;
769
- let body;
770
- try {
771
- body = JSON.parse(text || "{}");
772
- } catch {
773
- sendJson(res, 400, { error: "invalid-json" });
774
- return;
775
- }
776
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
777
- sendJson(res, 400, { error: "invalid-body" });
775
+ const parsed = parseBootstrapJson(text);
776
+ if (!parsed.ok) {
777
+ sendJson(res, 400, { error: parsed.error });
778
778
  return;
779
779
  }
780
- if (typeof body.dir !== "string" || !body.dir.trim()) {
781
- sendJson(res, 400, { error: "missing-dir" });
780
+ const dirResult = readBootstrapDir(parsed.body);
781
+ if (!dirResult.ok) {
782
+ sendJson(res, 400, { error: dirResult.error });
782
783
  return;
783
784
  }
784
- bootstrapFromDirectory({ service, dir: body.dir, logger })
785
- .then((summary) => sendJson(res, 200, summary))
786
- .catch((error) => {
787
- if (error?.code) {
788
- sendJson(res, 400, { error: error.code });
789
- return;
790
- }
791
- logger?.warn?.(`[dsh-mneme] standalone API bootstrap failed: ${String(error)}`);
792
- sendJson(res, 500, { error: "internal" });
793
- });
785
+ bootstrapReply({
786
+ bootstrapper: ({ dir }) => bootstrapFromDirectory({ service, dir, logger }),
787
+ dir: dirResult.dir,
788
+ warn: (msg) => logger?.warn?.(msg)
789
+ }).then(({ status, body }) => sendJson(res, status, body));
794
790
  });
795
791
  return;
796
792
  }