@co-engram/core 0.1.2 → 0.1.4

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 (239) hide show
  1. package/dist/bootstrap/classify.d.ts +73 -0
  2. package/dist/bootstrap/classify.d.ts.map +1 -0
  3. package/dist/bootstrap/classify.js +134 -0
  4. package/dist/bootstrap/classify.js.map +1 -0
  5. package/dist/bootstrap/index.d.ts +89 -0
  6. package/dist/bootstrap/index.d.ts.map +1 -0
  7. package/dist/bootstrap/index.js +158 -0
  8. package/dist/bootstrap/index.js.map +1 -0
  9. package/dist/concepts/dictionary.d.ts +260 -0
  10. package/dist/concepts/dictionary.d.ts.map +1 -0
  11. package/dist/concepts/dictionary.js +253 -0
  12. package/dist/concepts/dictionary.js.map +1 -0
  13. package/dist/concepts/index.d.ts +11 -0
  14. package/dist/concepts/index.d.ts.map +1 -0
  15. package/dist/concepts/index.js +10 -0
  16. package/dist/concepts/index.js.map +1 -0
  17. package/dist/concepts/types.d.ts +65 -0
  18. package/dist/concepts/types.d.ts.map +1 -0
  19. package/dist/concepts/types.js +15 -0
  20. package/dist/concepts/types.js.map +1 -0
  21. package/dist/config/defaults.d.ts +32 -3
  22. package/dist/config/defaults.d.ts.map +1 -1
  23. package/dist/config/defaults.js +53 -3
  24. package/dist/config/defaults.js.map +1 -1
  25. package/dist/config/index.d.ts +18 -1
  26. package/dist/config/index.d.ts.map +1 -1
  27. package/dist/config/index.js +75 -3
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/config/loader.d.ts +10 -0
  30. package/dist/config/loader.d.ts.map +1 -0
  31. package/dist/config/loader.js +10 -0
  32. package/dist/config/loader.js.map +1 -0
  33. package/dist/config/types.d.ts +97 -1
  34. package/dist/config/types.d.ts.map +1 -1
  35. package/dist/contradiction/auto-degrade.d.ts +11 -2
  36. package/dist/contradiction/auto-degrade.d.ts.map +1 -1
  37. package/dist/contradiction/auto-degrade.js +22 -0
  38. package/dist/contradiction/auto-degrade.js.map +1 -1
  39. package/dist/contradiction/resolver.d.ts.map +1 -1
  40. package/dist/contradiction/resolver.js +7 -1
  41. package/dist/contradiction/resolver.js.map +1 -1
  42. package/dist/dreaming/index.d.ts +1 -0
  43. package/dist/dreaming/index.d.ts.map +1 -1
  44. package/dist/dreaming/index.js +1 -0
  45. package/dist/dreaming/index.js.map +1 -1
  46. package/dist/dreaming/llm-pattern-abstraction.d.ts +31 -0
  47. package/dist/dreaming/llm-pattern-abstraction.d.ts.map +1 -0
  48. package/dist/dreaming/llm-pattern-abstraction.js +70 -0
  49. package/dist/dreaming/llm-pattern-abstraction.js.map +1 -0
  50. package/dist/dreaming/rem.d.ts.map +1 -1
  51. package/dist/dreaming/rem.js +1 -0
  52. package/dist/dreaming/rem.js.map +1 -1
  53. package/dist/dreaming/scheduler.d.ts +13 -0
  54. package/dist/dreaming/scheduler.d.ts.map +1 -1
  55. package/dist/dreaming/scheduler.js +14 -2
  56. package/dist/dreaming/scheduler.js.map +1 -1
  57. package/dist/evolution/triggered.d.ts.map +1 -1
  58. package/dist/evolution/triggered.js +1 -0
  59. package/dist/evolution/triggered.js.map +1 -1
  60. package/dist/generative/hypothesis.d.ts.map +1 -1
  61. package/dist/generative/hypothesis.js +1 -0
  62. package/dist/generative/hypothesis.js.map +1 -1
  63. package/dist/i18n/en.d.ts.map +1 -1
  64. package/dist/i18n/en.js +1278 -33
  65. package/dist/i18n/en.js.map +1 -1
  66. package/dist/i18n/index.d.ts +34 -1
  67. package/dist/i18n/index.d.ts.map +1 -1
  68. package/dist/i18n/index.js +36 -7
  69. package/dist/i18n/index.js.map +1 -1
  70. package/dist/i18n/zh.d.ts +709 -32
  71. package/dist/i18n/zh.d.ts.map +1 -1
  72. package/dist/i18n/zh.js +1282 -33
  73. package/dist/i18n/zh.js.map +1 -1
  74. package/dist/index/graph-builder.js +3 -3
  75. package/dist/index/graph-builder.js.map +1 -1
  76. package/dist/index.d.ts +3 -0
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +3 -0
  79. package/dist/index.js.map +1 -1
  80. package/dist/learning/loop.d.ts +9 -0
  81. package/dist/learning/loop.d.ts.map +1 -1
  82. package/dist/learning/loop.js +42 -1
  83. package/dist/learning/loop.js.map +1 -1
  84. package/dist/maintenance/types.d.ts +11 -0
  85. package/dist/maintenance/types.d.ts.map +1 -1
  86. package/dist/maintenance/types.js.map +1 -1
  87. package/dist/merge/auto-onboard.d.ts +7 -1
  88. package/dist/merge/auto-onboard.d.ts.map +1 -1
  89. package/dist/merge/auto-onboard.js +35 -13
  90. package/dist/merge/auto-onboard.js.map +1 -1
  91. package/dist/merge/post-merge-hook.d.ts.map +1 -1
  92. package/dist/merge/post-merge-hook.js +16 -2
  93. package/dist/merge/post-merge-hook.js.map +1 -1
  94. package/dist/merge/synapse-merger.js +6 -0
  95. package/dist/merge/synapse-merger.js.map +1 -1
  96. package/dist/merge-driver.cjs +64 -5
  97. package/dist/observability/audit-log.d.ts +7 -1
  98. package/dist/observability/audit-log.d.ts.map +1 -1
  99. package/dist/observability/audit-log.js.map +1 -1
  100. package/dist/observability/necessity-evaluator.d.ts +29 -0
  101. package/dist/observability/necessity-evaluator.d.ts.map +1 -1
  102. package/dist/observability/necessity-evaluator.js +240 -13
  103. package/dist/observability/necessity-evaluator.js.map +1 -1
  104. package/dist/observability/proposal-engine.d.ts +81 -4
  105. package/dist/observability/proposal-engine.d.ts.map +1 -1
  106. package/dist/observability/proposal-engine.js +207 -13
  107. package/dist/observability/proposal-engine.js.map +1 -1
  108. package/dist/observability/runtime-description-check.d.ts +55 -0
  109. package/dist/observability/runtime-description-check.d.ts.map +1 -0
  110. package/dist/observability/runtime-description-check.js +63 -0
  111. package/dist/observability/runtime-description-check.js.map +1 -0
  112. package/dist/prompt-signals/cache.d.ts +73 -0
  113. package/dist/prompt-signals/cache.d.ts.map +1 -1
  114. package/dist/prompt-signals/cache.js +102 -0
  115. package/dist/prompt-signals/cache.js.map +1 -1
  116. package/dist/prompt-signals/event-bus.d.ts +82 -0
  117. package/dist/prompt-signals/event-bus.d.ts.map +1 -0
  118. package/dist/prompt-signals/event-bus.js +105 -0
  119. package/dist/prompt-signals/event-bus.js.map +1 -0
  120. package/dist/prompt-signals/index.d.ts +2 -1
  121. package/dist/prompt-signals/index.d.ts.map +1 -1
  122. package/dist/prompt-signals/index.js +2 -1
  123. package/dist/prompt-signals/index.js.map +1 -1
  124. package/dist/reinforcement/ltp.d.ts +15 -1
  125. package/dist/reinforcement/ltp.d.ts.map +1 -1
  126. package/dist/reinforcement/ltp.js +24 -5
  127. package/dist/reinforcement/ltp.js.map +1 -1
  128. package/dist/reinforcement/related.d.ts +31 -2
  129. package/dist/reinforcement/related.d.ts.map +1 -1
  130. package/dist/reinforcement/related.js +39 -3
  131. package/dist/reinforcement/related.js.map +1 -1
  132. package/dist/retrieval/filter.d.ts.map +1 -1
  133. package/dist/retrieval/filter.js +7 -0
  134. package/dist/retrieval/filter.js.map +1 -1
  135. package/dist/retrieval/fts.d.ts +6 -5
  136. package/dist/retrieval/fts.d.ts.map +1 -1
  137. package/dist/retrieval/fts.js +74 -22
  138. package/dist/retrieval/fts.js.map +1 -1
  139. package/dist/status/index.d.ts +7 -0
  140. package/dist/status/index.d.ts.map +1 -0
  141. package/dist/status/index.js +7 -0
  142. package/dist/status/index.js.map +1 -0
  143. package/dist/status/status.d.ts +132 -0
  144. package/dist/status/status.d.ts.map +1 -0
  145. package/dist/status/status.js +437 -0
  146. package/dist/status/status.js.map +1 -0
  147. package/dist/storage/engram-store.d.ts.map +1 -1
  148. package/dist/storage/engram-store.js +17 -2
  149. package/dist/storage/engram-store.js.map +1 -1
  150. package/dist/storage/git.d.ts +168 -0
  151. package/dist/storage/git.d.ts.map +1 -1
  152. package/dist/storage/git.js +616 -33
  153. package/dist/storage/git.js.map +1 -1
  154. package/dist/storage/index.d.ts +1 -0
  155. package/dist/storage/index.d.ts.map +1 -1
  156. package/dist/storage/index.js +1 -0
  157. package/dist/storage/index.js.map +1 -1
  158. package/dist/storage/infra-doctor.d.ts +42 -0
  159. package/dist/storage/infra-doctor.d.ts.map +1 -0
  160. package/dist/storage/infra-doctor.js +92 -0
  161. package/dist/storage/infra-doctor.js.map +1 -0
  162. package/dist/storage/obsidian-links.d.ts +73 -0
  163. package/dist/storage/obsidian-links.d.ts.map +1 -0
  164. package/dist/storage/obsidian-links.js +177 -0
  165. package/dist/storage/obsidian-links.js.map +1 -0
  166. package/dist/storage/path.d.ts +24 -0
  167. package/dist/storage/path.d.ts.map +1 -1
  168. package/dist/storage/path.js +53 -0
  169. package/dist/storage/path.js.map +1 -1
  170. package/dist/storage/repository.d.ts +74 -5
  171. package/dist/storage/repository.d.ts.map +1 -1
  172. package/dist/storage/repository.js +337 -21
  173. package/dist/storage/repository.js.map +1 -1
  174. package/dist/storage/synapse-store.d.ts +7 -1
  175. package/dist/storage/synapse-store.d.ts.map +1 -1
  176. package/dist/storage/synapse-store.js +8 -0
  177. package/dist/storage/synapse-store.js.map +1 -1
  178. package/dist/tools/audit-query-tool.d.ts +53 -0
  179. package/dist/tools/audit-query-tool.d.ts.map +1 -0
  180. package/dist/tools/audit-query-tool.js +123 -0
  181. package/dist/tools/audit-query-tool.js.map +1 -0
  182. package/dist/tools/doctor-tools.d.ts +5 -0
  183. package/dist/tools/doctor-tools.d.ts.map +1 -1
  184. package/dist/tools/doctor-tools.js +11 -3
  185. package/dist/tools/doctor-tools.js.map +1 -1
  186. package/dist/tools/engram-tools.d.ts +13 -0
  187. package/dist/tools/engram-tools.d.ts.map +1 -1
  188. package/dist/tools/engram-tools.js +72 -8
  189. package/dist/tools/engram-tools.js.map +1 -1
  190. package/dist/tools/index.d.ts +3 -0
  191. package/dist/tools/index.d.ts.map +1 -1
  192. package/dist/tools/index.js +3 -0
  193. package/dist/tools/index.js.map +1 -1
  194. package/dist/tools/llm-descriptions.d.ts +28 -28
  195. package/dist/tools/llm-descriptions.d.ts.map +1 -1
  196. package/dist/tools/llm-descriptions.js +56 -489
  197. package/dist/tools/llm-descriptions.js.map +1 -1
  198. package/dist/tools/normalization.d.ts +43 -0
  199. package/dist/tools/normalization.d.ts.map +1 -0
  200. package/dist/tools/normalization.js +68 -0
  201. package/dist/tools/normalization.js.map +1 -0
  202. package/dist/tools/proposal-tools.d.ts +23 -1
  203. package/dist/tools/proposal-tools.d.ts.map +1 -1
  204. package/dist/tools/proposal-tools.js +58 -17
  205. package/dist/tools/proposal-tools.js.map +1 -1
  206. package/dist/tools/registry.d.ts.map +1 -1
  207. package/dist/tools/registry.js +6 -0
  208. package/dist/tools/registry.js.map +1 -1
  209. package/dist/tools/schemas.d.ts +96 -22
  210. package/dist/tools/schemas.d.ts.map +1 -1
  211. package/dist/tools/schemas.js +84 -11
  212. package/dist/tools/schemas.js.map +1 -1
  213. package/dist/tools/skill-tools.js +1 -1
  214. package/dist/tools/skill-tools.js.map +1 -1
  215. package/dist/tools/synapse-tools.d.ts.map +1 -1
  216. package/dist/tools/synapse-tools.js +1 -0
  217. package/dist/tools/synapse-tools.js.map +1 -1
  218. package/dist/tools/sync-tools.d.ts +102 -0
  219. package/dist/tools/sync-tools.d.ts.map +1 -0
  220. package/dist/tools/sync-tools.js +309 -0
  221. package/dist/tools/sync-tools.js.map +1 -0
  222. package/dist/tools/synthesize-tools.d.ts +79 -0
  223. package/dist/tools/synthesize-tools.d.ts.map +1 -0
  224. package/dist/tools/synthesize-tools.js +297 -0
  225. package/dist/tools/synthesize-tools.js.map +1 -0
  226. package/dist/tools/tool-profile.d.ts +68 -0
  227. package/dist/tools/tool-profile.d.ts.map +1 -0
  228. package/dist/tools/tool-profile.js +174 -0
  229. package/dist/tools/tool-profile.js.map +1 -0
  230. package/dist/tools/tool.d.ts +17 -0
  231. package/dist/tools/tool.d.ts.map +1 -1
  232. package/dist/tools/tool.js.map +1 -1
  233. package/dist/types/disclosure.d.ts +7 -0
  234. package/dist/types/disclosure.d.ts.map +1 -1
  235. package/dist/types/repository-types.d.ts +17 -1
  236. package/dist/types/repository-types.d.ts.map +1 -1
  237. package/dist/types/synapse.d.ts +19 -1
  238. package/dist/types/synapse.d.ts.map +1 -1
  239. package/package.json +9 -9
@@ -19,11 +19,14 @@ import { join, relative, sep } from "node:path";
19
19
  import { ulid } from "ulid";
20
20
  import { isStableEngramId } from "../types/repository-types.js";
21
21
  import { slugify, inferDomainTagsFromPath } from "../types/slugify.js";
22
+ import { safeEmit } from "../prompt-signals/event-bus.js";
23
+ import { safeJoinWithinRoot, isPathWithinRoot, } from "./path.js";
22
24
  import { computeContentHash, computeContentSize } from "./hash.js";
23
25
  import { DEFAULT_LANGUAGE } from "../i18n/index.js";
24
26
  import { readEngramFile, writeEngramFile, deleteEngramFile, renameEngramFile, parseEngramFile, detectEngramFileLanguage, } from "./engram-store.js";
25
27
  import { SYNAPSES_DIR, collectAllSynapses, upsertSynapse, readSynapseByEndpoints, readSynapseById, listSynapsesForEngram, deleteSynapsesTouching, synapseRelativePath, deleteSynapseFile, writeSynapseFile, parseSynapseFile, } from "./synapse-store.js";
26
28
  import { buildIndexEntryFromFrontmatter, engramIndexPath, readEngramIndex, rebuildEngramIndex, removeEngramIndexEntry, upsertEngramIndexEntry, writeEngramIndex, } from "./engram-index.js";
29
+ import { regenerateObsidianLinks, checkObsidianView, } from "./obsidian-links.js";
27
30
  const DEFAULT_IMPORTANCE = 0.5;
28
31
  const DEFAULT_CONFIDENCE_BY_SOURCE = {
29
32
  firsthand: 0.85,
@@ -64,6 +67,21 @@ function deriveAutoSummary(content, title) {
64
67
  return cleaned;
65
68
  return cleaned.slice(0, 197) + "...";
66
69
  }
70
+ /**
71
+ * Synapse visibility 继承规则:取两端 engram 的最严。
72
+ *
73
+ * 严格度排序:`private` > `restricted` > `team` > `public`。
74
+ * 任一端是 private,synapse 整条就按 private 处理(保守策略)。
75
+ */
76
+ const VIS_STRICTNESS = {
77
+ public: 0,
78
+ team: 1,
79
+ restricted: 2,
80
+ private: 3,
81
+ };
82
+ function maxVisibility(a, b) {
83
+ return VIS_STRICTNESS[a] >= VIS_STRICTNESS[b] ? a : b;
84
+ }
67
85
  /**
68
86
  * EngramRepository — per-edge synapse + ULID stable id + 单文件 engram
69
87
  *
@@ -90,6 +108,18 @@ export class EngramRepository {
90
108
  * 会立即触发缓存失效,无需等下次 getIndex 的 mtime 兜底检查。
91
109
  */
92
110
  indexWatcher;
111
+ /**
112
+ * 递归 fs.watch 句柄,监听 dataRoot 下所有 .md 文件变化(可选)。
113
+ *
114
+ * 触发场景(关键):git pull / git checkout / 手动编辑 / rsync 等任何外部
115
+ * 写入 .md 的途径,startWatching() 单独监听 index.json 看不到这些变化。
116
+ * dataWatcher 触发后 debounce 调用 rebuildIndex,把磁盘 .md 真相重新
117
+ * 投影到 engram-index.json,避免"git pull 拉到新文件但 engram_search
118
+ * 找不到"的 fail-silent(架构缺陷 index-no-truth 的具体表现)。
119
+ */
120
+ dataWatcher;
121
+ /** dataWatcher debounce 定时器。git pull 一次性触发大量事件,合并为一次重建。 */
122
+ dataRebuildTimer;
93
123
  language;
94
124
  constructor(config) {
95
125
  this.config = config;
@@ -180,17 +210,43 @@ export class EngramRepository {
180
210
  removeEngramIndexEntry(index, id);
181
211
  this.persistIndex(index);
182
212
  }
213
+ /**
214
+ * 清理索引中所有指向 relativePath 的孤儿 entry,返回清理数量。
215
+ *
216
+ * 触发场景:外部(用户 rm / git 操作 / 进程异常)删除 engram 文件后,
217
+ * engram-index.json 仍保留旧 ULID 的 entry。下一次 createEngram 写入
218
+ * 同 path 的新 ULID 会留下永不消失的孤儿,导致 listEngrams / viewer
219
+ * 显示"重影"(同一文件被两个 ULID 引用)。
220
+ *
221
+ * 同 path 出现多条 entry 本身就是不变量破坏 —— 此处发现即清。多数调用
222
+ * 不会命中,N=0 时是 O(|index|) 扫一次,可接受。
223
+ */
224
+ purgeStaleIndexEntriesForPath(relativePath) {
225
+ const index = this.getIndex();
226
+ const stale = [];
227
+ for (const [id, entry] of index.entries) {
228
+ if (entry.path === relativePath)
229
+ stale.push(id);
230
+ }
231
+ for (const id of stale) {
232
+ this.deleteIndexEntry(id);
233
+ }
234
+ return stale.length;
235
+ }
183
236
  // ─── Cross-process watcher ─────────────────────────────────────────────
184
237
  /**
185
- * 启动对 engram-index.json 的 fs.watch 监听。
238
+ * 启动对 engram-index.json 的 fs.watch 监听 + dataRoot .md 递归监听。
186
239
  *
187
- * 启动后,外部进程修改 index(创建/更新/删除 engram)会触发 watcher,
188
- * 主动失效 indexCache。下次 getIndex 调用时从磁盘重读。
240
+ * 启动后:
241
+ * - index.json watcher:外部进程修改 index(创建/更新/删除 engram)→ 失效 cache
242
+ * - dataRoot .md watcher:任何途径(git pull / checkout / 手动编辑)写入 .md
243
+ * → debounce 后调 rebuildIndex,把磁盘真相重新投影到 index.json
189
244
  *
190
245
  * 幂等:多次调用安全,只创建一个 watcher。
191
246
  *
192
247
  * 适用场景:多个 host adapter(MCP server / OpenClaw plugin)共享同一
193
- * dataRoot 时,确保各进程的缓存相互一致。
248
+ * dataRoot 时,确保各进程的缓存相互一致;同时覆盖 git pull 后索引漏更新
249
+ * 的 fail-silent 场景。
194
250
  *
195
251
  * 不需要显式停止 — 进程退出时 OS 自动回收 fd。stopWatching() 仅用于
196
252
  * 测试 / 显式资源管理场景。
@@ -216,6 +272,76 @@ export class EngramRepository {
216
272
  // persistIndex 写盘后会 lazy 重试 startWatching,覆盖首次空 dataRoot 场景。
217
273
  this.indexWatcher = undefined;
218
274
  }
275
+ this.startDataRootWatcher();
276
+ }
277
+ /**
278
+ * 启动 dataRoot 下 .md 文件的递归 fs.watch。
279
+ *
280
+ * Node 22+ 在 Linux/macOS/Windows 都支持 `recursive: true`(Linux 通过 inotify)。
281
+ * 不支持的平台(NFS / 老 Node)→ catch 后 noop,startWatching 的 index.json
282
+ * watcher + getIndex 的 mtime 兜底仍然有效,只是 .md 变化需要等下次主动 getIndex。
283
+ *
284
+ * 触发后通过 scheduleDataRebuild debounce 合并,避免 git pull 一次性大量事件
285
+ * 触发多次 rebuildIndex。
286
+ */
287
+ startDataRootWatcher() {
288
+ if (this.dataWatcher)
289
+ return;
290
+ try {
291
+ this.dataWatcher = watch(this.config.rootPath, { recursive: true, persistent: false }, (_eventType, filename) => {
292
+ if (process.env["CO_ENGRAM_WATCHER_DEBUG"]) {
293
+ process.stderr.write(`[co-engram-watcher] event=${_eventType} filename=${filename ?? "null"}\n`);
294
+ }
295
+ // 只关心 .md 变化(.yaml / .json / .co-engram/ 内部状态由 index.json
296
+ // watcher 或 persistIndex 路径覆盖)。filename 跨平台可能为 null,
297
+ // 不可靠时宁可多触发一次 rebuildIndex 也不要漏事件。
298
+ if (typeof filename === "string" && !filename.endsWith(".md"))
299
+ return;
300
+ this.scheduleDataRebuild();
301
+ });
302
+ this.dataWatcher.on("error", () => {
303
+ this.dataWatcher = undefined;
304
+ });
305
+ }
306
+ catch {
307
+ // 平台不支持 recursive fs.watch → 降级,功能不阻塞
308
+ this.dataWatcher = undefined;
309
+ }
310
+ }
311
+ /**
312
+ * Debounce 调用 rebuildIndex,合并短时间内的多次 .md 变化事件。
313
+ *
314
+ * git pull / rsync 等批量操作会一次性产生几十~几百个事件,逐个触发
315
+ * rebuildIndex(全量扫盘)会卡死。1000ms debounce 把它们合并成一次重建。
316
+ */
317
+ scheduleDataRebuild() {
318
+ if (this.dataRebuildTimer)
319
+ clearTimeout(this.dataRebuildTimer);
320
+ this.dataRebuildTimer = setTimeout(() => {
321
+ this.dataRebuildTimer = undefined;
322
+ try {
323
+ if (process.env["CO_ENGRAM_WATCHER_DEBUG"]) {
324
+ process.stderr.write(`[co-engram-watcher] rebuildIndex triggered\n`);
325
+ }
326
+ // rebuildIndex 全量扫盘重读所有 .md,更新 engram-index.json。
327
+ // 之后 persistIndex 写 index.json → 触发 indexWatcher → invalidateIndexCache
328
+ // → searchOrchestrator 重建 ftsIndex,完成全链路同步。
329
+ const result = this.rebuildIndex();
330
+ this.invalidateIndexCache();
331
+ if (process.env["CO_ENGRAM_WATCHER_DEBUG"]) {
332
+ const count = result.entries
333
+ ? Object.keys(result.entries).length
334
+ : "?";
335
+ process.stderr.write(`[co-engram-watcher] rebuildIndex done, entries=${count}\n`);
336
+ }
337
+ }
338
+ catch (e) {
339
+ if (process.env["CO_ENGRAM_WATCHER_DEBUG"]) {
340
+ process.stderr.write(`[co-engram-watcher] rebuildIndex failed: ${e instanceof Error ? e.message : String(e)}\n`);
341
+ }
342
+ // 重建失败不能阻塞 watcher 后续触发,静默吞掉(下次事件再次尝试)
343
+ }
344
+ }, 1000);
219
345
  }
220
346
  /** 停止 watcher(主要用于测试隔离) */
221
347
  stopWatching() {
@@ -228,6 +354,19 @@ export class EngramRepository {
228
354
  }
229
355
  this.indexWatcher = undefined;
230
356
  }
357
+ if (this.dataWatcher) {
358
+ try {
359
+ this.dataWatcher.close();
360
+ }
361
+ catch {
362
+ // ignore
363
+ }
364
+ this.dataWatcher = undefined;
365
+ }
366
+ if (this.dataRebuildTimer) {
367
+ clearTimeout(this.dataRebuildTimer);
368
+ this.dataRebuildTimer = undefined;
369
+ }
231
370
  }
232
371
  /** 失效缓存 — 下次 getIndex 从磁盘重读。同时通知 invalidate listeners。 */
233
372
  invalidateIndexCache() {
@@ -262,17 +401,26 @@ export class EngramRepository {
262
401
  /** 解析 stableId → 相对路径 */
263
402
  resolvePath(stableId) {
264
403
  if (!isStableEngramId(stableId)) {
265
- // 兼容:可能是相对路径,直接当 path
404
+ // 兼容:可能是相对路径,直接当 path 用。
405
+ // 但必须先校验路径在 root 内(防 `..` 逃逸,path traversal 防御)
406
+ if (!isPathWithinRoot(this.config.rootPath, stableId))
407
+ return undefined;
266
408
  if (this.existsAtPath(stableId))
267
409
  return stableId;
268
410
  return undefined;
269
411
  }
270
412
  const entry = this.getIndex().entries.get(stableId);
413
+ // 防御:索引中的 path 也校验(理论上是 trusted,但 doctor 自愈后可能含异常)
414
+ if (entry?.path && !isPathWithinRoot(this.config.rootPath, entry.path)) {
415
+ return undefined;
416
+ }
271
417
  return entry?.path;
272
418
  }
273
419
  /** 检查相对路径是否存在 engram 文件 */
274
420
  existsAtPath(relativePath) {
275
- return existsSync(join(this.config.rootPath, relativePath));
421
+ if (!isPathWithinRoot(this.config.rootPath, relativePath))
422
+ return false;
423
+ return existsSync(safeJoinWithinRoot(this.config.rootPath, relativePath));
276
424
  }
277
425
  // ─── Engram CRUD ───────────────────────────────────────────────────────
278
426
  /**
@@ -290,11 +438,18 @@ export class EngramRepository {
290
438
  const sourceType = input.sourceType ?? "firsthand";
291
439
  const contentHash = computeContentHash(input.content);
292
440
  const contentSize = computeContentSize(input.content);
441
+ // pathHint 优先;否则用 deriveDefaultPath(slugify title + raw domainTags + .md)
442
+ // safeJoinWithinRoot 拦截 `..` 逃逸与绝对路径(path traversal 防御)
293
443
  const relativePath = input.pathHint ?? this.deriveDefaultPath(input);
294
- const absolutePath = join(this.config.rootPath, relativePath);
444
+ const absolutePath = safeJoinWithinRoot(this.config.rootPath, relativePath);
295
445
  if (existsSync(absolutePath)) {
296
446
  throw new Error(`Engram already exists at ${relativePath}`);
297
447
  }
448
+ // 自愈:外部 rm / git 操作可能让 engram-index.json 残留指向同 path
449
+ // 但磁盘已无文件的孤儿 entry。新 ULID 写入后会留下永不消失的"重影"
450
+ // (listEngrams / viewer 显示重复节点)。在写入前清理。engram_doctor
451
+ // 是手动巡检版本,此处是写入路径的自动防线。
452
+ this.purgeStaleIndexEntriesForPath(relativePath);
298
453
  const hasExplicitDomainTags = input.domainTags.length > 0;
299
454
  const frontmatter = {
300
455
  id: stableId,
@@ -344,14 +499,29 @@ export class EngramRepository {
344
499
  contentHash,
345
500
  });
346
501
  this.updateIndexEntry(entry);
502
+ // Task 3.4 Phase B:engram 创建后 emit,让 prompt-signals cache 失效并 debounced rebuild
503
+ safeEmit({
504
+ type: "engram_created",
505
+ engramId: stableId,
506
+ at: new Date().toISOString(),
507
+ });
347
508
  return this.readEngram(stableId);
348
509
  }
349
- /** 默认路径:{domainTags.join('/')/}{slug}.md */
510
+ /**
511
+ * 默认路径:{domainTags.join('/')/}{slug}.md
512
+ *
513
+ * private engram 自动落 `private/` 子目录(被 .gitignore 隔离出团队仓库)。
514
+ * 注意:本函数只用于「无 pathHint」场景;调用方传 pathHint 时直接尊重用户路径,
515
+ * 不会自动加 private 前缀(避免 `private/private/...` 双前缀)。
516
+ */
350
517
  deriveDefaultPath(input) {
351
518
  const slug = slugify(input.title);
352
519
  const domains = input.domainTags.length > 0 ? input.domainTags : [];
353
520
  const parts = [...domains, `${slug}.md`];
354
- return parts.join("/");
521
+ const basePath = parts.join("/");
522
+ return input.visibility === "private"
523
+ ? `private/${basePath}`
524
+ : basePath;
355
525
  }
356
526
  /**
357
527
  * 读取完整 Engram(单文件 + 统计)
@@ -361,7 +531,8 @@ export class EngramRepository {
361
531
  if (!relativePath) {
362
532
  throw new Error(`Engram not found: ${stableId}`);
363
533
  }
364
- const absolutePath = join(this.config.rootPath, relativePath);
534
+ // resolvePath 已校验路径在 root 内,这里再防御一次
535
+ const absolutePath = safeJoinWithinRoot(this.config.rootPath, relativePath);
365
536
  const file = readEngramFile(absolutePath);
366
537
  return this.assembleEngram(file, relativePath);
367
538
  }
@@ -371,7 +542,8 @@ export class EngramRepository {
371
542
  exists(stableId) {
372
543
  const relativePath = this.resolvePath(stableId);
373
544
  return (relativePath !== undefined &&
374
- existsSync(join(this.config.rootPath, relativePath)));
545
+ isPathWithinRoot(this.config.rootPath, relativePath) &&
546
+ existsSync(safeJoinWithinRoot(this.config.rootPath, relativePath)));
375
547
  }
376
548
  /**
377
549
  * 更新 Engram(content/frontmatter 双写)
@@ -454,9 +626,18 @@ export class EngramRepository {
454
626
  const newSlugUnlocked = oldFrontmatter.slug === undefined
455
627
  ? slugify(newTitle)
456
628
  : oldFrontmatter.slug;
457
- const newPath = newSlugUnlocked !== oldSlug
458
- ? this.rebuildPath(relativePath, newSlugUnlocked)
459
- : relativePath;
629
+ // 处理 visibility 变化:public/team/restricted private → 路径前缀调整
630
+ // (private engram 落 `private/` 子目录,变更 visibility 时同步迁移路径)
631
+ const oldVisibility = oldFrontmatter.visibility ?? "public";
632
+ const visibilityChanged = newVisibility !== oldVisibility;
633
+ // slug + visibility 都可能触发 rename,正交串联应用
634
+ let newPath = relativePath;
635
+ if (newSlugUnlocked !== oldSlug) {
636
+ newPath = this.rebuildPath(newPath, newSlugUnlocked);
637
+ }
638
+ if (visibilityChanged) {
639
+ newPath = this.rebuildPathForVisibility(newPath, newVisibility);
640
+ }
460
641
  const newFile = {
461
642
  frontmatter: newFrontmatter,
462
643
  content: newContent,
@@ -464,10 +645,15 @@ export class EngramRepository {
464
645
  if (newPath !== relativePath) {
465
646
  const newAbsolutePath = join(this.config.rootPath, newPath);
466
647
  if (existsSync(newAbsolutePath)) {
467
- throw new Error(`Slug rename conflict: ${newPath} already exists`);
648
+ // 原子性保证:目标已存在就报错,不动旧文件
649
+ throw new Error(`Rename conflict: ${newPath} already exists`);
468
650
  }
469
651
  writeEngramFile(newAbsolutePath, newFile, this.language);
470
652
  rmSync(absolutePath);
653
+ // 旧路径孤儿 index entry 清理(原 updateEngram 漏了这一步):
654
+ // rename 后旧 path 的反向 entry(path → stableId)若不清理,会留下
655
+ // 「磁盘无文件但 index 有记录」的孤儿,listEngrams / viewer 会显示重影。
656
+ this.purgeStaleIndexEntriesForPath(relativePath);
471
657
  }
472
658
  else {
473
659
  writeEngramFile(absolutePath, newFile, this.language);
@@ -488,8 +674,28 @@ export class EngramRepository {
488
674
  parts[parts.length - 1] = `${newSlug}.md`;
489
675
  return parts.join("/");
490
676
  }
677
+ /**
678
+ * 调整路径的 visibility 前缀(不改动 basename):
679
+ * - `'private'` → 加 `private/` 前缀(若已含则幂等返回原值)
680
+ * - 其他(`'public'`/`'team'`/`'restricted'`) → 移除 `private/` 前缀(若不含则幂等返回)
681
+ *
682
+ * 用于 updateEngram 中 visibility 变更时的路径迁移。
683
+ * 注意:仅处理前缀,basename 由 rebuildPath 负责,两者正交可串联应用。
684
+ */
685
+ rebuildPathForVisibility(path, visibility) {
686
+ const PRIVATE_PREFIX = "private/";
687
+ if (visibility === "private") {
688
+ return path.startsWith(PRIVATE_PREFIX) ? path : `${PRIVATE_PREFIX}${path}`;
689
+ }
690
+ return path.startsWith(PRIVATE_PREFIX)
691
+ ? path.slice(PRIVATE_PREFIX.length)
692
+ : path;
693
+ }
491
694
  /**
492
695
  * 删除 Engram + 级联删除触及的 synapses + 清理 index
696
+ *
697
+ * 走 `this.deleteSynapsesTouching` 方法版而非模块函数,以触发
698
+ * 邻居派生段( Obsidian wikilinks)的 cascade refresh。
493
699
  */
494
700
  deleteEngram(stableId) {
495
701
  const relativePath = this.resolvePath(stableId);
@@ -497,7 +703,7 @@ export class EngramRepository {
497
703
  return;
498
704
  const absolutePath = join(this.config.rootPath, relativePath);
499
705
  deleteEngramFile(absolutePath);
500
- deleteSynapsesTouching(this.config.rootPath, stableId);
706
+ this.deleteSynapsesTouching(stableId);
501
707
  if (isStableEngramId(stableId)) {
502
708
  this.deleteIndexEntry(stableId);
503
709
  }
@@ -613,9 +819,40 @@ export class EngramRepository {
613
819
  readSynapseById(synapseId) {
614
820
  return readSynapseById(this.config.rootPath, synapseId);
615
821
  }
822
+ /**
823
+ * 触发 Obsidian 派生段重建(多条 engram 一次性刷新,去重)。
824
+ *
825
+ * 永不抛(regenerateObsidianLinks 内部已吞错)。调用方无需 try/catch。
826
+ */
827
+ refreshObsidianLinks(...ids) {
828
+ const seen = new Set();
829
+ for (const id of ids) {
830
+ if (!id || seen.has(id))
831
+ continue;
832
+ seen.add(id);
833
+ regenerateObsidianLinks(this.config.rootPath, id, this.language);
834
+ }
835
+ }
616
836
  /** 创建 synapse(idempotent) */
617
837
  createSynapse(input) {
618
- return upsertSynapse(this.config.rootPath, {
838
+ // 继承最严 visibility:取 from/to 两端 engram 的 max(private > restricted > team > public)
839
+ // 端点查不到(已删/无效 id)时降级 'public',不阻塞 synapse 创建
840
+ let fromVis = "public";
841
+ let toVis = "public";
842
+ try {
843
+ fromVis = this.readEngram(input.from).visibility ?? "public";
844
+ }
845
+ catch {
846
+ // from engram 不存在或不可读,降级 public
847
+ }
848
+ try {
849
+ toVis = this.readEngram(input.to).visibility ?? "public";
850
+ }
851
+ catch {
852
+ // to engram 不存在或不可读,降级 public
853
+ }
854
+ const inheritedVisibility = maxVisibility(fromVis, toVis);
855
+ const result = upsertSynapse(this.config.rootPath, {
619
856
  from: input.from,
620
857
  to: input.to,
621
858
  kind: input.kind,
@@ -625,8 +862,17 @@ export class EngramRepository {
625
862
  createdBy: input.createdBy,
626
863
  sourceSemantic: input.sourceSemantic,
627
864
  targetSemantic: input.targetSemantic,
865
+ visibility: inheritedVisibility,
628
866
  language: this.language,
629
867
  });
868
+ this.refreshObsidianLinks(input.from, input.to);
869
+ // Task 3.4 Phase B:synapse 创建影响 graph 结构,触发 prompt-signals rebuild
870
+ safeEmit({
871
+ type: "synapse_created",
872
+ engramId: input.from,
873
+ at: new Date().toISOString(),
874
+ });
875
+ return result;
630
876
  }
631
877
  /**
632
878
  * 更新 synapse(走 upsert,合并 evidence)。
@@ -648,7 +894,7 @@ export class EngramRepository {
648
894
  const oldPath = join(this.config.rootPath, synapseRelativePath(target.id, target.kind));
649
895
  deleteSynapseFile(oldPath);
650
896
  }
651
- return upsertSynapse(this.config.rootPath, {
897
+ const result = upsertSynapse(this.config.rootPath, {
652
898
  from: target.from,
653
899
  to: target.to,
654
900
  kind: nextKind,
@@ -659,8 +905,13 @@ export class EngramRepository {
659
905
  sourceSemantic: target.sourceSemantic,
660
906
  targetSemantic: target.targetSemantic,
661
907
  resolutionState: target.resolutionState,
908
+ // 保守策略:不因端点 visibility 提升而自动调整 synapse visibility,
909
+ // 保留原值。Phase 1.5 可加 recomputeSynapseVisibility(engramId)。
910
+ visibility: target.visibility ?? "public",
662
911
  language: this.language,
663
912
  });
913
+ this.refreshObsidianLinks(target.from, target.to);
914
+ return result;
664
915
  }
665
916
  /** 删除某条 synapse */
666
917
  deleteSynapse(synapseId) {
@@ -669,10 +920,26 @@ export class EngramRepository {
669
920
  return;
670
921
  const path = join(this.config.rootPath, synapseRelativePath(syn.id, syn.kind));
671
922
  deleteSynapseFile(path);
923
+ this.refreshObsidianLinks(syn.from, syn.to);
672
924
  }
673
925
  /** 级联删除触及 engram 的所有 synapse */
674
926
  deleteSynapsesTouching(engramId) {
675
- return deleteSynapsesTouching(this.config.rootPath, engramId);
927
+ // 先抓所有邻居 endpoint — 删除后这些 synapse 就找不到了,
928
+ // 邻居 engram.md 的派生段还引用着 engramId,需要重建。
929
+ const touching = listSynapsesForEngram(this.config.rootPath, engramId);
930
+ const neighbors = new Set();
931
+ for (const s of touching.outgoing) {
932
+ if (s.to !== engramId)
933
+ neighbors.add(s.to);
934
+ }
935
+ for (const s of touching.incoming) {
936
+ if (s.from !== engramId)
937
+ neighbors.add(s.from);
938
+ }
939
+ const count = deleteSynapsesTouching(this.config.rootPath, engramId);
940
+ if (neighbors.size > 0)
941
+ this.refreshObsidianLinks(...neighbors);
942
+ return count;
676
943
  }
677
944
  /**
678
945
  * 添加 outgoing synapse。
@@ -683,7 +950,7 @@ export class EngramRepository {
683
950
  * @returns 实际落盘的 synapse(其 id 是计算值)
684
951
  */
685
952
  addOutgoingSynapse(fromId, synapse) {
686
- return upsertSynapse(this.config.rootPath, {
953
+ const result = upsertSynapse(this.config.rootPath, {
687
954
  from: fromId,
688
955
  to: synapse.to,
689
956
  kind: synapse.kind,
@@ -701,6 +968,8 @@ export class EngramRepository {
701
968
  resolutionState: synapse.resolutionState,
702
969
  language: this.language,
703
970
  });
971
+ this.refreshObsidianLinks(fromId, synapse.to);
972
+ return result;
704
973
  }
705
974
  /**
706
975
  * 删除 outgoing synapse。
@@ -714,6 +983,7 @@ export class EngramRepository {
714
983
  return;
715
984
  const path = join(this.config.rootPath, synapseRelativePath(target.id, target.kind));
716
985
  deleteSynapseFile(path);
986
+ this.refreshObsidianLinks(target.from, target.to);
717
987
  }
718
988
  /**
719
989
  * 更新 synapse 的 resolutionState(contradicts 专用)。
@@ -822,6 +1092,12 @@ export class EngramRepository {
822
1092
  */
823
1093
  updateVerificationStatus(id, status) {
824
1094
  this.mutateFrontmatter(id, (fm) => ({ ...fm, verificationStatus: status }));
1095
+ // Task 3.4 Phase B:验证状态变化影响 prompt 的 lowConfidenceTopics
1096
+ safeEmit({
1097
+ type: "engram_verified",
1098
+ engramId: id,
1099
+ at: new Date().toISOString(),
1100
+ });
825
1101
  }
826
1102
  /**
827
1103
  * 低层 frontmatter 修改:不触发 version++,不改 slug,不改 path。
@@ -876,6 +1152,11 @@ export class EngramRepository {
876
1152
  path: orphanPath,
877
1153
  message: `Markdown file without frontmatter: ${orphanPath} (either delete it or add frontmatter with a stable id)`,
878
1154
  autoFixed: false,
1155
+ nextAction: {
1156
+ tool: "engram_create",
1157
+ argsHint: `{ title, content, kind, domainTags, createdBy } // 直接读取这个 markdown 的内容作为 engram body`,
1158
+ explanation: "Markdown 文件没有 frontmatter,所以不在 engram 索引里。如果是新记忆,用 engram_create 注册(把现有正文粘到 content);如果是废弃草稿,直接 rm 即可。",
1159
+ },
879
1160
  });
880
1161
  pendingManualReview.push(issues[issues.length - 1]);
881
1162
  });
@@ -913,11 +1194,17 @@ export class EngramRepository {
913
1194
  // 标记相关 synapse dangling
914
1195
  const touching = listSynapsesForEngram(this.config.rootPath, oldId);
915
1196
  if (touching.outgoing.length + touching.incoming.length > 0) {
1197
+ const danglingCount = touching.outgoing.length + touching.incoming.length;
916
1198
  pendingManualReview.push({
917
1199
  kind: "dangling_synapse",
918
1200
  stableId: oldId,
919
- message: `Engram ${oldId} was deleted but ${touching.outgoing.length + touching.incoming.length} synapse(s) still reference it (clean up manually or restore the engram)`,
1201
+ message: `Engram ${oldId} was deleted but ${danglingCount} synapse(s) still reference it (clean up manually or restore the engram)`,
920
1202
  autoFixed: false,
1203
+ nextAction: {
1204
+ tool: "synapse_delete",
1205
+ argsHint: `{ id: "<synapseId>" } // synapseId 在每个 synapse 的 yaml id 字段,逐条删`,
1206
+ explanation: `被删 engram 还有 ${danglingCount} 条 synapse 指向它,这些 synapse 现在是悬空的。去 synapses/ 目录找出涉及该 engram 的 synapse,用 synapse_delete 逐条清理;或者用 engram_create 重建该 engram(让 synapse 重新有目标)。`,
1207
+ },
921
1208
  });
922
1209
  }
923
1210
  }
@@ -988,6 +1275,35 @@ export class EngramRepository {
988
1275
  });
989
1276
  }
990
1277
  }
1278
+ // 5. Obsidian 视图一致性(派生段 wikilinks)
1279
+ // 对每条 engram:checkObsidianView 检测派生段与权威源(synapse yaml)不一致,
1280
+ // 不一致 → regenerateObsidianLinks 重写派生段(wikilink target=文件名)。
1281
+ for (const [id, entry] of freshIndex.entries) {
1282
+ const absPath = join(this.config.rootPath, entry.path);
1283
+ if (!existsSync(absPath))
1284
+ continue;
1285
+ let file;
1286
+ try {
1287
+ file = readEngramFile(absPath);
1288
+ }
1289
+ catch {
1290
+ continue; // parse 错误由别处报告(或phan_markdown 路径)
1291
+ }
1292
+ const touching = listSynapsesForEngram(this.config.rootPath, id);
1293
+ const status = checkObsidianView(file, touching, freshIndex);
1294
+ if (!status.stale)
1295
+ continue;
1296
+ regenerateObsidianLinks(this.config.rootPath, id, this.language);
1297
+ fixes.push({
1298
+ kind: "obsidian_view_stale",
1299
+ stableId: id,
1300
+ path: entry.path,
1301
+ message: `Obsidian derived wikilinks regenerated (target=filename, display=title·kind)`,
1302
+ autoFixed: true,
1303
+ });
1304
+ }
1305
+ // Task 3.4 Phase B:doctor 完成后 emit(doctor 可能 sweep/forget,engram 集合变化)
1306
+ safeEmit({ type: "doctor_completed", at: new Date().toISOString() });
991
1307
  return {
992
1308
  startedAt,
993
1309
  finishedAt: now(),