dsh-prime-memory 0.22.2 → 0.24.1

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 (104) hide show
  1. package/CHANGELOG.en.md +421 -0
  2. package/CHANGELOG.ja.md +416 -0
  3. package/CHANGELOG.ko.md +416 -0
  4. package/CHANGELOG.md +120 -0
  5. package/dist/client.js +1266 -845
  6. package/dist/config.d.ts +131 -1341
  7. package/dist/config.js +89 -4
  8. package/dist/conflict-service.d.ts +7 -1
  9. package/dist/conflict-service.js +7 -1
  10. package/dist/contract.d.ts +211 -0
  11. package/dist/domain-gate.d.ts +11 -0
  12. package/dist/domain-gate.js +15 -0
  13. package/dist/hooks/obsolete-notice.d.ts +26 -0
  14. package/dist/hooks/obsolete-notice.js +64 -0
  15. package/dist/hooks/recall.d.ts +39 -0
  16. package/dist/hooks/recall.js +110 -30
  17. package/dist/index.d.ts +1 -1127
  18. package/dist/index.js +52 -2
  19. package/dist/memory-types.d.ts +69 -0
  20. package/dist/memory-types.js +165 -0
  21. package/dist/memport/format.d.ts +6 -1
  22. package/dist/memport/format.js +42 -2
  23. package/dist/pipeline/l1.d.ts +44 -2
  24. package/dist/pipeline/l1.js +163 -9
  25. package/dist/pipeline/md-sync.d.ts +65 -0
  26. package/dist/pipeline/md-sync.js +456 -0
  27. package/dist/pipeline/ruminate.d.ts +46 -1
  28. package/dist/pipeline/ruminate.js +109 -0
  29. package/dist/pipeline/runner.d.ts +10 -0
  30. package/dist/pipeline/runner.js +13 -2
  31. package/dist/pipeline/workspace-relink.d.ts +60 -0
  32. package/dist/pipeline/workspace-relink.js +73 -0
  33. package/dist/projection/recall.d.ts +51 -13
  34. package/dist/projection/recall.js +47 -12
  35. package/dist/prompts/examples.d.ts +73 -0
  36. package/dist/prompts/examples.js +178 -0
  37. package/dist/prompts/l1-dedup.d.ts +9 -3
  38. package/dist/prompts/l1-dedup.js +34 -13
  39. package/dist/prompts/l1-extraction.d.ts +5 -3
  40. package/dist/prompts/l1-extraction.js +49 -4
  41. package/dist/settings.d.ts +8 -0
  42. package/dist/settings.js +16 -1
  43. package/dist/stats.d.ts +12 -0
  44. package/dist/stats.js +352 -17
  45. package/dist/store/embedding-source.d.ts +8 -0
  46. package/dist/store/embedding-source.js +39 -2
  47. package/dist/store/file-versions.d.ts +2 -0
  48. package/dist/store/file-versions.js +2 -0
  49. package/dist/store/graph-store.d.ts +14 -0
  50. package/dist/store/graph-store.js +35 -0
  51. package/dist/store/l1-snapshot.d.ts +26 -1
  52. package/dist/store/l1-snapshot.js +53 -8
  53. package/dist/store/l1.d.ts +94 -3
  54. package/dist/store/l1.js +169 -4
  55. package/dist/store/memory-md-archive.d.ts +84 -0
  56. package/dist/store/memory-md-archive.js +155 -0
  57. package/dist/store/memory-md-disable.d.ts +74 -0
  58. package/dist/store/memory-md-disable.js +302 -0
  59. package/dist/store/memory-md-export.d.ts +50 -0
  60. package/dist/store/memory-md-export.js +227 -0
  61. package/dist/store/memory-md-ledger.d.ts +94 -0
  62. package/dist/store/memory-md-ledger.js +243 -0
  63. package/dist/store/memory-md-orphan.d.ts +27 -0
  64. package/dist/store/memory-md-orphan.js +153 -0
  65. package/dist/store/memory-md-parse.d.ts +35 -0
  66. package/dist/store/memory-md-parse.js +85 -0
  67. package/dist/store/memory-md-run.d.ts +48 -0
  68. package/dist/store/memory-md-run.js +151 -0
  69. package/dist/store/memory-md-skeleton.d.ts +18 -0
  70. package/dist/store/memory-md-skeleton.js +61 -0
  71. package/dist/store/memory-md-sync.d.ts +59 -0
  72. package/dist/store/memory-md-sync.js +152 -0
  73. package/dist/store/memory-md-types.d.ts +243 -0
  74. package/dist/store/memory-md-types.js +10 -0
  75. package/dist/store/memory-md.d.ts +117 -0
  76. package/dist/store/memory-md.js +230 -0
  77. package/dist/store/obsolete-notices.d.ts +39 -0
  78. package/dist/store/obsolete-notices.js +153 -0
  79. package/dist/store/recall-feedback.d.ts +75 -0
  80. package/dist/store/recall-feedback.js +213 -0
  81. package/dist/store/recall-usage.d.ts +11 -0
  82. package/dist/store/recall-usage.js +29 -0
  83. package/dist/store/session-recalled.d.ts +38 -3
  84. package/dist/store/session-recalled.js +156 -9
  85. package/dist/store/sqlite.d.ts +66 -0
  86. package/dist/store/sqlite.js +233 -15
  87. package/dist/store/supersede.d.ts +1 -1
  88. package/dist/store/supersede.js +1 -1
  89. package/dist/tools/index.d.ts +4 -0
  90. package/dist/tools/index.js +26 -7
  91. package/dist/tools/slots.d.ts +2 -1
  92. package/dist/tools/slots.js +2 -1
  93. package/dist/types.d.ts +24 -7
  94. package/dist/types.js +10 -2
  95. package/dist/util/maturity.d.ts +20 -3
  96. package/dist/util/maturity.js +18 -1
  97. package/dist/util/md-name.d.ts +129 -0
  98. package/dist/util/md-name.js +366 -0
  99. package/dist/util/md-normalize.d.ts +23 -0
  100. package/dist/util/md-normalize.js +131 -0
  101. package/dist/workspace-route.d.ts +22 -0
  102. package/dist/workspace-route.js +66 -0
  103. package/dsh.plugin.json +1 -1
  104. package/package.json +6 -5
@@ -0,0 +1,152 @@
1
+ /**
2
+ * 库 → 文件投射的**统一挂钩**(收口点)。
3
+ *
4
+ * 为什么收在 `L1Store` 而不是各个调用方:改变「记录有效性/身份」的写路径全部汇流到
5
+ * `appendNew` / `retire` / `restore` / `rename` 四个方法,而上游有 8 处 `retire()` 调用点
6
+ * (面板批量软删、Room 退役、`memory_delete` 工具、槽位连带、冲突裁决、合并取代、冲突自动
7
+ * 判负、召回判过时)。事件挂钩已被真机证伪:920 条退场记录里只有 8 个 `.disable`,全部来自
8
+ * 召回面板那一条路径。收在汇流口意味着——机器侧自动退场(合并取代、冲突判负)和模型侧
9
+ * `memory_add` 都自动跟随,以后新增调用点不会漏。
10
+ *
11
+ * 一次 `sync` 的形状固定为「导出 → 后缀收敛」,顺序不可反:收敛器判「盘上没有这份」要靠
12
+ * 导出先把这份写出来,反过来会把它算成 `absent` 而漏改名。调用方因此**不需要分辨自己
13
+ * 是增还是删**。
14
+ *
15
+ * 一句话口径:**投射文件形状是库状态的函数,不是任何调用点的事件**(事件只是重算契机)。
16
+ *
17
+ * 四条契约(前三条与 `makeSuffixSync` 时期一致,收口没有放宽):
18
+ * 1. **不阻塞库**:钩子同步返回,文件侧在后台跑;磁盘满不能变成「记忆删不掉」。
19
+ * 2. **绝不抛**:异常一律收敛成 warn(与 `disableProjectionFile` 同纪律)。
20
+ * 3. **现读配置与账本**:`memExport` 是 volatile,快照会让「事后打开旋钮」一直到下次启动
21
+ * 才生效;账本在 `stores.l1` 之后才装配,所以也只能现拿(见 `setFileSyncHook` 晚绑定)。
22
+ * 4. **后台任务串行**:同一个文件树与同一本账不能被两个任务同时动,否则紧邻的两次点火
23
+ * (先写后退)会互踩,退场记录会停在 `.memory.md` 上(详 `makeFileSyncHook`)。
24
+ *
25
+ * 批量路径**禁止逐条 flush 账本**(账本是整本 JSON 的原子重写,逐条 flush = O(n²)):
26
+ * 统一走 `moveLedger(..., persist=false)` + 收尾一次 `flush()`。
27
+ */
28
+ import { dirname } from 'node:path';
29
+ import { rename, stat } from 'node:fs/promises';
30
+ import { ensureDir } from '../util/io.js';
31
+ import { exportRecords } from './memory-md-export.js';
32
+ import { convergeDisabledSuffix, moveLedger } from './memory-md-disable.js';
33
+ import { disablePathOf, isRetiredRecord, renderRecord, targetFor } from './memory-md.js';
34
+ /** 存在性判定:一次 `stat`,任何错误都当「不存在」(与 `memory-md-disable.ts` 同口径)。 */
35
+ async function exists(p) {
36
+ try {
37
+ await stat(p);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * 该批记录的投射跟随:导出 → 后缀收敛。**不打日志**(调用方自己的口径不同:钩子说
46
+ * “联动”,反刍说“增量”,统一格式反而把两者都稀释);也不抛,异常由调用方收敛。
47
+ *
48
+ * @returns 两个报告;投射面关着或账本未装配时返回 `undefined`(调用方据此不打日志)。
49
+ */
50
+ export async function syncFiles(deps, records) {
51
+ if (records.length === 0)
52
+ return undefined;
53
+ const cfg = deps.exportCfg();
54
+ const ledger = deps.ledger();
55
+ if (!cfg.enabled || ledger === undefined)
56
+ return undefined;
57
+ const base = { cfgRoot: cfg.root, dataDir: deps.dataDir, ledger, maxRecords: cfg.maxRecords, timeBudgetMs: cfg.timeBudgetMs, logger: deps.logger };
58
+ const exported = await exportRecords({ records, ...base });
59
+ const suffix = await convergeDisabledSuffix({ records, ...base });
60
+ return { exported, suffix };
61
+ }
62
+ /** 按 id 取快照后跑一轮 `syncFiles`(钩子走这个入口,判据只有一份)。 */
63
+ export async function syncFilesByIds(deps, ids, why) {
64
+ if (ids.length === 0)
65
+ return undefined;
66
+ const out = await syncFiles(deps, deps.getByIds(ids));
67
+ if (out !== undefined) {
68
+ deps.logger?.info(`[memory] 投射联动(${why}): ${ids.length} 条,写 ${out.exported.written},未变 ${out.exported.unchanged},改名 ${out.suffix.renamed},改回 ${out.suffix.revived}`);
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * 改名跟随:把 `name` 变更的那批文件搬到新路径(含 `.disable` 兄弟)。
74
+ *
75
+ * 三种盘态:
76
+ * - 旧路径有文件 → 改名到新路径 + 账本换键(键是绝对路径,不换下一轮对账两边都错);
77
+ * - 旧路径没文件 → 不报错,交给导出按新名补写(这条记录可能从没导出过);
78
+ * - 新路径已被占用 → 只 warn 不动盘:`rename` 到已存在路径在 Windows 上会 EEXIST,
79
+ * 覆盖等于揉掉别人的投射文件,而 name 唯一性由写入侧管,这里不越权裁决。
80
+ */
81
+ async function relocateRenamed(deps, oldNameById, why) {
82
+ const cfg = deps.exportCfg();
83
+ const ledger = deps.ledger();
84
+ if (!cfg.enabled || ledger === undefined || oldNameById.size === 0)
85
+ return;
86
+ const needExport = [];
87
+ let moved = 0;
88
+ for (const [id, oldName] of oldNameById) {
89
+ const r = deps.getByIds([id])[0];
90
+ if (r === undefined || r.name === undefined || r.name === oldName)
91
+ continue;
92
+ const oldTarget = targetFor({ ...r, name: oldName }, cfg.root, deps.dataDir, deps.logger);
93
+ const newTarget = targetFor(r, cfg.root, deps.dataDir, deps.logger);
94
+ if (oldTarget.absPath === undefined || newTarget.absPath === undefined)
95
+ continue;
96
+ // 退场记录的文件住在 `.disable` 上:后缀跟着走,别把无效态搬成有效态。
97
+ const retire = isRetiredRecord(r);
98
+ const from = retire ? disablePathOf(oldTarget.absPath) : oldTarget.absPath;
99
+ const to = retire ? disablePathOf(newTarget.absPath) : newTarget.absPath;
100
+ if (from === to)
101
+ continue;
102
+ if (!(await exists(from))) {
103
+ needExport.push(r);
104
+ continue;
105
+ }
106
+ if (await exists(to)) {
107
+ deps.logger?.warn(`[memory] 改名跟随跳过(目标已存在,不覆盖他人投射): ${to}`);
108
+ continue;
109
+ }
110
+ try {
111
+ await ensureDir(dirname(to));
112
+ await rename(from, to);
113
+ await moveLedger(ledger, from, to, r, renderRecord(r).text, false);
114
+ moved++;
115
+ }
116
+ catch (err) {
117
+ deps.logger?.warn(`[memory] 改名跟随失败(下轮导出/收敛重试): ${from} → ${to} - ${err instanceof Error ? err.message : String(err)}`);
118
+ }
119
+ }
120
+ // 收尾落一次账:循环里只记脏(逐条 flush 会把整本 JSON 重写 N 次)。
121
+ await ledger.flush();
122
+ if (needExport.length > 0)
123
+ await syncFiles(deps, needExport);
124
+ if (moved > 0)
125
+ deps.logger?.info(`[memory] 改名跟随(${why}): 搬运 ${moved} 个投射文件,补写 ${needExport.length} 条`);
126
+ }
127
+ /**
128
+ * 造注入给 `L1Store` 的挂钩。契约与 `disableProjectionFile` 一致:同步返回、异常只落日志。
129
+ *
130
+ * 双触发是**允许的**(`recall-feedback(-1)` 仍直接调 `disableProjectionFile` 拿确定性凭证):
131
+ * 两条路径落到同一形状,第二次要么 ENOENT 兜底、要么 `already` 幂等。
132
+ *
133
+ * **串行队列(真机测出来的,不是洁癖)**:同一批文件与同一本账不能被两个后台任务同时
134
+ * 动。旧写法每次点火就自己开一个 promise,于是「先 `appendNew` 再 `retire`」这种紧邻的两次
135
+ * 点火会重叠:第二个任务的收敛器在第一个还没写出文件时判「盘上没这份」(`absent`),
136
+ * 什么都不做——退场记录就永久停在 `.memory.md` 上,直到下次全量导出才被抬回来。
137
+ * 串成一条链后,后一个任务看到的是前一个的终态;执行时再按 id 回查快照,拿到的也是最新有效性。
138
+ */
139
+ export function makeFileSyncHook(deps) {
140
+ let chain = Promise.resolve();
141
+ const fire = (task, why) => {
142
+ chain = chain
143
+ .then(task)
144
+ .catch((err) => {
145
+ deps.logger?.warn(`[memory] 投射联动失败(${why},不影响库侧结果,下轮导出重试): ${err instanceof Error ? err.message : String(err)}`);
146
+ });
147
+ };
148
+ return {
149
+ sync: (ids, why) => fire(() => syncFilesByIds(deps, ids, why), why),
150
+ renamed: (oldNameById, why) => fire(() => relocateRenamed(deps, oldNameById, why), why),
151
+ };
152
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * md 投射的**纯类型**面(无 IO、无 node 依赖)。
3
+ *
4
+ * 为什么要单独一个文件:`src/contract.ts` 的头部纪律是"只允许 type/interface,
5
+ * 不 import 任何运行时值"——它一旦被拉进 client 编译程序,就会连带把
6
+ * `node:fs`/`node:crypto` 的依赖也拖进去(实测:`tsc -p tsconfig.client.json`
7
+ * 立刻报 `Cannot find module 'node:path'`)。所以报告形状收在这里,
8
+ * 存储层与契约层各自 import type,**单一事实源仍然成立**。
9
+ */
10
+ /** 导出报告(库 → 文件 + 账本)。`skipped` 含"路径越界/写盘失败"两类,都各自留痕。 */
11
+ export interface MemExportReport {
12
+ /** 单根(无 workspace 归属那批落这里);workspace 优先的条目不反映在此字段。 */
13
+ root: string;
14
+ /** 落在各 workspace 根下的条数(N10)。 */
15
+ inWorkspace: number;
16
+ /** 本次真正遍历过的条数(含未命中/跳过)。 */
17
+ scanned: number;
18
+ written: number;
19
+ /** 内容指纹一致、没重写的条数。 */
20
+ unchanged: number;
21
+ skipped: number;
22
+ /** 因护栏未处理的条数(>0 时 UI 提示"再点一次继续")。 */
23
+ remaining: number;
24
+ /** 是否因墙钟/条数护栏提前收工。 */
25
+ capped: boolean;
26
+ }
27
+ /**
28
+ * 后缀不变式收敛报告(库里的“是否有效” ⇄ 文件后缀)。
29
+ *
30
+ * 口径:投射文件的后缀**只由这条记录在库里是否退场决定**,与 `name` 字段无关、
31
+ * 与“是哪条路径把它退场的”也无关。所以这个计数面不挂在某个按钮的事件后面,
32
+ * 而是每次导出都按「文件存在性 + 当前后缀 + 指纹一致性」重算一遍。
33
+ */
34
+ export interface SuffixReport {
35
+ /** 本次遍历过的记录数(含什么都不做的)。 */
36
+ scanned: number;
37
+ /** 已退场但盘上还是 `.memory.md` → 已改名。 */
38
+ renamed: number;
39
+ /** 在库但只剩 `.disable`(且账本认得是我们写的)→ 已改回。 */
40
+ revived: number;
41
+ /** 改名前发现文件与账本指纹不符(人动过正文);**仍照改**,改名不丢字节。 */
42
+ edited: number;
43
+ /** 后缀已经对上,没碰盘。 */
44
+ already: number;
45
+ /** 两份都在(或 `.disable` 不是我们写的):语义相冲,**保守不动**,交人裁决。 */
46
+ both: number;
47
+ /** 盘上根本没有对应文件(从没导出过):不凭空造。 */
48
+ absent: number;
49
+ /** 路径越界 / 单条失败(都各自留痕,不阻断整批)。 */
50
+ skipped: number;
51
+ /** 因护栏未处理到的剩余条数。 */
52
+ remaining: number;
53
+ /** 是否因墙钟/条数护栏提前收工(>0 时 UI 提示“再点一次继续”)。 */
54
+ capped: boolean;
55
+ }
56
+ /**
57
+ * `dsh-memory/mem-export` 的响应 = 导出报告 + 命名回填计数 + 后缀收敛计数。
58
+ *
59
+ * 为什么要单开一个类型:投射默认开后,导出这条路径现在会先做一步**写库**的命名回填
60
+ * (给 `name` 为空的记录派生全库唯一的文件身份名)。它只改 `name`——内容/类型/退场态都不动,
61
+ * 而 `name` 不在 `canonicalRecords` 的快照哈希字段表里,所以「导出不改检索面」依旧成立;
62
+ * 但“导出什么都不写”这句话已经不成立了,把计数摆到响应上是让这笔写在面板上看得见。
63
+ */
64
+ export interface MemExportResponse extends MemExportReport {
65
+ /** 本轮新起了多少条名字(0 = 全都已有名)。 */
66
+ named: number;
67
+ /** 起名算出来但落库失败的条数(下轮会重试)。 */
68
+ nameFailed: number;
69
+ /** 导出后紧跟的后缀收敛结果(退场记录的文件该带 `.disable`)。 */
70
+ suffix: SuffixReport;
71
+ /**
72
+ * 本次实际滚了几轮(一轮 = 一次有界遍历)。
73
+ *
74
+ * 要报这个:`written`/`renamed` 是各轮累加的,人得知道“一次点击背后跑了 4 轮”
75
+ * 才不会把面板上的时长误读成“一次只能做这么多”。
76
+ */
77
+ passes: number;
78
+ /**
79
+ * 本次**实际生效**的单次动作护栏(包了 `live.mdExportMaxRecords` 覆盖后的值)。
80
+ *
81
+ * 为什么报而不是让人自己算:面板上那个旋钮经 `effectiveCfg` 落地,人看到的数字
82
+ * 必须是被应用过的那个,否则“改了但没生效”与“改了且生效”在报告上长得一样。
83
+ */
84
+ appliedMaxRecords: number;
85
+ }
86
+ /**
87
+ * 投影长任务的进度快照(`dsh-memory/mem-run-status` 的响应)。
88
+ *
89
+ * 形状住在这里而不是执行侧:`contract.ts` 要 import 它但不能把 `node:fs` 拖进
90
+ * client 编译程序(同本文件头的纪律)。
91
+ */
92
+ export interface MdRunStatus {
93
+ running: boolean;
94
+ /** 当前阶段:导出(库→文件)/ 收敛(后缀对齐)/ 已结束。 */
95
+ phase: 'idle' | 'export' | 'converge' | 'done' | 'failed';
96
+ /** 第几轮(一轮 = 一次有界遍历)。 */
97
+ pass: number;
98
+ /** 两阶段合计已推进的条数(分母 = `total`×2,两阶段各算一遍全表)。 */
99
+ done: number;
100
+ total: number;
101
+ written: number;
102
+ renamed: number;
103
+ /** 人读的一行进度说明。 */
104
+ detail: string;
105
+ startedAt: string;
106
+ endedAt: string;
107
+ /** 出错或总预算用尽的说明(非空 = 需要再点一次续跑)。 */
108
+ notice?: string;
109
+ }
110
+ /**
111
+ * 孤儿投射文件遍历报告(P4.4)。
112
+ *
113
+ * “孤儿”只算**有账可查且没人认领**的路径:账本里没有的来路不明文件(人自己放进投射目录
114
+ * 等着被同步的输入)一律不报也不动,所以这里没有“陌生文件”这一项——那个计数在同步侧。
115
+ */
116
+ export interface OrphanSample {
117
+ path: string;
118
+ /** `orphan`(干跑时只报这个)/ `moved` / `failed` / `unsafe-relPath`。 */
119
+ reason: string;
120
+ }
121
+ export interface OrphanReport {
122
+ /** 本次是不是干跑(面板要知道这段数字是哪个模式出的,与 cleanup 同一个理由)。 */
123
+ dryRun: boolean;
124
+ /** 扫过的账本条目数。 */
125
+ scanned: number;
126
+ /** 仍被某条记录(含它的 `.disable` 兄弟)认领的。 */
127
+ claimed: number;
128
+ /** 无人认领的总数(干跑时就等于“将要搬走的数”)。 */
129
+ orphans: number;
130
+ moved: number;
131
+ failed: number;
132
+ /** 账本说有、盘上没:那是“文件缺失”,对账已经在报,这里不重复处理也不删账本。 */
133
+ missing: number;
134
+ /** 因护栏没扫完的剩余条目数。 */
135
+ remaining: number;
136
+ capped: boolean;
137
+ /** 本批归档目录(干跑或什么都没做时为空串)。 */
138
+ dir: string;
139
+ samples: OrphanSample[];
140
+ }
141
+ /** 四态计数(外加两个边界态)。 */
142
+ export interface ReconcileCounts {
143
+ /** 账本里没有这条文件(首次导出前,或人新加了文件)。 */
144
+ firstTime: number;
145
+ /** 两边都没变。 */
146
+ same: number;
147
+ /** 只有库变了 → 该重渲染文件。 */
148
+ dbOnly: number;
149
+ /** 只有文件变了 → P2 该回灌库。 */
150
+ fileOnly: number;
151
+ /** 两边都变 → P2 挂待裁决,绝不自动覆盖任何一边。 */
152
+ both: number;
153
+ /** 账本说有文件、磁盘上没有(被删/被移走)。 */
154
+ missingFile: number;
155
+ }
156
+ /** 差异样本(人拿它决定要不要真同步)。 */
157
+ export interface ReconcileSample {
158
+ relPath: string;
159
+ id: string;
160
+ state: keyof ReconcileCounts;
161
+ }
162
+ /** 对账报告。`capped` 时必须提醒"计数只覆盖已扫部分,不等于全库无差异"。 */
163
+ export interface ReconcileReport extends ReconcileCounts {
164
+ root: string;
165
+ capped: boolean;
166
+ scanned: number;
167
+ samples: ReconcileSample[];
168
+ }
169
+ /** 反向同步的单条挂账样本(只给路径与做了什么,不给内容——内容可能含敏感信息)。 */
170
+ export interface MdSyncSample {
171
+ path: string;
172
+ action: string;
173
+ }
174
+ /**
175
+ * P2 反向同步报告(文件 → 库)。
176
+ *
177
+ * 与对账报告的区别:对账**只读**,同步会写。所以这里的每个计数都对应一次真实动作,
178
+ * `pending` 那批除外——那批是刻意什么都不做。
179
+ */
180
+ export interface MdSyncReport {
181
+ /** 本轮遍历过的投射根数(workspace 根 + 单根,N10)。 */
182
+ roots: number;
183
+ scanned: number;
184
+ /** 只有文件变 → 已回灌入库。 */
185
+ imported: number;
186
+ /** 只有库变 → 已重写文件。 */
187
+ rendered: number;
188
+ /** 两边都没变。 */
189
+ same: number;
190
+ /** 跳过(读不出/形状不符/写库失败)。 */
191
+ skipped: number;
192
+ /** **两边都变 → 挂待裁决**:两侧保持原样,等人裁决。 */
193
+ pending: number;
194
+ /** `.disable` → 已退场。 */
195
+ retired: number;
196
+ /** 没有账本、也认不出记录的新文件:本轮**不入库**(导入必须过写入内核)。 */
197
+ newFiles: number;
198
+ /** `.disable` 文件但找不到对应记录(改名后的 id 也追不到)。 */
199
+ orphan: number;
200
+ /**
201
+ * 遍历防护拦下的条目数(task_3.7):dirent 报链接的条目、真实路径落在投射根外的目录、
202
+ * 以及 realpath 解析失败因而无法验证的目录(含整根划不出边界那一种)。
203
+ * 与 `skipped` 的区别:那是“进来了但不合格式”,这里是**根本没进**——两者混在一个数里
204
+ * 就分不清“文件写得不对”和“目录拓扑越界”,而后者的代价是扫描被链接吞掉整棵外部树。
205
+ */
206
+ skippedLinks: number;
207
+ /**
208
+ * 扫到了但**所在根不是这条记录当前的落点根**的文件数(换过 `memExport.root`、迁库、
209
+ * workspace 根与单根并存时会出现:同一条记录在两个根下各一份)。
210
+ *
211
+ * 它们一律**不参与象限判定、不回灌、不被重写**——拿旧根那份判“谁变了”、却往新根那份写,
212
+ * 会绕过“两边都变就不动”把新根覆盖掉(真机双向往返实测出的形状)。
213
+ * 这些文件本质是孤儿,处置归 `mem-orphans` 归档,不归同步。
214
+ * 注意判据只看**根**不看全路径:同根内嵌套目录里的人手文件仍是既有语义。
215
+ */
216
+ staleRootFiles: number;
217
+ /**
218
+ * 同一条记录被两份以上投射文件认领,且**多于一份被改过** → 这些文件本轮一律不动的件数。
219
+ *
220
+ * 成因是改名(撞车消歧 / 人改 `name` / 命名回填)后旧路径文件与旧账本条目都还留着——
221
+ * 搬走它们的是 `mem-orphans`,不是同步。两份各自独立判象限就会变成两次「仅文件变」回灌,
222
+ * **后跑的赢**,前一份人写的编辑被静默丢掉;而「两边都变就不动」是按单条账本条目判的,
223
+ * 看不见对面那份(真机沙箱实测:旧路径被改 → `imported 1`,库里正文来自已改名那一侧)。
224
+ * 这里把同记录的多路径当成一个裁决单元:只要多于一份有改动就全部挂账不动,等人自己定留哪份
225
+ * (把旧残片交给孤儿归档后,下一轮就只剩一份,判定回到正常轨道)。
226
+ */
227
+ multiPath: number;
228
+ /**
229
+ * 声明 `hash` 与正文实算**不符**的文件数(N13 自校验):文件被外部改动或损坏,内容已不是
230
+ * 我们上次写出去的那份字节。它**不阻断**任何裁决——正文被人改过恰是 P2 要回灌的正常输入;
231
+ * 这里只把它从“静默按文件侧处理”变成“看得见地处理”。
232
+ */
233
+ hashMismatch: number;
234
+ /**
235
+ * 没带 `hash` 键的老投射文件数(升级前导出的产物):本身无自校验能力,按兼容放行;
236
+ * 下一轮导出会补盖指纹,因此这个数会随同步推进自然收敛到 0。
237
+ */
238
+ unsigned: number;
239
+ /** 因护栏没扫完的剩余文件数。 */
240
+ remaining: number;
241
+ capped: boolean;
242
+ samples: MdSyncSample[];
243
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * md 投射的**纯类型**面(无 IO、无 node 依赖)。
3
+ *
4
+ * 为什么要单独一个文件:`src/contract.ts` 的头部纪律是"只允许 type/interface,
5
+ * 不 import 任何运行时值"——它一旦被拉进 client 编译程序,就会连带把
6
+ * `node:fs`/`node:crypto` 的依赖也拖进去(实测:`tsc -p tsconfig.client.json`
7
+ * 立刻报 `Cannot find module 'node:path'`)。所以报告形状收在这里,
8
+ * 存储层与契约层各自 import type,**单一事实源仍然成立**。
9
+ */
10
+ export {};
@@ -0,0 +1,117 @@
1
+ import type { MemoryLogger, MemoryRecord } from '../types.js';
2
+ /**
3
+ * 允许当目录名的 type 值(pm-name-distill task_1.3:由词表单一真源派生,不再手抄)。
4
+ *
5
+ * 手抄的危害很具体:换词表不改这一处,新词表里的十余类会**静默落进兜底桶**——
6
+ * 不报错、不同步失败,只少目录树。
7
+ */
8
+ export declare const MEMORY_TYPE_DIRS: ReadonlySet<string>;
9
+ /**
10
+ * 不在词表的 type 落到这个目录(P4.1 的**默认类**)。
11
+ *
12
+ * 它是**目录层的投影**,不是库内事实:记录自己的 `type` 原值照旧写进 frontmatter,
13
+ * 也绝不回填到 `rowToRecord`/upsert/metadata(F5 红线)。之所以不直接归进某个 `work_*`:
14
+ * 那会把这批无类记录的 `family` 挑到 work 侧,而 family 决定共享面——等于静默改了可见性。
15
+ */
16
+ export declare const UNSORTED_DIR = "unclassified";
17
+ /** frontmatter 允许出现的键(用例断言键集**恰等于**它,防检索轴泄漏进文件)。 */
18
+ export declare const FRONTMATTER_KEYS: readonly string[];
19
+ /** 内容指纹长度(12 hex ≈ 48 bit;账本只用来判"变没变",不当身份用)。 */
20
+ export declare const DIGEST_HEX = 12;
21
+ /** 失效后缀(N11):`x.memory.md` → `x.memory.md.disable`。用户定名,取代原设计的 `.outdated`。 */
22
+ export declare const DISABLE_SUFFIX = ".disable";
23
+ /** workspace 优先时的子目录(N10):`<workspace>/.agents/memory/…`。 */
24
+ export declare const WORKSPACE_SUBDIR: readonly string[];
25
+ /**
26
+ * 解析单根(无 workspace 归属的记录落这里)。
27
+ *
28
+ * 给了 `cfgRoot` 就用它(必须绝对路径且不含 `..`),否则默认 `<dataDir>/memories`。
29
+ * **不合法只回退默认并 warn,绝不抛错**:投射是旁路,旁路配置写错不能弄挂记忆本体。
30
+ */
31
+ export declare function resolveMemoriesRoot(cfgRoot: string | undefined, dataDir: string, logger?: MemoryLogger): string;
32
+ /**
33
+ * 把渲染出的相对路径安全地拼到根下。
34
+ *
35
+ * 越界(绝对路径、`..` 跳出、盘符切换)一律返回 `undefined` 由调用方跳过并 warn——
36
+ * 记录内容是人/模型写的,它可能包含 `../../`,这里必须是结构性挡死而不是"应该不会有"。
37
+ */
38
+ export declare function safeJoin(root: string, relPath: string): string | undefined;
39
+ /** 一个记录该落到哪。`absPath` 为 `undefined` = 越界,调用方必须跳过而不是写。 */
40
+ export interface MdTarget {
41
+ /** 投射根(workspace 根或单根),绝对路径。 */
42
+ root: string;
43
+ /** 相对根的路径(正斜杠),展示与归档分目录用。 */
44
+ relPath: string;
45
+ /** 绝对路径——**账本键与文件操作都用它**。undefined = 越界。 */
46
+ absPath: string | undefined;
47
+ /** 是否落在 workspace 下(区分两个根,面板与归档路径都要看)。 */
48
+ inWorkspace: boolean;
49
+ }
50
+ /** 渲染一条记录所需的最小字段集(也是算落点所需的全部)。 */
51
+ export type MdRecordInput = Pick<MemoryRecord, 'id' | 'content' | 'type' | 'priority' | 'createdAt' | 'updatedAt' | 'metadata' | 'validTo' | 'extKey' | 'name' | 'workspaceId'>;
52
+ /**
53
+ * 投射落点(N10):**workspace 优先**。
54
+ *
55
+ * 有 `workspaceId` ⇒ `<workspace>/.agents/memory/<type>/<stem>.memory.md`;
56
+ * 没有 ⇒ 单根(`cfg.memExport.root` 或 `<dataDir>/memories`)。
57
+ *
58
+ * ⚠️ **导出/对账/失效改名/归档四处必须共用本函数**。库里的 `workspace_id` 在 Windows 上是
59
+ * 小写形态(`normalizeWorkspacePath` 会 `.toLowerCase()`),任何一处自己拼路径、或者自己
60
+ * 转一下大小写,就会把“刚写进去的文件”算成另一个账本键 → 对账永远报“文件缺失”。
61
+ * 同理:**不**在这里做 `fs.realpath` 归一——那会让目录创建前后的键漂移,反而造出假缺失;
62
+ * Windows 路径大小写不敏感,同一逻辑路径的两种写法解析到同一个文件,键自洽即可。
63
+ */
64
+ export declare function targetFor(r: MdRecordInput, cfgRoot: string | undefined, dataDir: string, logger?: MemoryLogger): MdTarget;
65
+ /**
66
+ * 退场判据:与库侧同口径(`sqlite.ts` 的“`valid_to` 非空即退场”,与
67
+ * `retiredL1Count`/`listRetiredL1` 一致)。
68
+ *
69
+ * 为什么住在本文件:导出(要不要把文件放回非退场路径)与失效联动(后缀该不该带)
70
+ * 两侧都得用同一个判据,否则“列表说它退了、文件却没后缀”会回来。放 `memory-md-disable.ts`
71
+ * 会让 `memory-md-export.ts` 反向 import 它形成循环(那边已经 import 这边)。
72
+ */
73
+ export declare function isRetiredRecord(r: Pick<MemoryRecord, 'validTo'>): boolean;
74
+ /**
75
+ * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
76
+ * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
77
+ */
78
+ export declare function disablePathOf(absPath: string): string;
79
+ /**
80
+ * 内容指纹。
81
+ *
82
+ * 刻意**不复用** `memport/format.ts` 的 `contentHash`:那个口径被 TS↔Python 跨实现
83
+ * 金标准锁死(`tests/memport-ts-consistency`),改了就是让两边算出不同的包。这里只
84
+ * 借它的 `normText`(NFKC + 换行归一),口径独立、且明确标注为"投射账本专用"。
85
+ */
86
+ export declare function digest(text: string): string;
87
+ /**
88
+ * 投射文件自带 `hash` 键的**规范口径**(导出写、读入核,两侧都必须走它)。
89
+ *
90
+ * 定义 = `digest(归一后的正文)`:与 `renderRecord` 写进 `hash` 的值、以及读侧拿解析出的
91
+ * `content` 实算的值三处同源。只覆盖正文、不含任何 frontmatter 字段——所以换 id、换文件名、
92
+ * 退场标记都不改变它,它只回答「正文有没有被改过」这一件事。
93
+ *
94
+ * 走 `digest`(内含 NFKC + 空白折叠)而非裸 sha1:CRLF/BOM/尾随空白这类搬运噪声不该触发
95
+ * 假不符(搬文件、换编辑器、git autocrlf 都会引入它们)。
96
+ */
97
+ export declare function contentHash(content: string): string;
98
+ /** 记录内容指纹:正文字段(不含 metadata——轴变了不算内容变)。 */
99
+ export declare function recordDigest(r: Pick<MemoryRecord, 'content'>): string;
100
+ /**
101
+ * 文件名干:有 `name` 用 `name`;没有则回落 `mem-<id 后 8 位>`。
102
+ *
103
+ * 回落是**必需**的:存量记录 name 恒空(零回填红线),不回落就没法投射第一批。
104
+ * id 每轮新铸,所以回落名不稳定——这正是 P0 要先做 `name` 的原因,注释留在这里
105
+ * 免得后来人以为"回落名也能双向"。
106
+ */
107
+ export declare function fileStemFor(r: Pick<MemoryRecord, 'id' | 'name' | 'content'>): string;
108
+ /**
109
+ * 渲染一条记录。
110
+ *
111
+ * @param r - L1 记录(含已退场的——退场也要有投影,否则人在文件树里看不到它)。
112
+ * @returns `relPath`(相对投射根,已归一分隔符)与 `text`(全文)。
113
+ */
114
+ export declare function renderRecord(r: MdRecordInput): {
115
+ relPath: string;
116
+ text: string;
117
+ };