@modusensus/dsh-mneme 0.8.6 → 0.8.8
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.
- package/README.md +19 -148
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +349 -61
- package/lib/config.js +118 -7
- package/lib/content-hash.js +54 -0
- package/lib/continuity.js +220 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/dream.js +138 -39
- package/lib/guide.js +18 -0
- package/lib/index.js +87 -12
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/peak-hours.js +117 -0
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +16 -2
- package/lib/store.js +318 -21
- package/lib/summarize.js +52 -113
- package/lib/tools.js +37 -11
- package/lib/write-admission.js +259 -0
- package/package.json +4 -4
- package/src/api-standalone.js +57 -1
- package/src/api.js +34 -11
- package/src/config.js +118 -7
- package/src/content-hash.js +54 -0
- package/src/continuity.js +220 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/dream.js +138 -39
- package/src/guide.js +18 -0
- package/src/index.js +87 -12
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/peak-hours.js +117 -0
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +16 -2
- package/src/store.js +318 -21
- package/src/summarize.js +52 -113
- package/src/tools.js +37 -11
- package/src/write-admission.js +259 -0
- package/test/api.test.js +21 -3
- package/test/client.test.js +103 -6
- package/test/content-hash.test.js +204 -0
- package/test/continuity.test.js +333 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/dream-failure-backoff.test.js +173 -0
- package/test/dream-peak-hours.test.js +194 -0
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/llm-audit.test.js +42 -0
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/reasoning-effort.test.js +6 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/summarize-reasoning-effort.test.js +189 -0
- package/test/write-admission.test.js +345 -0
package/src/index.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { createStore } from "./store.js";
|
|
2
2
|
import { createMirror, TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { createDocumentIndex } from "./document-index.js";
|
|
4
|
+
import { resolveDocumentDir } from "./document.js";
|
|
3
5
|
import { createService } from "./service.js";
|
|
6
|
+
// #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。
|
|
7
|
+
import { createWriteAdmission } from "./write-admission.js";
|
|
4
8
|
import { createTools } from "./tools.js";
|
|
5
9
|
import { createInjector } from "./inject.js";
|
|
10
|
+
import { createContinuityRescue } from "./continuity.js";
|
|
6
11
|
import { createSummarizer } from "./summarize.js";
|
|
7
12
|
import { createDreamScheduler } from "./dream.js";
|
|
8
13
|
import { createSleepScheduler, runSleep } from "./dream/sleep.js";
|
|
9
14
|
import { createApi } from "./api.js";
|
|
10
15
|
import { createStandaloneApi } from "./api-standalone.js";
|
|
16
|
+
// #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
|
|
17
|
+
import { createMaintenance } from "./maintenance.js";
|
|
11
18
|
import { createSettings } from "./settings.js";
|
|
12
19
|
import { createCommandManager } from "./commands.js";
|
|
13
20
|
import { createEmbedder } from "./embedding.js";
|
|
14
21
|
import { createEmbedderByProvider } from "./local-embedder.js";
|
|
15
22
|
import { LocalReranker } from "./reranker.js";
|
|
16
23
|
import { createVectorIndex } from "./vector-index.js";
|
|
17
|
-
import { Config, applyLightModePreset } from "./config.js";
|
|
24
|
+
import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
|
|
18
25
|
import { langOf } from "./lang.js";
|
|
19
26
|
import { extractEntities } from "./entities/extractor.js";
|
|
20
27
|
import { mkdirSync } from "node:fs";
|
|
@@ -193,15 +200,6 @@ export const apply = (ctx, config) => {
|
|
|
193
200
|
try {
|
|
194
201
|
store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
|
|
195
202
|
} catch { /* non-fatal */ }
|
|
196
|
-
// Bug8: enforce llm_audit_logs retention on boot (config.llmAudit.retentionDays,
|
|
197
|
-
// default 90). Best-effort like the failure prune — the audit trail is
|
|
198
|
-
// bookkeeping and a failed purge must never block plugin boot.
|
|
199
|
-
try {
|
|
200
|
-
if (rawCfg.llmAudit?.enabled !== false) {
|
|
201
|
-
const retentionMs = Number.isInteger(rawCfg.llmAudit?.retentionDays) ? rawCfg.llmAudit.retentionDays : 90;
|
|
202
|
-
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
203
|
-
}
|
|
204
|
-
} catch { /* non-fatal */ }
|
|
205
203
|
|
|
206
204
|
// User-configurable settings (profile, rules, panel mode, standalone API
|
|
207
205
|
// token) share the same SQLite file in dedicated tables, isolated from
|
|
@@ -240,10 +238,46 @@ export const apply = (ctx, config) => {
|
|
|
240
238
|
cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
|
|
241
239
|
}
|
|
242
240
|
|
|
241
|
+
// Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
|
|
242
|
+
// cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
|
|
243
|
+
// rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
|
|
244
|
+
// (raw 说关 → 不清理,cfg 说开 → 照写,审计表就无保留期地长)。保留期默认 90 天、
|
|
245
|
+
// 失败只 warn,与失败表清理同款:账本清理绝不许挡住插件启动。
|
|
246
|
+
try {
|
|
247
|
+
if (cfg.llmAudit?.enabled !== false) {
|
|
248
|
+
const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
|
|
249
|
+
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
250
|
+
}
|
|
251
|
+
} catch { /* non-fatal */ }
|
|
252
|
+
|
|
243
253
|
// 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
|
|
244
254
|
// sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
|
|
245
255
|
const mirror = createMirror(memoryDir, langOf(cfg));
|
|
246
|
-
|
|
256
|
+
|
|
257
|
+
// #296 第二批:managed 文档目录。解析与 memoryDir 同一套(空 = 跟随 memoryDir 的
|
|
258
|
+
// <memoryDir>/documents/,~ 展开,相对路径落在 memoryDir 下)。索引对象总是建:
|
|
259
|
+
// 闸关时它只承担「清掉陈旧 index.md」这一件事,与 documents.md 在闸关时被镜像删
|
|
260
|
+
// 掉同一口径。建目录与写索引失败都只 warn,不阻断插件加载(它们是机器产物)。
|
|
261
|
+
const documentDir = resolveDocumentDir(memoryDir, cfg.documentDir);
|
|
262
|
+
const documentIndex = createDocumentIndex(documentDir, langOf(cfg));
|
|
263
|
+
if (cfg.documentMemoryEnabled === true) {
|
|
264
|
+
const ensured = documentIndex.ensure();
|
|
265
|
+
if (!ensured.ok) {
|
|
266
|
+
ctx.logger?.warn?.(`[dsh-mneme] documentDir is not writable: ${documentDir}: ${ensured.error}`);
|
|
267
|
+
}
|
|
268
|
+
// 启动即渲染一次:索引不该等到第一次业务写才存在(删掉它之后重启也能自愈)。
|
|
269
|
+
const synced = documentIndex.sync(store.list({ type: "document", limit: null }));
|
|
270
|
+
if (!synced.ok) ctx.logger?.warn?.(`[dsh-mneme] document index sync failed: ${synced.error}`);
|
|
271
|
+
} else {
|
|
272
|
+
documentIndex.remove();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 写入准入实例:本批次只做计量(决策恒放行、写审计行),所以不需要新开关——它
|
|
276
|
+
// 不改变任何写入行为,也不新增拦截分支;既有的 llmAudit.enabled 关掉时它同样
|
|
277
|
+
// 不写(那个开关连审计行的启动期清理一起关掉)。第二阶段把拦截打开时才按仓库
|
|
278
|
+
// 惯例引入 opt-in 默认关的配置键,届时只改这一个实例的构造与 service 的调用点。
|
|
279
|
+
const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
|
|
280
|
+
const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
|
|
247
281
|
|
|
248
282
|
// F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
|
|
249
283
|
// a safe re-render at boot so a stale mirror converges without needing a
|
|
@@ -457,9 +491,37 @@ export const apply = (ctx, config) => {
|
|
|
457
491
|
thresholdChars: cfg.dreamThresholdChars,
|
|
458
492
|
delayMs: cfg.dreamDelayMs,
|
|
459
493
|
minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
|
|
494
|
+
// Issue #292:连续失败指数退避(opt-in,默认关 = 行为不变)。
|
|
495
|
+
failureBackoff: cfg.autoDreamFailureBackoff === true,
|
|
460
496
|
logger: ctx.logger,
|
|
461
497
|
semantic: { embedder, vectorIndex },
|
|
462
498
|
lastRunAtSeed: store.lastDreamRunAt("auto"),
|
|
499
|
+
// Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。
|
|
500
|
+
peakHours: cfg.dreamPeakHours ?? "",
|
|
501
|
+
peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120,
|
|
502
|
+
// 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的
|
|
503
|
+
// llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就
|
|
504
|
+
// 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。
|
|
505
|
+
auditPeakSkip: ({ count, chars }) => {
|
|
506
|
+
if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
|
|
507
|
+
const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : "";
|
|
508
|
+
service.saveLlmAudit({
|
|
509
|
+
timestamp: new Date().toISOString(),
|
|
510
|
+
trigger_source: "autoDream",
|
|
511
|
+
operation_type: "dream_consolidate",
|
|
512
|
+
model_id: modelId,
|
|
513
|
+
input_tokens: 0,
|
|
514
|
+
output_tokens: 0,
|
|
515
|
+
total_tokens: 0,
|
|
516
|
+
cost_usd: 0,
|
|
517
|
+
duration_ms: 0,
|
|
518
|
+
status: "skipped",
|
|
519
|
+
error_message: "peak-hours",
|
|
520
|
+
related_memory_ids: [],
|
|
521
|
+
// 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。
|
|
522
|
+
metadata: JSON.stringify({ count, chars })
|
|
523
|
+
});
|
|
524
|
+
},
|
|
463
525
|
onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
|
|
464
526
|
});
|
|
465
527
|
service.setDreamHook(() => dream.maybeSchedule(service));
|
|
@@ -526,6 +588,13 @@ export const apply = (ctx, config) => {
|
|
|
526
588
|
if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
|
|
527
589
|
});
|
|
528
590
|
|
|
591
|
+
// #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
|
|
592
|
+
// 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
|
|
593
|
+
// 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
|
|
594
|
+
if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
|
|
595
|
+
disposers.push(createContinuityRescue(ctx, store));
|
|
596
|
+
}
|
|
597
|
+
|
|
529
598
|
ctx.inject(["tools"], (toolsCtx) => {
|
|
530
599
|
disposers.push(createTools(toolsCtx, service, cfg, embedder));
|
|
531
600
|
});
|
|
@@ -549,13 +618,19 @@ export const apply = (ctx, config) => {
|
|
|
549
618
|
disposers.push(api.dispose);
|
|
550
619
|
}
|
|
551
620
|
|
|
621
|
+
// #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
|
|
622
|
+
// 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
|
|
623
|
+
// 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
|
|
624
|
+
// 抢锁的那条路走不通)。
|
|
625
|
+
const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
|
|
626
|
+
|
|
552
627
|
// Standalone external API (v0.7.12): plain node:http server for ecosystem
|
|
553
628
|
// integrations outside the DSH host. Persisted external_api settings win
|
|
554
629
|
// over the bundle config (enabled/port); the Bearer token lives in the same
|
|
555
630
|
// kv and is auto-generated on first boot by createStandaloneApi. Binding a
|
|
556
631
|
// non-loopback host is the operator's documented responsibility.
|
|
557
632
|
if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
|
|
558
|
-
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings });
|
|
633
|
+
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings, maintenance });
|
|
559
634
|
disposers.push(() => standalone.server.close());
|
|
560
635
|
standalone.ready.catch((error) => {
|
|
561
636
|
ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
|
package/src/inject.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHotMemory } from "./hot-memory.js";
|
|
|
3
3
|
import { STR, langOf } from "./lang.js";
|
|
4
4
|
import { MEMORY_GUIDE_SECTION } from "./guide.js";
|
|
5
5
|
import { adaptiveInjectBudget } from "./search/adaptive.js";
|
|
6
|
+
import { injectChildEnabled } from "./config.js";
|
|
6
7
|
|
|
7
8
|
// Issue #179:注入预览的数据底座。systemPrompt 渲染是同步回调,面板只能事后
|
|
8
9
|
// 拉取,所以在这里旁路缓存「最近一帧组装」——快照就是本次渲染用过的同一份
|
|
@@ -283,12 +284,15 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
283
284
|
return escapePromptVars(lines.join("\n"));
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
// #249
|
|
287
|
+
// #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例
|
|
287
288
|
// (ACP 的 ACP_SYSTEM_PROMPT 与 mnemon 的 routing 段都在 150)。文本是常量、
|
|
288
289
|
// 同会话内不随轮次变化,故不影响其后的前缀缓存,也不逐轮复读。宿主若不提供
|
|
289
290
|
// section seam 就静默跳过——#249 §3 的口径是「只用现成 seam,拿不到位的语义
|
|
290
291
|
// 降级处理」:能力说明仍落在工具描述上,不算失败。
|
|
291
|
-
|
|
292
|
+
// 走父/子闸门(#249 第二批):注入器本身只在 `autoInject` 开时挂载,这里是
|
|
293
|
+
// 第二道、也是同一个判据的显式落点——两处消费点共用 injectChildEnabled,
|
|
294
|
+
// 不在各自的地方重写「父关则子不生效」。
|
|
295
|
+
const guideSection = injectChildEnabled(config, "injectGuidanceEnabled") && typeof ctx.systemPrompt?.section === "function"
|
|
292
296
|
? ctx.systemPrompt.section({ name: "memory-guide", order: 150, text: MEMORY_GUIDE_SECTION })
|
|
293
297
|
: null;
|
|
294
298
|
|
package/src/lang.js
CHANGED
|
@@ -196,12 +196,23 @@ export const STR = {
|
|
|
196
196
|
|
|
197
197
|
// --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
|
|
198
198
|
mirrorLabel: {
|
|
199
|
-
zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源" },
|
|
200
|
-
en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source" }
|
|
199
|
+
zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源", scope: "作用域", sensitivity: "敏感度" },
|
|
200
|
+
en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source", scope: "Scope", sensitivity: "Sensitivity" }
|
|
201
201
|
},
|
|
202
202
|
mirrorHeader: {
|
|
203
|
-
zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!--
|
|
204
|
-
en: (name) => `# ${name} — dsh-mneme mirror\n\n<!--
|
|
203
|
+
zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!-- 条目标题与正文可编辑,会被合并回记忆库(人工优先);文件头与条目元数据行由机器维护,改动会在下次同步时被覆盖。 -->\n\n`,
|
|
204
|
+
en: (name) => `# ${name} — dsh-mneme mirror\n\n<!-- Entry titles and bodies are editable and merged back into the memory store (human edits win); the file header and entry metadata lines are machine-owned and get overwritten on the next sync. -->\n\n`
|
|
205
|
+
},
|
|
206
|
+
// document 的镜像只有指针行(#296 第二批):没有可编辑的正文,手工改动一律被
|
|
207
|
+
// 下次同步覆盖——所以不能复用上面那句「可编辑、会被合并回记忆库」。
|
|
208
|
+
mirrorReadonlyHeader: {
|
|
209
|
+
zh: (name) => `# ${name} — dsh-mneme 只读视图\n\n<!-- 只含指针行:id + 标题 + 摘要首句 + 文件路径,不含正文。正文在路径指向的文件里,批注请写进记忆库;本文件由机器维护,手工改动会在下次同步时被覆盖。 -->\n\n`,
|
|
210
|
+
en: (name) => `# ${name} — dsh-mneme read-only view\n\n<!-- Pointer rows only: id + title + first sentence of the summary + file path, never the full text. The document itself lives at that path, annotations belong in the memory store, and this file is machine-owned: hand edits are overwritten on the next sync. -->\n\n`
|
|
211
|
+
},
|
|
212
|
+
// documentDir 的 index.md(#296 第二批):整文件机器所有、可从库重建。
|
|
213
|
+
documentIndexHeader: {
|
|
214
|
+
zh: () => "# document 索引 — dsh-mneme\n\n<!-- 整文件机器所有,可从记忆库随时重建(所以这里不写生成时间);要批注请写进记忆库(memory_save)。managed = 文件在 documentDir 内。 -->\n\n",
|
|
215
|
+
en: () => "# document index — dsh-mneme\n\n<!-- Machine-owned as a whole and rebuildable from the memory store at any time (which is why it carries no generation timestamp). Annotations belong in the memory store (memory_save). managed = the file sits inside documentDir. -->\n\n"
|
|
205
216
|
},
|
|
206
217
|
|
|
207
218
|
prompts: PROMPTS
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// #275 存储生命周期第一批:无损回收的维护入口。
|
|
2
|
+
//
|
|
3
|
+
// 为什么是「手动入口 + dry-run + receipt」而不是后台任务:这两步都是不可逆的内容
|
|
4
|
+
// 丢弃(历史 run 的输入快照置空、归档行的向量置空),该由人在看得见数字的时候按下
|
|
5
|
+
// (维护者 2026-09-23 拍板:不挂启动自动跑,VACUUM 约 0.8s 说明它就该是个随手跑的
|
|
6
|
+
// 东西)。所以模块里没有任何定时器,挂点只有两个:standalone 数据面的
|
|
7
|
+
// `POST /maintenance/reclaim` 与它上面的 `dsh-mneme reclaim` 子命令。
|
|
8
|
+
//
|
|
9
|
+
// 两项都零价值判断、零条数变化:
|
|
10
|
+
// A `dream_runs.input` 置空。这一列的含义按 run_type 分叉:auto / sleep 的 run 存的
|
|
11
|
+
// 是当时的记忆库快照,可由记忆库重建;**organize 的 run 存的是 apply 的重放载荷**,
|
|
12
|
+
// 置空会让那次 apply 静默空转并把报告锁死,所以 organize 行不在可清范围内(谓词
|
|
13
|
+
// 在 store.dreamRunInputStats / clearDreamRunInputs 里)。run 的骨架、LLM 决策原文
|
|
14
|
+
// 与 receipt 一律保留,行永不删(拍板 3:裁列同意、删行不同意)。
|
|
15
|
+
// B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是
|
|
16
|
+
// 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。
|
|
17
|
+
//
|
|
18
|
+
// 报告口径(拍板 2):收益按 VACUUM 前后体积量,不按列字节估——实际释放来自溢出页与
|
|
19
|
+
// 索引页回收,列文本大小只是上界,所以两个数分开报,不混成一个「省了多少」。
|
|
20
|
+
//
|
|
21
|
+
// 与 #254 的分界(拍板 4):本入口止体积、不止重复。归档区里同主题堆积是出口问题,
|
|
22
|
+
// 归 #254 的写入准入与后续的整理动作面,这里一条记忆都不碰。
|
|
23
|
+
export const RECLAIM_TRIGGER_SOURCE = "maintenance";
|
|
24
|
+
export const RECLAIM_OPERATION_TYPE = "storage_reclaim";
|
|
25
|
+
// 输入快照的默认保留窗口:7 天内仍可能被离线回放(近期 run 的决策上下文),更早的
|
|
26
|
+
// 重建成本低于留存成本。窗口由调用方显式给,不做成配置键——配置键意味着有个周期性
|
|
27
|
+
// 消费者,而本批次刻意不接周期动作。
|
|
28
|
+
export const DEFAULT_INPUT_RETENTION_DAYS = 7;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 无损回收(#275 第一批)。返回一个函数:同一条代码路径跑 dry-run 与执行,dry-run
|
|
32
|
+
* 只是不落 UPDATE/VACUUM,所以「报出来的数字」与「执行时的数字」出自同一处统计。
|
|
33
|
+
*
|
|
34
|
+
* @param {object} deps
|
|
35
|
+
* @param {object} deps.store 存储层(dreamRunInputStats / clearDreamRunInputs /
|
|
36
|
+
* archivedEmbeddingStats / clearArchivedEmbeddings / storageStats / vacuum / saveLlmAudit)
|
|
37
|
+
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled:关掉 audit 时不落 receipt)
|
|
38
|
+
* @param {object} [deps.logger]
|
|
39
|
+
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
40
|
+
*/
|
|
41
|
+
export function createMaintenance({ store, config, logger, now = Date.now } = {}) {
|
|
42
|
+
const warn = (msg) => {
|
|
43
|
+
try {
|
|
44
|
+
logger?.warn?.(msg);
|
|
45
|
+
} catch { /* 日志故障不能影响回收本身 */ }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** receipt 落到 llm_audit_logs(既有 bookkeeping 表,不触发写钩子)。 */
|
|
49
|
+
function writeReceipt(report) {
|
|
50
|
+
if (config?.llmAudit?.enabled === false) return null;
|
|
51
|
+
try {
|
|
52
|
+
const row = store.saveLlmAudit({
|
|
53
|
+
trigger_source: RECLAIM_TRIGGER_SOURCE,
|
|
54
|
+
operation_type: RECLAIM_OPERATION_TYPE,
|
|
55
|
+
// 不是模型调用,但该列 NOT NULL——用显式占位串,免得混进按路由分组的花费统计。
|
|
56
|
+
model_id: "-",
|
|
57
|
+
status: "success",
|
|
58
|
+
metadata: {
|
|
59
|
+
older_than_days: report.olderThanDays,
|
|
60
|
+
cleared: report.cleared,
|
|
61
|
+
size_before: report.size.before_bytes,
|
|
62
|
+
size_after: report.size.after_bytes,
|
|
63
|
+
vacuum: report.vacuum.ran,
|
|
64
|
+
duration_ms: report.duration_ms
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return row?.id ?? null;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// 回执失败只 warn:清理已经发生了,不能因为记不上账就当作没发生(也不能反噬)。
|
|
70
|
+
warn(`[dsh-mneme] storage reclaim receipt failed: ${String(e)}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} [opts]
|
|
77
|
+
* @param {number} [opts.olderThanDays] 输入快照的保留窗口(天);0 = 清掉全部快照
|
|
78
|
+
* @param {boolean} [opts.vacuum] 是否在清理后 VACUUM(默认否:它要排他锁,由人决定)
|
|
79
|
+
* @param {boolean} [opts.dryRun] 默认 true——只报数字,不改一个字节
|
|
80
|
+
* @returns {object} 报告(dry-run 与执行同形状,执行时多 receipt 字段)
|
|
81
|
+
*/
|
|
82
|
+
function reclaim({ olderThanDays = DEFAULT_INPUT_RETENTION_DAYS, vacuum = false, dryRun = true } = {}) {
|
|
83
|
+
const days = Number.isFinite(olderThanDays) && olderThanDays >= 0 ? olderThanDays : DEFAULT_INPUT_RETENTION_DAYS;
|
|
84
|
+
const cutoff = new Date(now() - days * 86400000).toISOString();
|
|
85
|
+
const started = now();
|
|
86
|
+
const before = store.storageStats();
|
|
87
|
+
const inputs = store.dreamRunInputStats(cutoff);
|
|
88
|
+
const embeddings = store.archivedEmbeddingStats();
|
|
89
|
+
const report = {
|
|
90
|
+
dryRun,
|
|
91
|
+
olderThanDays: days,
|
|
92
|
+
cutoff,
|
|
93
|
+
// planned = 这次会动几行;cleared = 实际动了几行(dry-run 恒 0)
|
|
94
|
+
planned: { dream_run_inputs: inputs.runs, archived_embeddings: embeddings.rows },
|
|
95
|
+
// 列文本大小:上界,不是回收量(真实释放看下面的 size 差值)
|
|
96
|
+
column_bytes: { dream_run_inputs: inputs.bytes, archived_embeddings: embeddings.bytes },
|
|
97
|
+
cleared: { dream_run_inputs: 0, archived_embeddings: 0 },
|
|
98
|
+
size: {
|
|
99
|
+
before_bytes: before.fileBytes,
|
|
100
|
+
after_bytes: before.fileBytes,
|
|
101
|
+
// 空闲页就是「现在 VACUUM 能立刻收回来的那部分」,带上它 dry-run 才有个能
|
|
102
|
+
// 参照的量(真实数字仍然只有做完才知道)
|
|
103
|
+
freelist_pages: before.freelistCount,
|
|
104
|
+
freelist_bytes: before.freelistCount * before.pageSize,
|
|
105
|
+
page_count: before.pageCount
|
|
106
|
+
},
|
|
107
|
+
vacuum: { requested: vacuum === true, ran: false, duration_ms: null, error: null },
|
|
108
|
+
receipt: null,
|
|
109
|
+
duration_ms: 0
|
|
110
|
+
};
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
report.duration_ms = now() - started;
|
|
113
|
+
return report;
|
|
114
|
+
}
|
|
115
|
+
report.cleared.dream_run_inputs = store.clearDreamRunInputs(cutoff);
|
|
116
|
+
report.cleared.archived_embeddings = store.clearArchivedEmbeddings();
|
|
117
|
+
if (report.vacuum.requested) {
|
|
118
|
+
// VACUUM 失败不能把前面的清理一起吞掉:两个 UPDATE 已经各自提交了,此时抛出去
|
|
119
|
+
// 就是「数据已经不可逆地清了,但没有任何回执」。所以失败记进报告、回执照写,
|
|
120
|
+
// 让调用方看到「清了 N 行,VACUUM 没跑成」——锁被别的进程占着(SQLITE_BUSY)是
|
|
121
|
+
// 最常见的成因,重跑一次 --apply --vacuum 即可(清理是幂等的)。
|
|
122
|
+
try {
|
|
123
|
+
const result = store.vacuum();
|
|
124
|
+
report.vacuum.ran = true;
|
|
125
|
+
report.vacuum.duration_ms = result.duration_ms;
|
|
126
|
+
} catch (e) {
|
|
127
|
+
report.vacuum.error = String(e);
|
|
128
|
+
warn(`[dsh-mneme] storage reclaim vacuum failed: ${String(e)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const after = store.storageStats();
|
|
132
|
+
report.size.after_bytes = after.fileBytes;
|
|
133
|
+
report.size.freelist_pages = after.freelistCount;
|
|
134
|
+
report.size.freelist_bytes = after.freelistCount * after.pageSize;
|
|
135
|
+
report.size.page_count = after.pageCount;
|
|
136
|
+
report.duration_ms = now() - started;
|
|
137
|
+
report.receipt = writeReceipt(report);
|
|
138
|
+
return report;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { reclaim };
|
|
142
|
+
}
|
package/src/mirror.js
CHANGED
|
@@ -2,17 +2,37 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { STR, langOf } from "./lang.js";
|
|
5
|
+
import { PACKAGE_VERSION } from "./version-check.js";
|
|
5
6
|
|
|
6
7
|
export const TYPE_FILE = {
|
|
7
8
|
preference: "preferences.md",
|
|
8
9
|
project: "projects.md",
|
|
9
10
|
decision: "decisions.md",
|
|
10
11
|
history: "history.md",
|
|
11
|
-
summary: "summary.md"
|
|
12
|
+
summary: "summary.md",
|
|
13
|
+
pitfall: "pitfalls.md",
|
|
14
|
+
constraint: "constraints.md",
|
|
15
|
+
rejected_solution: "rejected-solutions.md",
|
|
16
|
+
pattern: "patterns.md",
|
|
17
|
+
document: "documents.md"
|
|
12
18
|
};
|
|
13
19
|
|
|
20
|
+
// 不落镜像的 type。现在是空集:document 由 #296 第二批收进来——第一版排除它的
|
|
21
|
+
// 理由是「镜像块渲染的字段里没有 doc_path,落一个 documents.md 会得到看着完整、
|
|
22
|
+
// 其实找不到文件的视图」,doc_path 进指针行以后这条理由不再成立。这个集合由
|
|
23
|
+
// test/mirror-types.test.js 钉死(TYPES \ TYPE_FILE 必须恰好等于本集合):将来
|
|
24
|
+
// 新增 type 时必须显式决定它落不落镜像,静默漏掉才是 #278 要修的那种盲区。
|
|
25
|
+
export const MIRROR_EXCLUDED_TYPES = new Set([]);
|
|
26
|
+
|
|
27
|
+
// 只读视图(#296):document 的镜像只有指针行(id + 标题 + 摘要首句 + doc_path),
|
|
28
|
+
// 没有可编辑的正文,所以它不参与 readHumanEdits 的人改合并,手工改动一律被下次
|
|
29
|
+
// 同步覆盖;导出/导入也不带它(那两个是 round-trip 通道,见 api.js)。
|
|
30
|
+
export const MIRROR_READONLY_TYPES = new Set(["document"]);
|
|
31
|
+
|
|
14
32
|
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
15
33
|
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
34
|
+
// 只读视图里摘要首句的字符上限(#296):够定位就行,长摘要不该把一行撑成一段。
|
|
35
|
+
const POINTER_MAX = 120;
|
|
16
36
|
|
|
17
37
|
function esc(text) {
|
|
18
38
|
return String(text).replace(ESCAPE, "\\$1");
|
|
@@ -22,6 +42,36 @@ function unescape(text) {
|
|
|
22
42
|
return String(text).replace(UNESCAPE, "$1");
|
|
23
43
|
}
|
|
24
44
|
|
|
45
|
+
// 元数据标签的唯一来源是 STR.mirrorLabel(lang.js):渲染按实例语言取,解析两套
|
|
46
|
+
// 都认。下面的正则从那张表现算——旧实现把手写正则与 lang.js 的两处文案分开维护,
|
|
47
|
+
// 加一个字段要改三处,漏改一处那一行就会随 /import 写进 entry 的 content。
|
|
48
|
+
const META_LABELS = [];
|
|
49
|
+
for (const language of Object.keys(STR.mirrorLabel)) META_LABELS.push(...Object.values(STR.mirrorLabel[language]));
|
|
50
|
+
|
|
51
|
+
function reEsc(text) {
|
|
52
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 元数据行的值必须单行:值里带换行会多出一行,轻则破坏「整段元数据行」的解析契约,
|
|
56
|
+
// 重则伪造出一个条目头(sensitivity 是调用方传入的任意字符串,属信任边界)。
|
|
57
|
+
function oneLine(text) {
|
|
58
|
+
return String(text).replace(/\s+/g, " ").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 结构头锚:ID 行紧跟类型行才算条目头(正文里机器格式的 ID 行不会切开块)。
|
|
62
|
+
const ANCHOR_RE = new RegExp(
|
|
63
|
+
"^- \\*\\*ID\\*\\*: `([^`]+)`\\n- \\*\\*(?:" +
|
|
64
|
+
[STR.mirrorLabel.zh.type, STR.mirrorLabel.en.type].map(reEsc).join("|") +
|
|
65
|
+
")\\*\\*:", "gm"
|
|
66
|
+
);
|
|
67
|
+
// 渲染出的整段元数据行(加字段只需改 lang.js 的 mirrorLabel)。
|
|
68
|
+
const META_RUN_RE = new RegExp(`^(- \\*\\*(?:${META_LABELS.map(reEsc).join("|")})\\*\\*:.*\\n?)+`);
|
|
69
|
+
// 三方合并用的版本令牌(service.syncMirror 比对并发写)。
|
|
70
|
+
const UPDATED_RE = new RegExp(
|
|
71
|
+
"- \\*\\*(?:" + [STR.mirrorLabel.zh.updated, STR.mirrorLabel.en.updated].map(reEsc).join("|") +
|
|
72
|
+
")\\*\\*: ([^\\n]+)"
|
|
73
|
+
);
|
|
74
|
+
|
|
25
75
|
function renderMemory(m, language = "zh") {
|
|
26
76
|
// last-rendered digest baseline: sha256(title \x00 content). service.js
|
|
27
77
|
// compares the file hash against this to tell "untouched by a human" (machine
|
|
@@ -40,6 +90,12 @@ function renderMemory(m, language = "zh") {
|
|
|
40
90
|
lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
41
91
|
lines.push(`- **${ML.updated}**: ${m.updated_at}`);
|
|
42
92
|
if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
|
|
93
|
+
// scope / sensitivity 是只读展示字段(#278:sensitivity 的过滤语义另有归属,见
|
|
94
|
+
// 那条讨论;这里只把值摆出来)。只在真有值时渲染——本机 2843 条里只有 37 条带
|
|
95
|
+
// scope 标签,无条件渲染会给每条目多添两行空字段。
|
|
96
|
+
const scopeText = [m.agent_scope, m.workspace_scope].filter(Boolean).map(oneLine).join(" / ");
|
|
97
|
+
if (scopeText) lines.push(`- **${ML.scope}**: ${scopeText}`);
|
|
98
|
+
if (m.sensitivity) lines.push(`- **${ML.sensitivity}**: ${oneLine(m.sensitivity)}`);
|
|
43
99
|
lines.push("");
|
|
44
100
|
lines.push(`<!-- mirror-digest: ${digest} -->`);
|
|
45
101
|
lines.push(m.content);
|
|
@@ -49,22 +105,76 @@ function renderMemory(m, language = "zh") {
|
|
|
49
105
|
return lines.join("\n");
|
|
50
106
|
}
|
|
51
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 文件头 frontmatter(#278 第一批)。按 type 分文件时一个文件装 N 条,所以
|
|
110
|
+
* frontmatter 只能落在文件级;条目级元数据仍是块内的 `- **字段**:` 行(见
|
|
111
|
+
* renderMemory)。
|
|
112
|
+
*
|
|
113
|
+
* `covered` / `coverage` 是覆盖范围的自述,必须与这份文本**实际包含的行集**一致:
|
|
114
|
+
* 磁盘镜像写活跃集(active-only),/export 的文档写全表(all)。说一套写一套比
|
|
115
|
+
* 没有这个字段更糟——外部工具会照着它聚合条数与标签。
|
|
116
|
+
*
|
|
117
|
+
* 键名对齐 OKF v0.2:`type` 是 §4.1 唯一必填键,`tags` 是 §4.1 的推荐键,
|
|
118
|
+
* `generated.by` / `generated.at` 是 §5.2 的 trust 族。`generated.by` 按 §7 的
|
|
119
|
+
* actor 约定写 `<producer>/<version>`(所以带版本号);`generated.at` 按 §5.2 的
|
|
120
|
+
* 语义只记「内容上次真正变化」,sync 因此只在正文变化时才落盘。v0.2 已用
|
|
121
|
+
* `generated.at` 取代 v0.1 的 `timestamp`,没有 `updated` 这个顶层键。
|
|
122
|
+
* `covered` / `coverage` 是 §4.1 允许的 producer 自定义扩展。
|
|
123
|
+
*/
|
|
124
|
+
export function renderFileHeader(type, items, coverage = "active-only") {
|
|
125
|
+
const tags = [...new Set(items.flatMap((m) => (m.tags ?? []).map(String)))].sort();
|
|
126
|
+
return [
|
|
127
|
+
"---",
|
|
128
|
+
`type: ${type}`,
|
|
129
|
+
`generated.by: dsh-mneme/${PACKAGE_VERSION}`,
|
|
130
|
+
`generated.at: ${new Date().toISOString()}`,
|
|
131
|
+
`covered: ${items.length}`,
|
|
132
|
+
`coverage: ${coverage}`,
|
|
133
|
+
`tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
|
|
134
|
+
"---",
|
|
135
|
+
""
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* document 的只读视图一行(#296):一句话定位 + 文件指针,正文按 doc_path 去读。
|
|
141
|
+
* 摘要首句按句末标点切,切不出来就整段(超长再按字符数兜底),保证「一行一个
|
|
142
|
+
* 文档」的形态不被长摘要撑破。
|
|
143
|
+
*/
|
|
144
|
+
function renderPointer(m) {
|
|
145
|
+
const flat = String(m.content ?? "").replace(/\s+/g, " ").trim();
|
|
146
|
+
// 句末标点切首句:中文的。!? 后面通常不跟空格,不能像英文那样要求一个空白
|
|
147
|
+
// (要求了就一句都切不出来,整段摘要被当成「首句」塞进视图)。英文的 . 仍要求
|
|
148
|
+
// 后接空白或结尾,免得把 e.g. / v1.2 这类点号当成句末。
|
|
149
|
+
const first = flat.match(/^.*?(?:[。!?]|[!?]|[.](?=\s|$))|^.*$/s)?.[0] ?? flat;
|
|
150
|
+
// 按码点切,不按 UTF-16 码元:后者会在 emoji / 代理对中间落刀,落盘成 U+FFFD。
|
|
151
|
+
const chars = [...first];
|
|
152
|
+
const summary = chars.length > POINTER_MAX ? `${chars.slice(0, POINTER_MAX).join("")}…` : first;
|
|
153
|
+
const where = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
|
|
154
|
+
return `- \`${m.id}\` **${oneLine(m.title)}**:${oneLine(summary)}${where}\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
52
157
|
/**
|
|
53
158
|
* Render one type's memories into exactly the mirror-file text (header +
|
|
54
159
|
* per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
|
|
55
|
-
* the /export endpoint
|
|
56
|
-
*
|
|
57
|
-
*
|
|
160
|
+
* the /export endpoint reuses the same entry blocks for its per-type sections,
|
|
161
|
+
* so an exported markdown round-trips back through parseHumanEdits →
|
|
162
|
+
* mergeHumanEdits. Unknown type → undefined.
|
|
163
|
+
*
|
|
164
|
+
* `fileHeader: false` 供 /export 用:一个文档只能有一个 frontmatter 块(在文档
|
|
165
|
+
* 最前,见 api.js 的 /export),所以分节不再各自带文件头。只读 type(#296 的
|
|
166
|
+
* document)只走磁盘镜像这一条:渲染指针行 + 只读文件头,导出侧不取它。
|
|
58
167
|
*/
|
|
59
|
-
export function renderMirrorText(type, memories, language = "zh") {
|
|
168
|
+
export function renderMirrorText(type, memories, language = "zh", { fileHeader = true } = {}) {
|
|
60
169
|
const name = TYPE_FILE[type];
|
|
61
170
|
if (!name) return undefined;
|
|
62
171
|
const items = (memories ?? [])
|
|
63
172
|
.slice()
|
|
64
173
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
174
|
+
const readonly = MIRROR_READONLY_TYPES.has(type);
|
|
175
|
+
const header = readonly ? STR.mirrorReadonlyHeader[language](name) : STR.mirrorHeader[language](name);
|
|
176
|
+
const body = items.map((m) => (readonly ? renderPointer(m) : renderMemory(m, language))).join("\n");
|
|
177
|
+
return (fileHeader ? renderFileHeader(type, items) : "") + header + body;
|
|
68
178
|
}
|
|
69
179
|
|
|
70
180
|
/**
|
|
@@ -91,7 +201,7 @@ export function parseHumanEdits(text) {
|
|
|
91
201
|
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
92
202
|
// or produces a ghost entry.
|
|
93
203
|
// 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
|
|
94
|
-
const anchors = [...normalized.matchAll(
|
|
204
|
+
const anchors = [...normalized.matchAll(ANCHOR_RE)];
|
|
95
205
|
let prevEnd = 0;
|
|
96
206
|
for (let i = 0; i < anchors.length; i++) {
|
|
97
207
|
const anchor = anchors[i];
|
|
@@ -109,7 +219,7 @@ export function parseHumanEdits(text) {
|
|
|
109
219
|
let body = normalized
|
|
110
220
|
.slice(blockStart, blockEnd)
|
|
111
221
|
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
112
|
-
.replace(
|
|
222
|
+
.replace(META_RUN_RE, "")
|
|
113
223
|
.replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
|
|
114
224
|
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
115
225
|
const lastSep = separators[separators.length - 1];
|
|
@@ -120,7 +230,7 @@ export function parseHumanEdits(text) {
|
|
|
120
230
|
// render time — the version token for detecting a concurrent store write
|
|
121
231
|
// during a three-way merge of human edits (see service.syncMirror).
|
|
122
232
|
const block = normalized.slice(blockStart, blockEnd);
|
|
123
|
-
const updatedMatch = block.match(
|
|
233
|
+
const updatedMatch = block.match(UPDATED_RE);
|
|
124
234
|
const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
|
|
125
235
|
edits.push({
|
|
126
236
|
id: anchor[1],
|
|
@@ -136,6 +246,15 @@ export function parseHumanEdits(text) {
|
|
|
136
246
|
return edits;
|
|
137
247
|
}
|
|
138
248
|
|
|
249
|
+
/**
|
|
250
|
+
* 判断「这份文本的内容有没有变」时忽略 generated.at:它每次渲染都是新时间戳,
|
|
251
|
+
* 带上它就永远判为变化。其余字节必须逐字相同才算没变——镜像的幂等性(同样的库
|
|
252
|
+
* 渲染两次得到同样的文件)才是可断言的。
|
|
253
|
+
*/
|
|
254
|
+
function stripGeneratedAt(text) {
|
|
255
|
+
return String(text).replace(/^generated\.at: .*$/m, "");
|
|
256
|
+
}
|
|
257
|
+
|
|
139
258
|
export function createMirror(dir, language = "zh") {
|
|
140
259
|
mkdirSync(dir, { recursive: true });
|
|
141
260
|
|
|
@@ -150,7 +269,10 @@ export function createMirror(dir, language = "zh") {
|
|
|
150
269
|
* this wrapper only owns the "read file → text" side.
|
|
151
270
|
*/
|
|
152
271
|
function readHumanEdits(type = undefined) {
|
|
153
|
-
|
|
272
|
+
// 只读 type 不参与人改合并(#296):它们的文件里没有可编辑的正文,解析出来的
|
|
273
|
+
// 指针行文本若被当成「人改」写回库,会污染摘要。整类跳过,包括 type 指定的
|
|
274
|
+
// 那条调用路径。
|
|
275
|
+
const types = (type ? [type] : Object.keys(TYPE_FILE)).filter((t) => !MIRROR_READONLY_TYPES.has(t));
|
|
154
276
|
const edits = [];
|
|
155
277
|
for (const t of types) {
|
|
156
278
|
const file = filePath(t);
|
|
@@ -182,9 +304,19 @@ export function createMirror(dir, language = "zh") {
|
|
|
182
304
|
// memories do not "resurrect" via readHumanEdits
|
|
183
305
|
rmSync(file, { force: true });
|
|
184
306
|
} else {
|
|
185
|
-
// 渲染走 renderMirrorText(与 /export
|
|
186
|
-
|
|
187
|
-
|
|
307
|
+
// 渲染走 renderMirrorText(与 /export 共用同一条渲染路径)。
|
|
308
|
+
const text = renderMirrorText(type, items, language);
|
|
309
|
+
// 内容没变就不落盘。generated.at 每次渲染都新,无脑写会让编辑器、同步盘
|
|
310
|
+
// 与 git 在每次业务写后看到全部镜像文件「被外部修改」,而内容其实一字未
|
|
311
|
+
// 变;按 OKF 的语义它只该记内容变化,未变就保留旧值。这也是同步读 9 个
|
|
312
|
+
// 小文件的代价,比无谓写 9 次盘便宜。
|
|
313
|
+
let prev = null;
|
|
314
|
+
try {
|
|
315
|
+
prev = readFileSync(file, "utf8");
|
|
316
|
+
} catch { /* 首次写:读不到就是没有旧文件 */ }
|
|
317
|
+
if (prev === null || stripGeneratedAt(prev) !== stripGeneratedAt(text)) {
|
|
318
|
+
writeFileSync(file, text, "utf8");
|
|
319
|
+
}
|
|
188
320
|
}
|
|
189
321
|
results[type] = { ok: true };
|
|
190
322
|
} catch (error) {
|