dsh-prime-memory 0.22.1 → 0.24.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 (121) hide show
  1. package/CHANGELOG.en.md +401 -0
  2. package/CHANGELOG.ja.md +401 -0
  3. package/CHANGELOG.ko.md +401 -0
  4. package/CHANGELOG.md +100 -0
  5. package/dist/client.js +1951 -1188
  6. package/dist/config.d.ts +176 -1240
  7. package/dist/config.js +113 -2
  8. package/dist/conflict-service.d.ts +12 -0
  9. package/dist/conflict-service.js +24 -0
  10. package/dist/contract.d.ts +327 -0
  11. package/dist/domain-gate.d.ts +19 -0
  12. package/dist/domain-gate.js +45 -0
  13. package/dist/graph/constraints.d.ts +4 -2
  14. package/dist/graph/constraints.js +4 -2
  15. package/dist/hooks/obsolete-notice.d.ts +26 -0
  16. package/dist/hooks/obsolete-notice.js +64 -0
  17. package/dist/hooks/recall.d.ts +41 -0
  18. package/dist/hooks/recall.js +127 -28
  19. package/dist/hooks/skill-recall.d.ts +25 -0
  20. package/dist/hooks/skill-recall.js +72 -0
  21. package/dist/index.d.ts +1 -1029
  22. package/dist/index.js +55 -2
  23. package/dist/memory-types.d.ts +69 -0
  24. package/dist/memory-types.js +165 -0
  25. package/dist/memport/format.d.ts +75 -0
  26. package/dist/memport/format.js +345 -0
  27. package/dist/memport/prefilter.d.ts +62 -0
  28. package/dist/memport/prefilter.js +132 -0
  29. package/dist/memport/scan.d.ts +13 -0
  30. package/dist/memport/scan.js +100 -0
  31. package/dist/metadata-validators.d.ts +22 -0
  32. package/dist/metadata-validators.js +45 -0
  33. package/dist/pipeline/l1.d.ts +44 -2
  34. package/dist/pipeline/l1.js +177 -9
  35. package/dist/pipeline/md-sync.d.ts +65 -0
  36. package/dist/pipeline/md-sync.js +456 -0
  37. package/dist/pipeline/ruminate.d.ts +46 -1
  38. package/dist/pipeline/ruminate.js +150 -1
  39. package/dist/pipeline/runner.d.ts +10 -0
  40. package/dist/pipeline/runner.js +7 -1
  41. package/dist/pipeline/slot-migrate.d.ts +45 -0
  42. package/dist/pipeline/slot-migrate.js +45 -0
  43. package/dist/pipeline/workspace-relink.d.ts +60 -0
  44. package/dist/pipeline/workspace-relink.js +73 -0
  45. package/dist/projection/recall.d.ts +112 -0
  46. package/dist/projection/recall.js +183 -0
  47. package/dist/projection/slots.d.ts +5 -0
  48. package/dist/prompts/examples.d.ts +73 -0
  49. package/dist/prompts/examples.js +178 -0
  50. package/dist/prompts/graph-projection.js +3 -0
  51. package/dist/prompts/l1-dedup.d.ts +9 -3
  52. package/dist/prompts/l1-dedup.js +34 -13
  53. package/dist/prompts/l1-extraction.d.ts +9 -4
  54. package/dist/prompts/l1-extraction.js +76 -8
  55. package/dist/settings.d.ts +8 -0
  56. package/dist/settings.js +15 -1
  57. package/dist/skills/crystallize.d.ts +41 -0
  58. package/dist/skills/crystallize.js +75 -0
  59. package/dist/stats.d.ts +30 -1
  60. package/dist/stats.js +504 -13
  61. package/dist/store/embedding-source.d.ts +8 -0
  62. package/dist/store/embedding-source.js +39 -2
  63. package/dist/store/file-versions.d.ts +4 -0
  64. package/dist/store/file-versions.js +4 -0
  65. package/dist/store/graph-store.d.ts +14 -0
  66. package/dist/store/graph-store.js +35 -0
  67. package/dist/store/l1-snapshot.d.ts +26 -1
  68. package/dist/store/l1-snapshot.js +53 -8
  69. package/dist/store/l1.d.ts +77 -3
  70. package/dist/store/l1.js +128 -3
  71. package/dist/store/memory-md-archive.d.ts +84 -0
  72. package/dist/store/memory-md-archive.js +155 -0
  73. package/dist/store/memory-md-disable.d.ts +23 -0
  74. package/dist/store/memory-md-disable.js +106 -0
  75. package/dist/store/memory-md-export.d.ts +50 -0
  76. package/dist/store/memory-md-export.js +200 -0
  77. package/dist/store/memory-md-ledger.d.ts +94 -0
  78. package/dist/store/memory-md-ledger.js +243 -0
  79. package/dist/store/memory-md-orphan.d.ts +27 -0
  80. package/dist/store/memory-md-orphan.js +153 -0
  81. package/dist/store/memory-md-parse.d.ts +35 -0
  82. package/dist/store/memory-md-parse.js +85 -0
  83. package/dist/store/memory-md-skeleton.d.ts +18 -0
  84. package/dist/store/memory-md-skeleton.js +61 -0
  85. package/dist/store/memory-md-types.d.ts +174 -0
  86. package/dist/store/memory-md-types.js +10 -0
  87. package/dist/store/memory-md.d.ts +108 -0
  88. package/dist/store/memory-md.js +219 -0
  89. package/dist/store/memory-sources.d.ts +59 -0
  90. package/dist/store/memory-sources.js +214 -0
  91. package/dist/store/obsolete-notices.d.ts +39 -0
  92. package/dist/store/obsolete-notices.js +153 -0
  93. package/dist/store/recall-feedback.d.ts +75 -0
  94. package/dist/store/recall-feedback.js +213 -0
  95. package/dist/store/recall-usage.d.ts +11 -0
  96. package/dist/store/recall-usage.js +29 -0
  97. package/dist/store/session-recalled.d.ts +60 -0
  98. package/dist/store/session-recalled.js +310 -0
  99. package/dist/store/skill-store.d.ts +99 -0
  100. package/dist/store/skill-store.js +330 -0
  101. package/dist/store/sqlite.d.ts +71 -0
  102. package/dist/store/sqlite.js +259 -15
  103. package/dist/store/supersede.d.ts +1 -1
  104. package/dist/store/supersede.js +1 -1
  105. package/dist/tools/index.d.ts +4 -13
  106. package/dist/tools/index.js +80 -12
  107. package/dist/tools/skills.d.ts +13 -0
  108. package/dist/tools/skills.js +130 -0
  109. package/dist/types.d.ts +30 -7
  110. package/dist/types.js +10 -2
  111. package/dist/util/maturity.d.ts +44 -0
  112. package/dist/util/maturity.js +43 -0
  113. package/dist/util/md-name.d.ts +129 -0
  114. package/dist/util/md-name.js +366 -0
  115. package/dist/util/md-normalize.d.ts +23 -0
  116. package/dist/util/md-normalize.js +131 -0
  117. package/dist/wing-backfill.js +7 -4
  118. package/dist/workspace-route.d.ts +22 -0
  119. package/dist/workspace-route.js +66 -0
  120. package/dsh.plugin.json +1 -1
  121. package/package.json +6 -5
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 类型目录骨架 + `_index.md`(pm-name-distill Phase 4 / task_4.1)。
3
+ *
4
+ * 投射根(`<dataDir>/memories/` 或 `cfg.memExport.root`)过去只有"库里出现过记录"的类型目录
5
+ * (实测 6 个);词表已扩到 37 类。空目录会被 git/压缩包吐掉,而"没这个目录"与"这类 0 条"
6
+ * 在人眼里长得一样——所以每类目录固定放一个 `_index.md`(类名 + 库里条数),目录集合从
7
+ * **词表真源** `memoryTypeDirs()` 派生,不再手抄。
8
+ *
9
+ * 三条纪律:
10
+ * 1. **幂等**:内容不变就不重写(也不刷 mtime)。投射是"库 → 文件"的旁路,重刷 mtime 会让
11
+ * 下一轮对账把"我们没动过"误判成"文件被改过"——与 `exportRecords` 的 mtime 纪律同口径。
12
+ * 2. **失败只 warn 不抛**:旁路写挂不能弄挂记忆本体(照 `resolveMemoriesRoot` / 导出侧的降级口径)。
13
+ * 3. **原子落盘**:临时文件 + rename,走仓内既有范式 `atomicWriteText`(带 fsync / 安全路径校验 /
14
+ * 失败清理);根不存在由它内部 `ensureDir` recursive 建出。
15
+ *
16
+ * `_index.md` 刻意**不是**投射文件:md-sync 只认 `.memory.md` / `.memory.md.disable` 后缀
17
+ * (`listProjectionFiles`),孤儿扫描只遍历账本键(`sweepOrphanFiles`)——本模块从不把
18
+ * `_index.md` 写进账本,故两条通路都不会碰它(防误伤用例把这两条钉成断言)。
19
+ */
20
+ import { join } from 'node:path';
21
+ import { readFile } from 'node:fs/promises';
22
+ import { MEMORY_TYPE_LABELS, memoryTypeDirs } from '../memory-types.js';
23
+ import { atomicWriteText } from '../util/io.js';
24
+ /** 每类目录下的索引文件名(不带 `.memory.md`,因此天然落在投射/扫描面之外)。 */
25
+ export const SKELETON_INDEX_FILE = '_index.md';
26
+ /**
27
+ * 渲染一个目录的索引文本(纯函数:同一 (dir,label,count) 永远同一字节,幂等判据靠它)。
28
+ * 含类名标签、类型 id、条数三项——条数为 0 也如实写"0"。
29
+ */
30
+ function renderIndex(dir, label, count) {
31
+ return `# ${label}\n\n- 类型: ${dir}\n- 条数: ${count}\n`;
32
+ }
33
+ /**
34
+ * 往每个投射根补齐 37 类目录及各自的 `_index.md`。
35
+ *
36
+ * @param deps - 见 `SkeletonDeps`。
37
+ * @returns 无(副作用为落盘)。任何单目录失败只 warn,不抛。
38
+ */
39
+ export async function ensureTypeSkeleton(deps) {
40
+ const { roots, countsByType, logger } = deps;
41
+ // 目录集合来自词表真源,杜绝手抄漂移(换词表不改这里也不会静默少目录)。
42
+ const dirs = memoryTypeDirs();
43
+ for (const root of roots) {
44
+ for (const dir of dirs) {
45
+ const abs = join(root, dir, SKELETON_INDEX_FILE);
46
+ const count = countsByType.get(dir) ?? 0;
47
+ const label = MEMORY_TYPE_LABELS[dir] ?? dir;
48
+ const text = renderIndex(dir, label, count);
49
+ try {
50
+ // 读现内容比对:一致就跳过——既不重写,也不碰 mtime(atomicWriteText 会 rename 换 inode 刷 mtime)。
51
+ const existing = await readFile(abs, 'utf8').catch(() => undefined);
52
+ if (existing === text)
53
+ continue;
54
+ await atomicWriteText(abs, text, { logger });
55
+ }
56
+ catch (err) {
57
+ logger?.warn(`[memory] 类型骨架写入失败(旁路,不影响投射本体): ${abs} - ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,174 @@
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
+ * `dsh-memory/mem-export` 的响应 = 导出报告 + 命名回填计数。
29
+ *
30
+ * 为什么要单开一个类型:投射默认开后,导出这条路径现在会先做一步**写库**的命名回填
31
+ * (给 `name` 为空的记录派生全库唯一的文件身份名)。它只改 `name`——内容/类型/退场态都不动,
32
+ * 而 `name` 不在 `canonicalRecords` 的快照哈希字段表里,所以「导出不改检索面」依旧成立;
33
+ * 但“导出什么都不写”这句话已经不成立了,把计数摆到响应上是让这笔写在面板上看得见。
34
+ */
35
+ export interface MemExportResponse extends MemExportReport {
36
+ /** 本轮新起了多少条名字(0 = 全都已有名)。 */
37
+ named: number;
38
+ /** 起名算出来但落库失败的条数(下轮会重试)。 */
39
+ nameFailed: number;
40
+ }
41
+ /**
42
+ * 孤儿投射文件扫描报告(P4.4)。
43
+ *
44
+ * “孤儿”只算**有账可查且没人认领**的路径:账本里没有的来路不明文件(人自己放进投射目录
45
+ * 等着被同步的输入)一律不报也不动,所以这里没有“陌生文件”这一项——那个计数在同步侧。
46
+ */
47
+ export interface OrphanSample {
48
+ path: string;
49
+ /** `orphan`(干跑时只报这个)/ `moved` / `failed` / `unsafe-relPath`。 */
50
+ reason: string;
51
+ }
52
+ export interface OrphanReport {
53
+ /** 本次是不是干跑(面板要知道这段数字是哪个模式出的,与 cleanup 同一个理由)。 */
54
+ dryRun: boolean;
55
+ /** 扫过的账本条目数。 */
56
+ scanned: number;
57
+ /** 仍被某条记录(含它的 `.disable` 兄弟)认领的。 */
58
+ claimed: number;
59
+ /** 无人认领的总数(干跑时就等于“将要搬走的数”)。 */
60
+ orphans: number;
61
+ moved: number;
62
+ failed: number;
63
+ /** 账本说有、盘上没:那是“文件缺失”,对账已经在报,这里不重复处理也不删账本。 */
64
+ missing: number;
65
+ /** 因护栏没扫完的剩余条目数。 */
66
+ remaining: number;
67
+ capped: boolean;
68
+ /** 本批归档目录(干跑或什么都没做时为空串)。 */
69
+ dir: string;
70
+ samples: OrphanSample[];
71
+ }
72
+ /** 四态计数(外加两个边界态)。 */
73
+ export interface ReconcileCounts {
74
+ /** 账本里没有这条文件(首次导出前,或人新加了文件)。 */
75
+ firstTime: number;
76
+ /** 两边都没变。 */
77
+ same: number;
78
+ /** 只有库变了 → 该重渲染文件。 */
79
+ dbOnly: number;
80
+ /** 只有文件变了 → P2 该回灌库。 */
81
+ fileOnly: number;
82
+ /** 两边都变 → P2 挂待裁决,绝不自动覆盖任何一边。 */
83
+ both: number;
84
+ /** 账本说有文件、磁盘上没有(被删/被移走)。 */
85
+ missingFile: number;
86
+ }
87
+ /** 差异样本(人拿它决定要不要真同步)。 */
88
+ export interface ReconcileSample {
89
+ relPath: string;
90
+ id: string;
91
+ state: keyof ReconcileCounts;
92
+ }
93
+ /** 对账报告。`capped` 时必须提醒"计数只覆盖已扫部分,不等于全库无差异"。 */
94
+ export interface ReconcileReport extends ReconcileCounts {
95
+ root: string;
96
+ capped: boolean;
97
+ scanned: number;
98
+ samples: ReconcileSample[];
99
+ }
100
+ /** 反向同步的单条挂账样本(只给路径与做了什么,不给内容——内容可能含敏感信息)。 */
101
+ export interface MdSyncSample {
102
+ path: string;
103
+ action: string;
104
+ }
105
+ /**
106
+ * P2 反向同步报告(文件 → 库)。
107
+ *
108
+ * 与对账报告的区别:对账**只读**,同步会写。所以这里的每个计数都对应一次真实动作,
109
+ * `pending` 那批除外——那批是刻意什么都不做。
110
+ */
111
+ export interface MdSyncReport {
112
+ /** 本轮遍历过的投射根数(workspace 根 + 单根,N10)。 */
113
+ roots: number;
114
+ scanned: number;
115
+ /** 只有文件变 → 已回灌入库。 */
116
+ imported: number;
117
+ /** 只有库变 → 已重写文件。 */
118
+ rendered: number;
119
+ /** 两边都没变。 */
120
+ same: number;
121
+ /** 跳过(读不出/形状不符/写库失败)。 */
122
+ skipped: number;
123
+ /** **两边都变 → 挂待裁决**:两侧保持原样,等人裁决。 */
124
+ pending: number;
125
+ /** `.disable` → 已退场。 */
126
+ retired: number;
127
+ /** 没有账本、也认不出记录的新文件:本轮**不入库**(导入必须过写入内核)。 */
128
+ newFiles: number;
129
+ /** `.disable` 文件但找不到对应记录(改名后的 id 也追不到)。 */
130
+ orphan: number;
131
+ /**
132
+ * 遍历防护拦下的条目数(task_3.7):dirent 报链接的条目、真实路径落在投射根外的目录、
133
+ * 以及 realpath 解析失败因而无法验证的目录(含整根划不出边界那一种)。
134
+ * 与 `skipped` 的区别:那是“进来了但不合格式”,这里是**根本没进**——两者混在一个数里
135
+ * 就分不清“文件写得不对”和“目录拓扑越界”,而后者的代价是扫描被链接吞掉整棵外部树。
136
+ */
137
+ skippedLinks: number;
138
+ /**
139
+ * 扫到了但**所在根不是这条记录当前的落点根**的文件数(换过 `memExport.root`、迁库、
140
+ * workspace 根与单根并存时会出现:同一条记录在两个根下各一份)。
141
+ *
142
+ * 它们一律**不参与象限判定、不回灌、不被重写**——拿旧根那份判“谁变了”、却往新根那份写,
143
+ * 会绕过“两边都变就不动”把新根覆盖掉(真机双向往返实测出的形状)。
144
+ * 这些文件本质是孤儿,处置归 `mem-orphans` 归档,不归同步。
145
+ * 注意判据只看**根**不看全路径:同根内嵌套目录里的人手文件仍是既有语义。
146
+ */
147
+ staleRootFiles: number;
148
+ /**
149
+ * 同一条记录被两份以上投射文件认领,且**多于一份被改过** → 这些文件本轮一律不动的件数。
150
+ *
151
+ * 成因是改名(撞车消歧 / 人改 `name` / 命名回填)后旧路径文件与旧账本条目都还留着——
152
+ * 搬走它们的是 `mem-orphans`,不是同步。两份各自独立判象限就会变成两次「仅文件变」回灌,
153
+ * **后跑的赢**,前一份人写的编辑被静默丢掉;而「两边都变就不动」是按单条账本条目判的,
154
+ * 看不见对面那份(真机沙箱实测:旧路径被改 → `imported 1`,库里正文来自已改名那一侧)。
155
+ * 这里把同记录的多路径当成一个裁决单元:只要多于一份有改动就全部挂账不动,等人自己定留哪份
156
+ * (把旧残片交给孤儿归档后,下一轮就只剩一份,判定回到正常轨道)。
157
+ */
158
+ multiPath: number;
159
+ /**
160
+ * 声明 `hash` 与正文实算**不符**的文件数(N13 自校验):文件被外部改动或损坏,内容已不是
161
+ * 我们上次写出去的那份字节。它**不阻断**任何裁决——正文被人改过恰是 P2 要回灌的正常输入;
162
+ * 这里只把它从“静默按文件侧处理”变成“看得见地处理”。
163
+ */
164
+ hashMismatch: number;
165
+ /**
166
+ * 没带 `hash` 键的老投射文件数(升级前导出的产物):本身无自校验能力,按兼容放行;
167
+ * 下一轮导出会补盖指纹,因此这个数会随同步推进自然收敛到 0。
168
+ */
169
+ unsigned: number;
170
+ /** 因护栏没扫完的剩余文件数。 */
171
+ remaining: number;
172
+ capped: boolean;
173
+ samples: MdSyncSample[];
174
+ }
@@ -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,108 @@
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
+ * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
67
+ * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
68
+ */
69
+ export declare function disablePathOf(absPath: string): string;
70
+ /**
71
+ * 内容指纹。
72
+ *
73
+ * 刻意**不复用** `memport/format.ts` 的 `contentHash`:那个口径被 TS↔Python 跨实现
74
+ * 金标准锁死(`tests/memport-ts-consistency`),改了就是让两边算出不同的包。这里只
75
+ * 借它的 `normText`(NFKC + 换行归一),口径独立、且明确标注为"投射账本专用"。
76
+ */
77
+ export declare function digest(text: string): string;
78
+ /**
79
+ * 投射文件自带 `hash` 键的**规范口径**(导出写、读入核,两侧都必须走它)。
80
+ *
81
+ * 定义 = `digest(归一后的正文)`:与 `renderRecord` 写进 `hash` 的值、以及读侧拿解析出的
82
+ * `content` 实算的值三处同源。只覆盖正文、不含任何 frontmatter 字段——所以换 id、换文件名、
83
+ * 退场标记都不改变它,它只回答「正文有没有被改过」这一件事。
84
+ *
85
+ * 走 `digest`(内含 NFKC + 空白折叠)而非裸 sha1:CRLF/BOM/尾随空白这类搬运噪声不该触发
86
+ * 假不符(搬文件、换编辑器、git autocrlf 都会引入它们)。
87
+ */
88
+ export declare function contentHash(content: string): string;
89
+ /** 记录内容指纹:正文字段(不含 metadata——轴变了不算内容变)。 */
90
+ export declare function recordDigest(r: Pick<MemoryRecord, 'content'>): string;
91
+ /**
92
+ * 文件名干:有 `name` 用 `name`;没有则回落 `mem-<id 后 8 位>`。
93
+ *
94
+ * 回落是**必需**的:存量记录 name 恒空(零回填红线),不回落就没法投射第一批。
95
+ * id 每轮新铸,所以回落名不稳定——这正是 P0 要先做 `name` 的原因,注释留在这里
96
+ * 免得后来人以为"回落名也能双向"。
97
+ */
98
+ export declare function fileStemFor(r: Pick<MemoryRecord, 'id' | 'name' | 'content'>): string;
99
+ /**
100
+ * 渲染一条记录。
101
+ *
102
+ * @param r - L1 记录(含已退场的——退场也要有投影,否则人在文件树里看不到它)。
103
+ * @returns `relPath`(相对投射根,已归一分隔符)与 `text`(全文)。
104
+ */
105
+ export declare function renderRecord(r: MdRecordInput): {
106
+ relPath: string;
107
+ text: string;
108
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * L1 记录 → markdown 投射(P1 / N1)。
3
+ *
4
+ * 三条设计约束,都是被"文件要能双向"逼出来的:
5
+ *
6
+ * 1. **渲染必须是纯函数**:同一个记录永远渲染出同一份字节。否则每次导出都会重写
7
+ * 每个文件,账本里的 `fileDigest` 每轮都变,"文件被人改过"就永远判不出来。
8
+ * 所以文件里**不写导出时刻**、不写任何非记录字段。
9
+ * 2. **只写内容层,不写检索轴**(N6 口径):`hall`/`tags`/`room*`/`cogHall` 留在库里
10
+ * ——写进文件就是一份会过期的副本,还会诱导人改它(而改了又不生效)。派生评价
11
+ * (`maturity`/`usage`/`rating`)同理:它们是算出来的,不是事实。
12
+ * 3. **文件名走 `sanitizeFilename` 那一套**(与 `util/md-name.ts` 同源),type 目录名
13
+ * 走**枚举白名单**(不 sanitize——不在白名单的 type 直接归 `unsorted`,而不是
14
+ * 洗出一个没人认识的新目录)。
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import { existsSync } from 'node:fs';
18
+ import { isAbsolute, normalize, resolve, sep } from 'node:path';
19
+ import { normText } from '../memport/format.js';
20
+ import { memoryTypeDirs, UNCLASSIFIED_DIR } from '../memory-types.js';
21
+ import { deriveName } from '../util/md-name.js';
22
+ import { normalizeContentMd } from '../util/md-normalize.js';
23
+ import { sanitizeFilename } from './scenes.js';
24
+ /**
25
+ * 允许当目录名的 type 值(pm-name-distill task_1.3:由词表单一真源派生,不再手抄)。
26
+ *
27
+ * 手抄的危害很具体:换词表不改这一处,新词表里的十余类会**静默落进兜底桶**——
28
+ * 不报错、不同步失败,只少目录树。
29
+ */
30
+ export const MEMORY_TYPE_DIRS = memoryTypeDirs();
31
+ /**
32
+ * 不在词表的 type 落到这个目录(P4.1 的**默认类**)。
33
+ *
34
+ * 它是**目录层的投影**,不是库内事实:记录自己的 `type` 原值照旧写进 frontmatter,
35
+ * 也绝不回填到 `rowToRecord`/upsert/metadata(F5 红线)。之所以不直接归进某个 `work_*`:
36
+ * 那会把这批无类记录的 `family` 挑到 work 侧,而 family 决定共享面——等于静默改了可见性。
37
+ */
38
+ export const UNSORTED_DIR = UNCLASSIFIED_DIR;
39
+ /** frontmatter 允许出现的键(用例断言键集**恰等于**它,防检索轴泄漏进文件)。 */
40
+ export const FRONTMATTER_KEYS = [
41
+ 'name', 'type', 'priority', 'createdAt', 'updatedAt', 'usageScenario', 'extKey', 'id', 'hash', 'retired',
42
+ ];
43
+ /** 内容指纹长度(12 hex ≈ 48 bit;账本只用来判"变没变",不当身份用)。 */
44
+ export const DIGEST_HEX = 12;
45
+ /** 失效后缀(N11):`x.memory.md` → `x.memory.md.disable`。用户定名,取代原设计的 `.outdated`。 */
46
+ export const DISABLE_SUFFIX = '.disable';
47
+ /** workspace 优先时的子目录(N10):`<workspace>/.agents/memory/…`。 */
48
+ export const WORKSPACE_SUBDIR = ['.agents', 'memory'];
49
+ /**
50
+ * 解析单根(无 workspace 归属的记录落这里)。
51
+ *
52
+ * 给了 `cfgRoot` 就用它(必须绝对路径且不含 `..`),否则默认 `<dataDir>/memories`。
53
+ * **不合法只回退默认并 warn,绝不抛错**:投射是旁路,旁路配置写错不能弄挂记忆本体。
54
+ */
55
+ export function resolveMemoriesRoot(cfgRoot, dataDir, logger) {
56
+ const raw = (cfgRoot ?? '').trim();
57
+ if (raw.length === 0)
58
+ return resolve(dataDir, 'memories');
59
+ if (!isAbsolute(raw) || raw.includes('..')) {
60
+ logger?.warn(`[memory] memExport.root 不是无 .. 的绝对路径,回退默认根: ${raw}`);
61
+ return resolve(dataDir, 'memories');
62
+ }
63
+ return resolve(raw);
64
+ }
65
+ /**
66
+ * 把渲染出的相对路径安全地拼到根下。
67
+ *
68
+ * 越界(绝对路径、`..` 跳出、盘符切换)一律返回 `undefined` 由调用方跳过并 warn——
69
+ * 记录内容是人/模型写的,它可能包含 `../../`,这里必须是结构性挡死而不是"应该不会有"。
70
+ */
71
+ export function safeJoin(root, relPath) {
72
+ if (relPath.length === 0 || isAbsolute(relPath))
73
+ return undefined;
74
+ const parent = resolve(root);
75
+ const target = resolve(parent, relPath);
76
+ if (target === parent)
77
+ return undefined;
78
+ // 前缀判定必须带分隔符结尾,否则 `/a/memoriesX` 会被 `/a/memories` 误认包含。
79
+ const withSep = parent.endsWith('/') || parent.endsWith('\\') ? parent : `${parent}${sep}`;
80
+ return target.startsWith(withSep) ? target : undefined;
81
+ }
82
+ /**
83
+ * 投射落点(N10):**workspace 优先**。
84
+ *
85
+ * 有 `workspaceId` ⇒ `<workspace>/.agents/memory/<type>/<stem>.memory.md`;
86
+ * 没有 ⇒ 单根(`cfg.memExport.root` 或 `<dataDir>/memories`)。
87
+ *
88
+ * ⚠️ **导出/对账/失效改名/归档四处必须共用本函数**。库里的 `workspace_id` 在 Windows 上是
89
+ * 小写形态(`normalizeWorkspacePath` 会 `.toLowerCase()`),任何一处自己拼路径、或者自己
90
+ * 转一下大小写,就会把“刚写进去的文件”算成另一个账本键 → 对账永远报“文件缺失”。
91
+ * 同理:**不**在这里做 `fs.realpath` 归一——那会让目录创建前后的键漂移,反而造出假缺失;
92
+ * Windows 路径大小写不敏感,同一逻辑路径的两种写法解析到同一个文件,键自洽即可。
93
+ */
94
+ export function targetFor(r, cfgRoot, dataDir, logger) {
95
+ const { relPath } = renderRecord(r);
96
+ const ws = (r.workspaceId ?? '').trim();
97
+ if (ws.length > 0 && isAbsolute(ws) && !ws.includes('..')) {
98
+ // 护栏(默认开,不给开关):workspace 目录不存在时不往它下面写。worktree 被删、
99
+ // 盘符变了、或 `workspace_id` 是脏值时,写盘会凭空造出一棵
100
+ // `<已消失的项目>/.agents/memory/` 孤儿树——那比落回单根难查得多(单根里的文件
101
+ // 仍然可见、可导出、可同步;孤儿树没人知道它属于哪个项目)。
102
+ // 判据用 **workspace 根本身**而不是投射根:后者不存在是常态(首次导出会 mkdir)。
103
+ if (existsSync(ws)) {
104
+ const root = resolve(ws, ...WORKSPACE_SUBDIR);
105
+ return { root, relPath, absPath: safeJoin(root, relPath), inWorkspace: true };
106
+ }
107
+ warnMissingWorkspace(logger, ws);
108
+ }
109
+ const root = resolveMemoriesRoot(cfgRoot, dataDir, logger);
110
+ return { root, relPath, absPath: safeJoin(root, relPath), inWorkspace: false };
111
+ }
112
+ /**
113
+ * 已提醒过的缺失 workspace(同一个根只 warn 一次)。
114
+ *
115
+ * 不设上限就会在 1280 条记录上刷屏;集子封到 64 个就不再记录新条目(宁可漏提醒,
116
+ * 也不让一个诊断集无限吃内存)。放在 `targetFor` 之后:函数声明提升,`const` 集子在
117
+ * 模块求值后才被调用,所以顺序不影响可用性——影响的是文档块跟谁。
118
+ */
119
+ const warnedMissingWs = new Set();
120
+ function warnMissingWorkspace(logger, ws) {
121
+ if (logger === undefined)
122
+ return;
123
+ if (warnedMissingWs.has(ws))
124
+ return;
125
+ if (warnedMissingWs.size < 64)
126
+ warnedMissingWs.add(ws);
127
+ logger.warn(`[memory] workspace 目录不存在,该条投射落点退回单根: ${ws}`);
128
+ }
129
+ /**
130
+ * 投射文件的失效路径:`x.memory.md` → `x.memory.md.disable`。
131
+ * 已带后缀的原样返回(幂等靠这个性质,而不是靠调用方记住判重)。
132
+ */
133
+ export function disablePathOf(absPath) {
134
+ return absPath.endsWith(DISABLE_SUFFIX) ? absPath : `${absPath}${DISABLE_SUFFIX}`;
135
+ }
136
+ /**
137
+ * 内容指纹。
138
+ *
139
+ * 刻意**不复用** `memport/format.ts` 的 `contentHash`:那个口径被 TS↔Python 跨实现
140
+ * 金标准锁死(`tests/memport-ts-consistency`),改了就是让两边算出不同的包。这里只
141
+ * 借它的 `normText`(NFKC + 换行归一),口径独立、且明确标注为"投射账本专用"。
142
+ */
143
+ export function digest(text) {
144
+ return createHash('sha1').update(normText(text), 'utf8').digest('hex').slice(0, DIGEST_HEX);
145
+ }
146
+ /**
147
+ * 投射文件自带 `hash` 键的**规范口径**(导出写、读入核,两侧都必须走它)。
148
+ *
149
+ * 定义 = `digest(归一后的正文)`:与 `renderRecord` 写进 `hash` 的值、以及读侧拿解析出的
150
+ * `content` 实算的值三处同源。只覆盖正文、不含任何 frontmatter 字段——所以换 id、换文件名、
151
+ * 退场标记都不改变它,它只回答「正文有没有被改过」这一件事。
152
+ *
153
+ * 走 `digest`(内含 NFKC + 空白折叠)而非裸 sha1:CRLF/BOM/尾随空白这类搬运噪声不该触发
154
+ * 假不符(搬文件、换编辑器、git autocrlf 都会引入它们)。
155
+ */
156
+ export function contentHash(content) {
157
+ return digest(normalizeContentMd(content));
158
+ }
159
+ /** 记录内容指纹:正文字段(不含 metadata——轴变了不算内容变)。 */
160
+ export function recordDigest(r) {
161
+ return digest(r.content);
162
+ }
163
+ /**
164
+ * 文件名干:有 `name` 用 `name`;没有则回落 `mem-<id 后 8 位>`。
165
+ *
166
+ * 回落是**必需**的:存量记录 name 恒空(零回填红线),不回落就没法投射第一批。
167
+ * id 每轮新铸,所以回落名不稳定——这正是 P0 要先做 `name` 的原因,注释留在这里
168
+ * 免得后来人以为"回落名也能双向"。
169
+ */
170
+ export function fileStemFor(r) {
171
+ const named = (r.name ?? '').length > 0 ? r.name : deriveName(r.content);
172
+ const base = named.length > 0 ? named : `mem-${r.id.slice(-8)}`;
173
+ // 借 sanitizeFilename 的字符/保留名纪律,再剥掉它补的 .md(我们后面统一补)。
174
+ return sanitizeFilename(base).replace(/\.md$/i, '') || `mem-${r.id.slice(-8)}`;
175
+ }
176
+ /** 把值写成可解析回读的 frontmatter 标量/数组(JSON 编码,转义由它负责)。 */
177
+ function fmLine(key, value) {
178
+ return `${key}: ${JSON.stringify(value)}`;
179
+ }
180
+ /**
181
+ * 渲染一条记录。
182
+ *
183
+ * @param r - L1 记录(含已退场的——退场也要有投影,否则人在文件树里看不到它)。
184
+ * @returns `relPath`(相对投射根,已归一分隔符)与 `text`(全文)。
185
+ */
186
+ export function renderRecord(r) {
187
+ const dir = MEMORY_TYPE_DIRS.has(r.type) ? r.type : UNSORTED_DIR;
188
+ const stem = fileStemFor(r);
189
+ const relPath = normalize(`${dir}/${stem}.memory.md`).replace(/\\/g, '/');
190
+ const scenario = Array.isArray(r.metadata?.dsh_usage_scenario)
191
+ ? (r.metadata?.dsh_usage_scenario).filter((s) => typeof s === 'string')
192
+ : [];
193
+ // 正文只归一一次:`hash` 键与正文行必须落在**同一份正文**上,否则读入侧的
194
+ // 「声明 vs 实算」核对会因两次归一是否完全一致而变得可疑(归一虽幂等,复用同一变量最稳)。
195
+ const body = normalizeContentMd(r.content);
196
+ const lines = ['---'];
197
+ lines.push(fmLine('name', stem));
198
+ lines.push(fmLine('type', r.type));
199
+ lines.push(fmLine('priority', r.priority));
200
+ lines.push(fmLine('createdAt', r.createdAt ? new Date(r.createdAt).toISOString() : ''));
201
+ lines.push(fmLine('updatedAt', r.updatedAt ? new Date(r.updatedAt).toISOString() : ''));
202
+ lines.push(fmLine('usageScenario', scenario));
203
+ lines.push(fmLine('extKey', r.extKey ?? ''));
204
+ lines.push(fmLine('id', r.id));
205
+ // 正文自校验指纹(N13):写在 frontmatter 里的 `hash`,只覆盖**渲染后的正文**(与下面那行
206
+ // 正文同源)。读入侧据此确认文件未被外部改动/损坏,且**不依赖账本**——文件搬到别的机器、
207
+ // 账本丢失时仍可自证。刻意不含 frontmatter:`id` 会随 merge 重铸(F1),把 id 算进去会让
208
+ // 「换了 id、正文没动」也误报被改。
209
+ lines.push(fmLine('hash', contentHash(r.content)));
210
+ // 退场标记只在真退场时写:不留 `retired: false` 这种噪声键(键集越稳,人越不容易改错)。
211
+ if (r.validTo !== undefined)
212
+ lines.push(fmLine('retired', true));
213
+ lines.push('---', '');
214
+ // 正文过一道版式归一(P4.2):无结构存量文本出口就是标准形状。归一是**幂等**的,
215
+ // 所以不会把账本 `fileDigest` 每轮推得变一下(不幂等 = 反向同步永远判“文件被改过”)。
216
+ lines.push(body);
217
+ lines.push('');
218
+ return { relPath, text: lines.join('\n') };
219
+ }
@@ -0,0 +1,59 @@
1
+ import type { MemoryLogger } from '../types.js';
2
+ /** sidecar 版本常量(file-versions 同款集中纪律)。 */
3
+ export declare const MEMORY_SOURCES_FILE_VERSION: 1;
4
+ /** 已知外部 agent 来源类别(与 memport foreign-agent.md 对齐);'file' = 任意路径。 */
5
+ export declare const SOURCE_KINDS: readonly ["trae", "qoder", "codex", "claude", "workbuddy", "golutra", "cursor", "file"];
6
+ export type SourceKind = (typeof SOURCE_KINDS)[number];
7
+ export interface MemorySourceEntry {
8
+ /** 稳定 id(小写字母数字连字符);扫描/导入按此归类、支持按来源撤销。 */
9
+ id: string;
10
+ kind: SourceKind;
11
+ /** 路径指针(绝对路径/目录),正文永不复制进来。 */
12
+ path: string;
13
+ label?: string;
14
+ /** 最近一次扫描时间(ISO);未扫为空。 */
15
+ lastScanAt: string;
16
+ /** 最近一次导入时间(ISO);未导入为空。 */
17
+ lastImportAt: string;
18
+ /** 用户临时停用(保留条目但不参与自动扫描;≠ 删除)。 */
19
+ disabled: boolean;
20
+ status: 'active' | 'retired';
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+ export type MemorySourceInput = Pick<MemorySourceEntry, 'id' | 'kind' | 'path'> & Partial<Pick<MemorySourceEntry, 'label'>>;
25
+ export declare class MemorySourceStore {
26
+ private readonly logger?;
27
+ private readonly file;
28
+ private entries;
29
+ private rev;
30
+ private loaded;
31
+ private degraded;
32
+ private degradedLogged;
33
+ private writeChain;
34
+ constructor(dataDir: string, logger?: MemoryLogger | undefined);
35
+ /** 载入(启动 await 一次)。缺失 = 合法空表;损坏 = 只读降级(回退"无来源")。 */
36
+ init(): Promise<void>;
37
+ private ensureLoaded;
38
+ /** 全量条目(含 retired;副本返回,活引用纪律)。 */
39
+ list(): MemorySourceEntry[];
40
+ /** active 且未停用的来源(扫描/导入遍历用)。 */
41
+ listEnabled(): MemorySourceEntry[];
42
+ byId(id: string): MemorySourceEntry | undefined;
43
+ /** 登记来源(幂等:同 id 已存在则原地补全 kind/path/label,不新建)。上限防刷。 */
44
+ register(input: MemorySourceInput): Promise<{
45
+ entry: MemorySourceEntry;
46
+ created: boolean;
47
+ }>;
48
+ /** 记一次扫描时间。 */
49
+ markScanned(id: string, at: string): Promise<boolean>;
50
+ /** 记一次导入时间。 */
51
+ markImported(id: string, at: string): Promise<boolean>;
52
+ /** 启用/停用(停用保留条目、只是不参与自动扫描)。 */
53
+ setDisabled(id: string, disabled: boolean): Promise<boolean>;
54
+ /** 退役/恢复(status 迁移)。 */
55
+ setStatus(id: string, status: 'active' | 'retired'): Promise<boolean>;
56
+ private patch;
57
+ /** 写穿持久化(串行链;失败降级内存态并告警一次,绝不抛出阻塞调用方)。 */
58
+ private persist;
59
+ }