@sanqianx/project-knowledge 5.0.9-rc.9 → 5.0.10-rc.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 (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +140 -0
  3. package/README.md +11 -4
  4. package/_modules/knowledge-engine/README.md +9 -1
  5. package/_modules/knowledge-engine/package.json +1 -1
  6. package/_modules/knowledge-engine/src/coordinator.js +11 -2
  7. package/_modules/knowledge-engine/src/headless-analyzer.js +2 -1
  8. package/_modules/knowledge-engine/src/memories.js +133 -24
  9. package/_modules/knowledge-engine/src/retrieval.js +19 -4
  10. package/_modules/knowledge-engine/src/runtime/contracts.js +1 -0
  11. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +6 -2
  12. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +7 -2
  13. package/_modules/knowledge-engine/src/server.js +1 -1
  14. package/_modules/knowledge-engine/src/store.js +9 -6
  15. package/_site/_test/bin-cli-test.js +15 -2
  16. package/_site/_test/mcp-server-test.js +1 -1
  17. package/_site/_test/module-agent-start-proxy-test.js +65 -0
  18. package/_site/_test/module-bridge-eventbridge-test.js +1 -1
  19. package/_site/_test/module-wiring-unbind-race-test.js +59 -0
  20. package/_site/_test/path-consistency-test.js +1 -1
  21. package/_site/_test/product-diagnostics-ui-test.js +5 -1
  22. package/_site/_test/product-progress-status-test.js +70 -0
  23. package/_site/_test/shared-scope-root-binding-test.js +67 -0
  24. package/_site/_test/test-server-cleanup-test.js +5 -1
  25. package/_site/_test/vector-storage-root-test.js +16 -0
  26. package/_site/lib/engine-connection.js +4 -1
  27. package/_site/lib/knowledge-tool-runtime.js +1 -1
  28. package/_site/lib/module-bridge.js +65 -8
  29. package/_site/lib/server-app.js +4 -2
  30. package/_site/lib/settings-store.js +20 -0
  31. package/module-runtime-manifest.json +41 -41
  32. package/package.json +3 -3
  33. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  34. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  35. package/plugins/project-knowledge/.mcp.json +1 -1
  36. package/ui/index.html +2 -2
  37. package/ui/product.css +1 -0
  38. package/ui/product.js +62 -10
  39. package/vendor-manifest.json +12 -12
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.9-rc.9",
12
+ "version": "5.0.10-rc.1",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.10-rc.1] - 2026-10-12
4
+
5
+ - Add global/domain/project memory applicability and a single review form for
6
+ editing content, selecting any existing target and confirming atomically.
7
+ - Retain original evidence, scope-aware duplicate suppression, existing v5
8
+ global memories and all fixed code/knowledge directory bindings.
9
+ - Filter exclusive memories before retrieval ranking and file reads; clear old
10
+ projections after scope changes and report/retry pending file synchronization.
11
+ - Keep the accepted Workbench, vector-hub and event-bridge versions unchanged;
12
+ candidate package acceptance precedes stable latest promotion.
13
+
14
+ ## [5.0.9] - 2026-10-12
15
+
16
+ - Freeze the accepted rc.19 web runtime and exact module versions; no new
17
+ product features, old-version migration or existing v5 data reset.
18
+ - Include index failure diagnostics, index-only recovery, knowledge-root
19
+ index storage, safe client discovery/notify chaining, maintenance isolation
20
+ and the embedded Workbench startup/streaming fixes from the candidate series.
21
+ - Publish initially to next. Official npm installation, Node 22/24 regression,
22
+ real MiniMax knowledge/embedding loops and existing-user upgrade acceptance
23
+ must pass before stable latest promotion.
24
+
25
+ ## [5.0.9-rc.19] - 2026-10-12
26
+
27
+ - Pin Workbench 0.13.2-rc.7. Startup shows a loading state and keeps controls
28
+ inert until projects and event handlers are ready; the existing streaming
29
+ and session recovery paths are unchanged.
30
+ - Preserve the real rc.18 premature-click failure and require its actual
31
+ usable-page ready receipt in browser acceptance. Add a deterministic
32
+ startup gate regression and Node 22/24 retests.
33
+ - Include Windows test-server cleanup hardening and corrected storage-root
34
+ assertions. Retain existing v5 data, third-party configuration and the
35
+ official-package acceptance gate before stable latest promotion.
36
+
37
+ ## [5.0.9-rc.18] - 2026-10-12
38
+
39
+ - Project resolution for MCP and CLI now reports the vector-hub storage root
40
+ selected in settings, not the retired shell database path. A running tool
41
+ rereads root changes; resolution does not move files or rebuild indexes.
42
+ - Preserve the reproduced rc.17 diagnostic mismatch and add Node 22/24
43
+ coverage for configured, changed and unset roots and MCP output.
44
+ - Retain existing v5 data and all accepted module pins. Publish to next;
45
+ stable latest remains behind official-package and actual-user acceptance.
46
+
47
+ ## [5.0.9-rc.17] - 2026-10-11
48
+
49
+ - Pin Workbench 0.13.2-rc.6. Repeated embedded notifications for the current
50
+ project no longer abandon a newly created session; real project changes and
51
+ late project lookups still preserve isolation and per-project drafts.
52
+ - Keep a healthy progress stream labelled as connected on redundant online
53
+ events. Verify actual offline cleanup, snapshot recovery and exactly one
54
+ restored SSE connection with a real browser and HTTP proxy.
55
+ - Preserve the rc.16 real Workbench failure and require official-package
56
+ streaming/citation acceptance before upgrading the actual user instance.
57
+ - No data reset or third-party configuration overwrite. Stable latest remains
58
+ behind the full candidate and installed-product acceptance gates.
59
+
60
+ ## [5.0.9-rc.16] - 2026-10-11
61
+
62
+ - Pin Workbench 0.13.2-rc.5. Bound OpenCode's health request at both
63
+ response headers and JSON body so a stalled local server cannot exceed
64
+ the startup handshake budget before the existing CLI fallback.
65
+ - Reproduce the old behavior using two live HTTP connections, retain the
66
+ failed rc.15 client acceptance and require a new official-package retest.
67
+ - No project, knowledge, history, provider or third-party hook reset.
68
+ Candidate is published to next; stable latest still requires acceptance.
69
+
70
+ ## [5.0.9-rc.15] - 2026-10-11
71
+
72
+ - Serialize registration, module removal and registry deletion for each project.
73
+ Recheck membership inside the wiring lease so delayed supervisor registration
74
+ cannot resurrect a project after keep-data unbind. Unrelated projects remain
75
+ independent; failed deletion can still be repaired and retried.
76
+ - Reproduce the race with a delayed real HTTP registration and preserve the
77
+ downloaded rc.14 Node 22 UI failure. Include the regression in the package.
78
+ - Use non-binding CLI readiness probes in tests; the old bind probe could take
79
+ the exact port while its own foreground child was starting.
80
+ - Real MiniMax acceptance covers overlapping analyses, another commit and a
81
+ dirty developer draft during analysis, streaming, secondary development,
82
+ four installed clients and evidenced preference candidates. New downloaded
83
+ candidate acceptance and actual-user cutover still precede stable latest.
84
+
85
+ ## [5.0.9-rc.14] - 2026-10-11
86
+
87
+ - Vendor engine 0.2.3-rc.7: isolated maintenance consumes frozen evidence even
88
+ while the user edits or commits in the source repository. Do not mistake live
89
+ development for analyzer writes; retain live-source protection for custom
90
+ analyzers without the isolation capability.
91
+ - Add a paused child-worker regression covering another commit, uncommitted
92
+ drafts, frozen attribution, same-project FIFO and unchanged path/tool guards.
93
+ - Existing-user npm acceptance verified real MiniMax streaming, retained v5
94
+ history/configuration, D-root indexes and index-only e9f6560 recovery. Preserve
95
+ the actual rc.13 PROJECT_BUSY failure and require a real mid-analysis commit
96
+ retest from downloaded rc.14 before stable promotion.
97
+
98
+ ## [5.0.9-rc.13] - 2026-10-11
99
+
100
+ - Give native Agent session creation and input setup a bounded 90-second proxy
101
+ budget. Ordinary API reads/abort/permissions retain 10 seconds; SSE streaming
102
+ and folder-picker contracts are unchanged.
103
+ - Record safe proxy failure categories and deadlines without raw errors or URL
104
+ queries. Preserve MODULE_UNREACHABLE compatibility and add optional details.
105
+ - Reproduce the installed OpenCode cold-start failure with a real delayed HTTP
106
+ server; verify the targeted budget, unrelated routes and credential redaction.
107
+ - Retain rc.12 real acceptance and failed OpenCode results separately; require
108
+ a newly downloaded candidate and cold-client retest before user upgrade.
109
+
110
+ ## [5.0.9-rc.12] - 2026-10-11
111
+
112
+ - Vendor engine 0.2.3-rc.6 to address the Windows transient state-file rename
113
+ failure exposed by installed rc.11 fault acceptance. Use exclusive random
114
+ temporary files and bounded sharing retries, retain durable state on failure,
115
+ and never unlink live state to force replacement.
116
+ - Add deterministic sharing-violation, permanent-lock, foreign-temp and
117
+ disk-full regressions; all 39 engine tests pass on Node 22 and Node 24.
118
+ - Preserve the rc.11 failure and require new installed acceptance before any
119
+ existing-user upgrade or stable latest promotion.
120
+
121
+ ## [5.0.9-rc.11] - 2026-10-11
122
+
123
+ - Pin vector-hub 1.0.8-rc.3 and vendor engine 0.2.3-rc.5. Raw commit
124
+ conversations and internal backups are analysis evidence, not knowledge.
125
+ Exclude them from managed indexes, search and get; retain source evidence.
126
+ - Prune stale derived records during sync and rebuild once on source-policy
127
+ upgrade, without repeating paid whole-index work on ordinary restarts.
128
+ - Keep standalone vector-hub general document support unchanged.
129
+ - Record both original failing regressions and successful Node 22/24 reruns;
130
+ installed, real model and existing-v5 gates still precede latest promotion.
131
+
132
+ ## [5.0.9-rc.10] - 2026-10-11
133
+
134
+ - Preserve acknowledged brain/domain knowledge-directory bindings when changing
135
+ the knowledge root. Only derived indexes and future imports/shared scopes use
136
+ the new root; existing knowledge and confirmed memories stay in place.
137
+ - Persist bindings in shell metadata, reject implicit rebinding, and report
138
+ shared-scope connection failures in UI and safe structured logs.
139
+ - Add real-engine regression for confirmed memory, domain retrieval, restart,
140
+ new domain registration, offline retry and protected bindings on Node 22/24.
141
+ - Candidate acceptance still precedes the existing-v5 upgrade and stable latest.
142
+
3
143
  ## [5.0.9-rc.9] - 2026-10-11
4
144
 
5
145
  - Vendor engine 0.2.3-rc.4. Freeze existing target paths and hashes before
package/README.md CHANGED
@@ -194,13 +194,18 @@ project-knowledge
194
194
  ```
195
195
 
196
196
  The user selects a global knowledge root before importing projects. That root
197
- only chooses the directory for future imports. Each imported project stores an
197
+ chooses directories for future imports, new shared scopes, and derived vector
198
+ indexes (`<knowledge root>/.indexes`). Each imported project stores an
198
199
  absolute, fixed `knowledgePath`; changing the global root does not move existing
199
200
  projects.
200
201
 
201
- The selected knowledge root contains only authoritative project Markdown.
202
- Settings, metadata, indexes, caches, runtime state, logs, and recovery assets
203
- remain in the internal data directory:
202
+ Existing project and shared-knowledge directories do not move when the root
203
+ changes. Brain/domain directory bindings are retained in the shell settings
204
+ after the engine acknowledges them. Existing engine bindings are reused when
205
+ first recording those fields. A failed registration is shown as pending, not
206
+ healthy. Old index directories are retained; indexes rebuild at the new root.
207
+ Settings, registration metadata, caches, runtime state, logs, and recovery
208
+ assets remain in the internal data directory; vector payloads do not:
204
209
 
205
210
  ```text
206
211
  ~/.project-knowledge-v5/
@@ -220,6 +225,8 @@ remain in the internal data directory:
220
225
  └── recovery/ # migrations, promotion/index backups
221
226
 
222
227
  <selected knowledge root>/
228
+ ├── .indexes/ # rebuildable vector payloads
229
+ ├── _shared/<brain or domain ID>/ # fixed shared-knowledge bindings
223
230
  └── <project storage name>/
224
231
  ├── README.md # when produced by verified knowledge
225
232
  ├── GOAL.md
@@ -1,5 +1,7 @@
1
1
  # knowledge-engine
2
2
 
3
+ 正式知识检索(包括 `get`)排除 `dev-conversations`、内部会话、备份与隐藏目录。提交会话只作为分析证据,不直接充当已晋升知识;原始证据仍由事件模块保留。
4
+
3
5
  > 开发前读 [AGENTS.md](AGENTS.md)。该指南中的脚手架状态是历史背景;当前运行契约及实现状态以本 README 为准。
4
6
 
5
7
  `project-knowledge` 模块家族的第四块:**知识引擎**。owns 两个职责面,shell 只负责连接与投影("The shell owns no features"):
@@ -39,6 +41,8 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
39
41
 
40
42
  本模块零新增 npm 依赖、CommonJS、Node ≥18;无头 Agent SDK 由宿主已安装的 Workbench/SDK 提供,缺失时明确失败,不退回假分析。完整产品的 Node 门槛为 ≥22。
41
43
 
44
+ 隔离无头分析只读冻结证据、只写 staging,不以开发工作树的前后变化判定失败。用户可以在分析中继续编辑或提交;当前任务仍归属冻结提交,后续提交按项目 FIFO 执行。未声明隔离冻结证据能力的自定义分析器仍保留源码变更检查;目录越界、工具权限和晋升校验不变。
45
+
42
46
  每次真实分析在只读证据区冻结已有知识的目标路径与哈希清单,覆盖未被检索选中的文件;重试复用该清单。Agent 不得把“未检索到”当作“文件不存在”,也不得用部分片段覆盖完整旧文档;无法保留原文时只追加当前提交的变更记录。已有目标仍由晋升校验拒绝非法 create,不自动改为覆盖。清单仅保存元数据,不把整个知识树复制到每次运行中。
43
47
 
44
48
  ## API
@@ -58,7 +62,7 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
58
62
  | GET | `/api/snapshot`、`/api/progress` | 持久化快照 / 快照先行的 SSE 更新 |
59
63
  | GET / POST | `/api/events` | 真实事件查询 / 仅调试模式注入事件 |
60
64
  | POST | `/api/bridge/notify` | 唤醒 journal 消费;通知丢失仍由轮询恢复 |
61
- | GET / PATCH | `/api/memories`、`/api/memories/:id` | 候选和已确认记忆;编辑、确认、忽略持久化 |
65
+ | GET / PATCH | `/api/memories`、`/api/memories/:id` | 按 status/scopeType/scopeId 筛选;一次编辑内容、适用范围和确认状态;来源证据只读 |
62
66
  | POST | `/api/scopes` | 外壳投影智脑、领域共享目录;受运行期鉴权保护 |
63
67
  | POST | `/api/runtime-config` | 运行期模型配置,仅保留在内存;受鉴权保护 |
64
68
  | GET | `/api/runtime-auth` | 本机服务确认运行期鉴权;受鉴权保护,不对浏览器反代 |
@@ -81,7 +85,11 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
81
85
  - 项目、领域、智脑检索使用包含范围。未分配领域不是共享领域:领域范围只包含当前项目与智脑共享知识;显式加入同一领域后才包含其他项目。整个智脑范围仍包含全部同智脑项目。向量只提供排序,显示内容必须来自当前真实文件;索引不可用时降级为文件检索。
82
86
  - 已晋升提交的 SHA 或短 SHA 在自然语言查询中仍作为标识符匹配,只返回当前文件的晋升依据;仅在原始会话中提到相同 SHA 不构成知识晋升引用。多个提交标识符可以一起查询。
83
87
  - 候选必须有已晋升文件中的引用依据,未确认候选不能进入任务。确认记录原子持久化后写共享记忆文件,派生索引异步更新,索引失败不会撤销有效记忆。
88
+ - 记忆的适用范围为 `global/domain/project`,目标用 `scopeId` 指定;来源项目/领域不等于适用范围。新候选默认来源项目,分析器只有明确证据时才建议领域/全局;用户审核时可以编辑内容并选择当前智脑中的其他已有目标。原始总结与证据保持不变,同一范围内容去重,忽略和编辑后的原候选不会重复生成。
89
+ - 长期记忆统一保存在已绑定的智脑共享目录:`memories/confirmed.md`(全局)、`memories/domains/<id>.md`、`memories/projects/<id>.md`。不创建第二份项目知识目录。现有 v5 缺少范围字段的记忆仍按全局解释;不读取 pre-v5 状态、不重扫历史。
90
+ - 任务检索只允许当前项目、当前所属领域与全局的已确认记忆,即使检索整个智脑也不放宽专属记忆范围。过滤在排名之前完成,`get` 同样校验;向量旧内容不能绕过当前状态和文件一致性校验。已确认记录修改范围会同步清理旧投影;写入失败显示待同步并自动重试,未同步文件不能进入上下文。
84
91
  - 旧格式不读取、不自动迁移。损坏的正式状态不覆盖、不清空。
92
+ - 引擎状态复用受控原子写入:独占随机临时文件、文件同步、Windows 短暂共享冲突有界重试;永久失败保留原状态并清理本次临时文件,绝不先删正式状态。
85
93
 
86
94
  ## 验证与尚未验收项
87
95
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/ai-coding-knowledge-engine",
3
- "version": "0.2.3-rc.4",
3
+ "version": "0.2.4-rc.1",
4
4
  "description": "Knowledge engine module for project-knowledge: commit-boundary triggered knowledge analysis/generation (trigger plane) and retrieval for development assistants (retrieval plane).",
5
5
  "license": "Apache-2.0",
6
6
  "main": "src/server.js",
@@ -39,7 +39,15 @@ class EngineCoordinator extends EventEmitter {
39
39
  this.sharedIndexJobs = new Map();
40
40
  this.sharedIndexDirty = new Set();
41
41
  this.sharedIndexQueue = Promise.resolve();
42
- this.memories = new MemoryService(store, { onMaterialized: scope => { this.indexSharedScope(scope).catch(() => {}); } });
42
+ this.memories = new MemoryService(store, {
43
+ getIndexStatus: scope => scope && this.sharedIndexJobs.has(scope.id) ? 'pending' : null,
44
+ onMaterialized: scope => { this.indexSharedScope(scope).catch(() => {}); },
45
+ onSyncChange: (scope, code) => {
46
+ this.emit('progress', store.addEvent({ type: 'memory.sync', scopeId: scope.id, summary: code ? `记忆文件待恢复(${code})` : '记忆文件已恢复同步' }));
47
+ const log = code ? 'error' : 'info';
48
+ Promise.resolve(this.diagnostics[log]('knowledge.memory.' + (code ? 'write_failed' : 'write_recovered'), code ? '记忆状态已保存,文件待恢复。' : '记忆文件已恢复同步。', { phase: 'memory-sync', context: { scopeId: scope.id, code: code || null } })).catch(() => {});
49
+ },
50
+ });
43
51
  this.projectStore = {
44
52
  readConfig: id => { const p = store.getProject(id); if (!p) throw Object.assign(new Error('项目未登记。'), { code: 'PROJECT_NOT_FOUND' }); return { ...p, displayName: p.name, automation: { enabled: true } }; },
45
53
  readState: id => store.readRuntime(id),
@@ -50,7 +58,7 @@ class EngineCoordinator extends EventEmitter {
50
58
  this.retrieval = new EngineRetrieval({ layout: this.layout, projectStore: this.projectStore, registryStore, store, memories: this.memories, vectorUrl: this.vectorUrl });
51
59
  this.conversations = new ConversationStore({ layout: this.layout, projectStore: this.projectStore });
52
60
  this.analyzer = options.analyzer || new HeadlessAnalyzer({ resolveProfile: id => this.profiles.get(id) || this.profiles.get('default'), timeoutMs: options.timeoutMs });
53
- this.promotion = new KnowledgePromotionService({ layout: this.layout, logger: this.diagnostics, projectStore: this.projectStore, analyzer: { runClaim: async input => {
61
+ this.promotion = new KnowledgePromotionService({ layout: this.layout, logger: this.diagnostics, projectStore: this.projectStore, analyzer: { sourceReadMode: this.analyzer.sourceReadMode, runClaim: async input => {
54
62
  const run = this.currentRun(input.projectId);
55
63
  store.updateRun(run.runId, { analysisRunId: input.claim.runId, phase: 'analyzing' });
56
64
  this.notify(run, '正在分析冻结的提交证据');
@@ -291,6 +299,7 @@ class EngineCoordinator extends EventEmitter {
291
299
  return run;
292
300
  }
293
301
  async close() {
302
+ await this.memories.close();
294
303
  this.closed = true; clearInterval(this.poll);
295
304
  this.bridge.close?.();
296
305
  await this.analyzer.close?.();
@@ -6,6 +6,7 @@ const { finalizeAnalysisOutput } = require('./analysis-output');
6
6
  const { freezeKnowledgeTargets } = require('./knowledge-targets');
7
7
 
8
8
  class HeadlessAnalyzer {
9
+ get sourceReadMode() { return 'isolated-frozen-evidence'; }
9
10
  constructor({ resolveProfile, timeoutMs = 15 * 60 * 1000, workerPath = path.join(__dirname, 'analysis-worker.js') } = {}) { this.resolveProfile = resolveProfile; this.timeoutMs = timeoutMs; this.workerPath = workerPath; this.children = new Map(); this.closed = false; }
10
11
  async runClaim(input) {
11
12
  if (this.closed) throw Object.assign(new Error('分析服务正在关闭。'), { code: 'WORKER_STOPPED', retryable: true });
@@ -50,7 +51,7 @@ class HeadlessAnalyzer {
50
51
  if (!requested) outcome = Object.assign(new Error('后台分析进程意外退出。'), { code: 'WORKER_EXITED', retryable: true });
51
52
  if (outcome) reject(outcome); else resolve();
52
53
  });
53
- const memoryContract = '\nYou may optionally include memoryCandidates in manifest.json: [{text, entryId, quote}]. Only suggest durable user preferences explicitly evidenced in a promoted document; quote must be an exact excerpt (at least 8 characters) from that document. Never infer preferences from code style alone. These stay unconfirmed until the user accepts them.';
54
+ const memoryContract = '\nYou may optionally include memoryCandidates in manifest.json: [{text, entryId, quote, scopeType}]. Only suggest durable user preferences explicitly evidenced in a promoted document; quote must be an exact excerpt (at least 8 characters) from that document. Never infer preferences from code style alone. scopeType is project (default), domain or global. Recommend domain/global only when the quoted owner requirement explicitly applies across that domain/all projects; ambiguous or local requirements stay project-scoped. Do not invent target IDs. These stay unconfirmed until the user edits their text/scope and accepts them.';
54
55
  const languageContract = input.config?.knowledgeLanguage === 'en-US' ? '\nOUTPUT LANGUAGE: English. Preserve identifiers and verbatim evidence quotations.' : '\n输出语言:简体中文。知识正文、operations[].reason 与候选记忆用简体中文;代码标识、路径、哈希和原文证据引用保持不变。';
55
56
  child.send({ profile, projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha, prompt: `${frameFrozenEvidence(input.prompt)}${contract}${pathContract}${targetContract}${memoryContract}${languageContract}`, manifestPath: input.manifestPath, stagingPath: input.stagingPath, safetyPolicy: input.safetyPolicy }, error => { if (error) stop(Object.assign(new Error('无法发送分析任务。'), { code: 'WORKER_START_FAILED', retryable: true })); });
56
57
  });
@@ -6,8 +6,69 @@ const AtomicFile = require('./runtime/atomic-file');
6
6
  const { RuntimeLayout } = require('./runtime/layout');
7
7
  const { validateProjectId, DomainError } = require('./runtime/contracts');
8
8
 
9
+ const TYPES = ['global', 'domain', 'project'];
10
+ const normalized = text => String(text).toLowerCase().replace(/\s+/g, ' ').trim();
11
+ const fingerprint = (brainId, type, id, text) => crypto.createHash('sha256').update(`${brainId}:${type}:${id}:${normalized(text)}`).digest('hex');
12
+
9
13
  class MemoryService {
10
- constructor(store, options = {}) { this.store = store; this.layout = new RuntimeLayout(store.dataDir); this.onMaterialized = options.onMaterialized; }
14
+ constructor(store, options = {}) {
15
+ this.store = store; this.layout = new RuntimeLayout(store.dataDir); this.onMaterialized = options.onMaterialized;
16
+ this.syncErrors = new Map(); this.closed = false; this.onSyncChange = options.onSyncChange; this.writes = new Set();
17
+ this.getIndexStatus = options.getIndexStatus;
18
+ }
19
+ lock() { return this.layout.getRuntimePath('locks', 'memories.lock'); }
20
+ scopeOf(memory) { return { type: memory.scopeType || 'global', id: memory.scopeId || memory.brainId || 'brain-default' }; }
21
+ brainScope(brainId) { return this.store.state.sharedScopes.find(s => s.kind === 'brain' && s.brainId === brainId); }
22
+ target(memory) {
23
+ const scope = this.scopeOf(memory), brainId = memory.brainId || 'brain-default';
24
+ const target = scope.type === 'global' ? this.brainScope(brainId) : scope.type === 'domain'
25
+ ? this.store.state.sharedScopes.find(s => s.kind === 'domain' && s.id === scope.id && s.brainId === brainId)
26
+ : this.store.listProjects().find(p => p.projectId === scope.id && p.brainId === brainId && p.enabled !== false);
27
+ return { name: target?.name || scope.id, available: Boolean(target) };
28
+ }
29
+ validateScope(type, id, brainId) {
30
+ if (!TYPES.includes(type)) throw new DomainError('INVALID_ARGUMENT', '适用范围无效。');
31
+ validateProjectId(id);
32
+ if (type === 'global' && id !== brainId || type !== 'global' && !this.target({ brainId, scopeType: type, scopeId: id }).available) throw new DomainError('INVALID_ARGUMENT', '所选记忆目标不存在或不属于当前智脑。');
33
+ }
34
+ entry(memory) {
35
+ const scope = this.scopeOf(memory);
36
+ if (scope.type === 'global') return 'memories/confirmed.md';
37
+ validateProjectId(scope.id);
38
+ return `memories/${scope.type === 'domain' ? 'domains' : 'projects'}/${scope.id}.md`;
39
+ }
40
+ descriptor(entry) {
41
+ if (entry === 'memories/confirmed.md') return { type: 'global' };
42
+ const match = /^memories\/(domains|projects)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.md$/.exec(entry);
43
+ if (!match) return null;
44
+ try { validateProjectId(match[2]); } catch { return null; }
45
+ return { type: match[1] === 'domains' ? 'domain' : 'project', id: match[2] };
46
+ }
47
+ file(scope, entry = 'memories/confirmed.md') { return path.join(scope.knowledgePath, ...entry.split('/')); }
48
+ safeFile(scope, entry) {
49
+ const file = this.file(scope, entry);
50
+ if (!this.descriptor(entry) || !this.layout.isPathInside(scope.knowledgePath, file, { realpath: true }) || fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new DomainError('PATH_OUTSIDE_ROOT', '记忆文件目录越界。', { status: 403 });
51
+ return file;
52
+ }
53
+ confirmed(brainId, entry) { return this.store.state.memories.filter(m => m.status === 'confirmed' && (m.brainId || 'brain-default') === brainId && this.entry(m) === entry); }
54
+ applicable(memory, project) {
55
+ if (!project || memory.status !== 'confirmed' || (memory.brainId || 'brain-default') !== project.brainId || !this.target(memory).available) return false;
56
+ const scope = this.scopeOf(memory);
57
+ return scope.type === 'global' || scope.type === 'domain' && scope.id === project.domainId || scope.type === 'project' && scope.id === project.projectId;
58
+ }
59
+ readableEntry(scope, entry, project) {
60
+ return scope.kind === 'brain' && !this.syncErrors.has(scope.id) && this.validFile(scope, entry) && this.confirmed(scope.brainId, entry).some(m => this.applicable(m, project));
61
+ }
62
+ memoryForChunk(scope, entry, heading) {
63
+ return this.confirmed(scope.brainId, entry).find(m => (Array.isArray(heading) ? heading : String(heading).split(' > ')).includes(m.id));
64
+ }
65
+ project(memory) {
66
+ const scope = this.scopeOf(memory), target = this.target(memory), brain = this.brainScope(memory.brainId || 'brain-default');
67
+ return { ...memory, scopeType: scope.type, scopeId: scope.id, targetName: target.name, targetAvailable: target.available,
68
+ syncStatus: memory.status !== 'confirmed' ? 'not-required' : brain && !this.syncErrors.has(brain.id) && this.validFile(brain, this.entry(memory)) ? 'ready' : 'pending',
69
+ indexStatus: memory.status === 'confirmed' ? this.getIndexStatus?.(brain) || (brain?.index?.error ? 'failed' : brain?.index?.status || 'pending') : 'not-required',
70
+ ...(brain && this.syncErrors.has(brain.id) ? { syncError: this.syncErrors.get(brain.id) } : {}) };
71
+ }
11
72
  async configure(scopes) {
12
73
  if (!Array.isArray(scopes)) throw new DomainError('INVALID_ARGUMENT', '共享范围必须是数组。');
13
74
  const ids = new Set();
@@ -31,7 +92,10 @@ class MemoryService {
31
92
  await this.recover();
32
93
  return projected;
33
94
  }
34
- list({ brainId, status } = {}) { return this.store.state.memories.filter(m => (!brainId || m.brainId === brainId) && (!status || m.status === status)); }
95
+ list({ brainId, status, scopeType, scopeId } = {}) {
96
+ if (scopeType && !TYPES.includes(scopeType)) throw new DomainError('INVALID_ARGUMENT', '记忆筛选范围无效。');
97
+ return this.store.state.memories.filter(m => (!brainId || m.brainId === brainId) && (!status || m.status === status) && (!scopeType || this.scopeOf(m).type === scopeType) && (!scopeId || this.scopeOf(m).id === scopeId)).map(m => this.project(m));
98
+ }
35
99
  capture(run) {
36
100
  const project = this.store.getProject(run.projectId);
37
101
  if (!project || !run.promotedAt) return;
@@ -43,43 +107,88 @@ class MemoryService {
43
107
  const entryId = String(raw.entryId || '');
44
108
  const file = path.resolve(project.knowledgePath, entryId);
45
109
  if (!text || text.length > 2000 || quote.length < 8 || !run.staging.operations?.some(op => op.operation !== 'delete' && op.path === entryId) || !this.layout.isPathInside(project.knowledgePath, file, { realpath: true }) || !fs.existsSync(file) || !fs.readFileSync(file, 'utf8').includes(quote)) continue;
46
- const fingerprint = crypto.createHash('sha256').update(`${project.brainId || 'brain-default'}:${text.toLowerCase().replace(/\s+/g, ' ')}`).digest('hex');
47
- if (this.store.state.memories.some(m => m.fingerprint === fingerprint)) continue;
48
- this.store.state.memories.push({ id: `memory-${crypto.randomUUID()}`, fingerprint, brainId: project.brainId || 'brain-default', domainId: project.domainId || null, text, status: 'candidate', evidence: { projectId: project.projectId, commitSha: run.commitSha, runId: run.runId, entryId, quote }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
110
+ const brainId = project.brainId || 'brain-default';
111
+ const scopeType = raw.scopeType === 'global' ? 'global' : raw.scopeType === 'domain' && project.domainId ? 'domain' : 'project';
112
+ const scopeId = scopeType === 'global' ? brainId : scopeType === 'domain' ? project.domainId : project.projectId;
113
+ const key = fingerprint(brainId, scopeType, scopeId, text);
114
+ if (this.store.state.memories.some(m => m.captureFingerprint === key || fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key || !m.scopeType && m.evidence.projectId === project.projectId && normalized(m.originalText || m.text) === normalized(text))) continue;
115
+ this.store.state.memories.push({ id: `memory-${crypto.randomUUID()}`, fingerprint: key, captureFingerprint: key, brainId, domainId: project.domainId || null, scopeType, scopeId, text, originalText: text, status: 'candidate', evidence: { projectId: project.projectId, commitSha: run.commitSha, runId: run.runId, entryId, quote }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
49
116
  }
50
117
  this.store.save();
51
118
  }
52
119
  async update(id, input) {
53
- return AtomicFile.withFileLock(this.layout.getRuntimePath('locks', 'memories.lock'), async () => {
120
+ if (this.closed) throw new DomainError('PROJECT_BUSY', '引擎正在关闭,请稍后重试。', { status: 409 });
121
+ if (Object.keys(input).some(key => !['text', 'status', 'scopeType', 'scopeId'].includes(key))) throw new DomainError('INVALID_ARGUMENT', '来源证据和文件路径不能修改。');
122
+ const job = AtomicFile.withFileLock(this.lock(), async () => {
54
123
  const memory = this.store.state.memories.find(m => m.id === id);
55
124
  if (!memory) throw new DomainError('PROJECT_NOT_FOUND', '记忆不存在。', { status: 404 });
56
125
  if (input.text !== undefined && (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 2000)) throw new DomainError('INVALID_ARGUMENT', '记忆内容必须为 1–2000 个字符。');
57
126
  if (input.status !== undefined && !['confirmed', 'ignored'].includes(input.status)) throw new DomainError('INVALID_ARGUMENT', '记忆状态无效。');
58
127
  if (memory.status === 'ignored') throw new DomainError('INVALID_ARGUMENT', '该候选已忽略。', { status: 409 });
59
- if (input.status === 'confirmed' && !this.store.state.sharedScopes.some(s => s.kind === 'brain' && s.brainId === memory.brainId)) throw new DomainError('INVALID_ARGUMENT', '请先绑定智脑共享目录。', { status: 409 });
60
- Object.assign(memory, { text: input.text?.trim() || memory.text, status: input.status || memory.status, updatedAt: new Date().toISOString() });
61
- // Persist confirmation before writing. Retrieval verifies the materialized
62
- // file against this state, so a crash cannot inject unconfirmed content.
63
- this.store.save();
64
- await this.recover();
65
- return memory;
128
+ const brainId = memory.brainId || 'brain-default', old = this.scopeOf(memory);
129
+ const type = input.scopeType === undefined ? old.type : input.scopeType;
130
+ const targetId = input.scopeId === undefined ? (type === 'global' ? brainId : type === old.type ? old.id : '') : input.scopeId;
131
+ if (input.status !== 'ignored' || input.scopeType !== undefined || input.scopeId !== undefined) this.validateScope(type, targetId, brainId);
132
+ if ((input.status || memory.status) === 'confirmed' && !this.brainScope(brainId)) throw new DomainError('INVALID_ARGUMENT', '请先绑定智脑共享目录。', { status: 409 });
133
+ const text = input.text?.trim() || memory.text, key = fingerprint(brainId, type, targetId, text);
134
+ if (this.store.state.memories.some(m => m.id !== id && fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key)) throw new DomainError('MEMORY_DUPLICATE', '该范围内已存在相同记忆。', { status: 409 });
135
+ const next = { ...memory, originalText: memory.originalText || memory.text, captureFingerprint: memory.captureFingerprint || fingerprint(brainId, old.type, old.id, memory.originalText || memory.text), fingerprint: key, scopeType: type, scopeId: targetId, text, status: input.status || memory.status, updatedAt: new Date().toISOString() };
136
+ const before = this.store.state.memories;
137
+ this.store.state.memories = before.map(m => m.id === id ? next : m);
138
+ try { this.store.save(); } catch (error) { this.store.state.memories = before; throw error; }
139
+ await this.materialize(); return this.project(next);
66
140
  });
141
+ this.writes.add(job);
142
+ try { return await job; } finally { this.writes.delete(job); }
67
143
  }
68
- content(brainId) {
69
- return '# 已确认的长期记忆\n\n' + this.list({ brainId, status: 'confirmed' }).map(m => `## ${m.id}\n\n${m.text}\n\n来源:${m.evidence.projectId} · ${m.evidence.commitSha} · ${m.evidence.entryId}\n`).join('\n');
144
+ content(brainId, entry = 'memories/confirmed.md') {
145
+ return '# 已确认的长期记忆\n\n' + this.confirmed(brainId, entry).map(m => {
146
+ const scope = this.scopeOf(m);
147
+ // Preserve legacy global bytes until the user actually edits that record.
148
+ const scopeText = m.scopeType ? `适用范围:${{ global: '全局', domain: '领域', project: '项目' }[scope.type]} · ${scope.id}\n\n` : '';
149
+ const text = m.scopeType ? m.text.replace(/^\s*(#{1,6})\s/gm, '\\$1 ') : m.text;
150
+ return `## ${m.id}\n\n${scopeText}${text}\n\n来源:${m.evidence.projectId} · ${m.evidence.commitSha} · ${m.evidence.entryId}\n`;
151
+ }).join('\n');
70
152
  }
71
- file(scope) { return path.join(scope.knowledgePath, 'memories', 'confirmed.md'); }
72
- async recover() {
153
+ existingEntries(scope) {
154
+ const entries = new Set(['memories/confirmed.md']);
155
+ for (const group of ['domains', 'projects']) {
156
+ const directory = path.join(scope.knowledgePath, 'memories', group);
157
+ if (!fs.existsSync(directory)) continue;
158
+ if (fs.lstatSync(directory).isSymbolicLink() || !this.layout.isPathInside(scope.knowledgePath, directory, { realpath: true })) throw new DomainError('PATH_OUTSIDE_ROOT', '记忆目录越界。', { status: 403 });
159
+ for (const name of fs.readdirSync(directory)) if (this.descriptor(`memories/${group}/${name}`)) entries.add(`memories/${group}/${name}`);
160
+ }
161
+ for (const memory of this.store.state.memories.filter(m => m.status === 'confirmed' && (m.brainId || 'brain-default') === scope.brainId)) entries.add(this.entry(memory));
162
+ return entries;
163
+ }
164
+ async materialize() {
165
+ clearTimeout(this.retryTimer);
73
166
  for (const scope of this.store.state.sharedScopes.filter(s => s.kind === 'brain')) {
74
- const file = this.file(scope);
75
- if (!this.layout.isPathInside(scope.knowledgePath, file, { realpath: true }) || fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new DomainError('PATH_OUTSIDE_ROOT', '记忆文件目录越界。', { status: 403 });
76
- const content = this.content(scope.brainId);
77
- if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== content) {
78
- AtomicFile.writeFileAtomic(file, content);
79
- await this.onMaterialized?.(scope);
167
+ try {
168
+ let changed = false;
169
+ for (const entry of this.existingEntries(scope)) {
170
+ const file = this.safeFile(scope, entry), content = this.content(scope.brainId, entry);
171
+ if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== content) { AtomicFile.writeFileAtomic(file, content); changed = true; }
172
+ }
173
+ const recovered = this.syncErrors.delete(scope.id);
174
+ if (recovered) this.onSyncChange?.(scope, null);
175
+ if (changed) await this.onMaterialized?.(scope);
176
+ } catch (error) {
177
+ const code = error.code || 'MEMORY_WRITE_FAILED', prior = this.syncErrors.get(scope.id);
178
+ this.syncErrors.set(scope.id, code);
179
+ if (prior !== code) this.onSyncChange?.(scope, code);
80
180
  }
81
181
  }
182
+ if (this.syncErrors.size && !this.closed) { this.retryTimer = setTimeout(() => this.recover().catch(() => {}), 5000); this.retryTimer.unref(); }
183
+ }
184
+ async recover() {
185
+ if (this.recovery) return this.recovery;
186
+ this.recovery = AtomicFile.withFileLock(this.lock(), () => this.materialize()).finally(() => { this.recovery = null; });
187
+ return this.recovery;
188
+ }
189
+ validFile(scope, entry = 'memories/confirmed.md') {
190
+ try { const file = this.safeFile(scope, entry); return fs.existsSync(file) && fs.readFileSync(file, 'utf8') === this.content(scope.brainId, entry); } catch { return false; }
82
191
  }
83
- validFile(scope) { const file = this.file(scope); return fs.existsSync(file) && fs.readFileSync(file, 'utf8') === this.content(scope.brainId); }
192
+ async close() { this.closed = true; clearTimeout(this.retryTimer); await Promise.allSettled([...this.writes, this.recovery].filter(Boolean)); }
84
193
  }
85
194
  module.exports = { MemoryService };
@@ -2,7 +2,7 @@
2
2
  const fs = require('node:fs');
3
3
  const path = require('node:path');
4
4
  const { KnowledgeRetrievalService } = require('./runtime/knowledge-retrieval-service');
5
- const { listMarkdownFiles } = require('./runtime/markdown-knowledge-indexer');
5
+ const { listMarkdownFiles, isExcludedKnowledgePath } = require('./runtime/markdown-knowledge-indexer');
6
6
  const { DomainError } = require('./runtime/contracts');
7
7
 
8
8
  class EngineRetrieval extends KnowledgeRetrievalService {
@@ -45,15 +45,22 @@ class EngineRetrieval extends KnowledgeRetrievalService {
45
45
  return this.store.state.sharedScopes.filter(s => s.brainId === registration.brainId && (s.kind === 'brain' || range === 'brain' || registration.domainId && s.domainId === registration.domainId)).map(s => ({ projectId: s.id, spaceId: `${s.kind}:${s.id}`, knowledgePath: s.knowledgePath, weight: s.kind === 'brain' ? 0.6 : 0.75, reason: s.kind }));
46
46
  }
47
47
  scanCurrent(scopes) {
48
+ const registration = this.store.getProject(scopes.find(s => s.reason === 'primary')?.projectId);
48
49
  const ordinary = scopes.filter(s => this.store.getProject(s.projectId));
49
50
  const shared = scopes.filter(s => !this.store.getProject(s.projectId));
50
51
  const result = super.scanCurrent(ordinary);
52
+ for (const [id, candidate] of result.candidates) if (candidate.entryId.startsWith('memories/')) result.candidates.delete(id);
51
53
  if (!shared.length) return result;
52
54
  const scanner = new KnowledgeRetrievalService({ layout: this.layout, projectStore: { readState: () => ({ index: {} }) } });
53
55
  const extra = scanner.scanCurrent(shared);
54
56
  for (const [id, candidate] of extra.candidates) {
55
57
  const scope = this.store.state.sharedScopes.find(s => s.id === candidate.projectId);
56
- if (candidate.entryId.startsWith('memories/') && (scope.kind !== 'brain' || candidate.entryId !== 'memories/confirmed.md' || !this.memories?.validFile(scope))) continue;
58
+ if (candidate.entryId.startsWith('memories/')) {
59
+ if (!this.memories?.readableEntry(scope, candidate.entryId, registration) || !this.memories.memoryForChunk(scope, candidate.entryId, candidate.headingPath)) continue;
60
+ const memory = this.memories.memoryForChunk(scope, candidate.entryId, candidate.headingPath), type = this.memories.scopeOf(memory).type;
61
+ candidate.scopeWeight = { project: 1, domain: 0.75, global: 0.6 }[type];
62
+ candidate.title = `${{ project: '项目记忆', domain: '领域记忆', global: '全局记忆' }[type]} · ${this.memories.target(memory).name}`;
63
+ } else if (scopes.find(s => s.projectId === candidate.projectId)?.memoryOnly) continue;
57
64
  result.candidates.set(id, candidate);
58
65
  }
59
66
  result.scopeHealth.push(...extra.scopeHealth);
@@ -97,12 +104,19 @@ class EngineRetrieval extends KnowledgeRetrievalService {
97
104
  && (input.scope === 'brain' || p.projectId === registration.projectId || registration.domainId && p.domainId === registration.domainId)).map(p => ({ projectId: p.projectId, spaceId: `project:${p.projectId}`, knowledgePath: p.knowledgePath, weight: p.projectId === registration.projectId ? 1 : 0.88, reason: p.projectId === registration.projectId ? 'primary' : input.scope }));
98
105
  scopes.push(...this.sharedScopes(registration, input.scope));
99
106
  }
107
+ if (!scopes.some(s => s.spaceId === `brain:${registration.brainId}`)) {
108
+ scopes.push(...this.sharedScopes(registration, 'domain').filter(s => s.reason === 'brain').map(s => ({ ...s, memoryOnly: true })));
109
+ }
100
110
  const result = await super.search({ ...input, project, scopes });
101
111
  result.results = result.results.map(row => {
102
112
  const run = this.store.listRuns(row.scope_project_id).find(r => r.promotedAt && r.staging?.operations?.some(op => op.path === row.entry_id));
103
113
  const source = this.store.getProject(row.scope_project_id) || this.store.state.sharedScopes.find(s => s.id === row.scope_project_id);
104
- return { ...row, projectName: source?.name || row.scope_project_id, path: row.entry_id, snippet: row.chunk_text, score: row.relevance_score, projectId: row.scope_project_id, commitSha: run?.commitSha || null };
114
+ const memory = source?.kind === 'brain' && row.entry_id.startsWith('memories/') ? this.memories.memoryForChunk(source, row.entry_id, row.heading_path) : null;
115
+ return { ...row, projectName: source?.name || row.scope_project_id, path: row.entry_id, snippet: row.chunk_text, score: row.relevance_score, projectId: row.scope_project_id, commitSha: memory?.evidence.commitSha || run?.commitSha || null, ...(memory ? { memoryScopeType: this.memories.scopeOf(memory).type, memoryScopeId: this.memories.scopeOf(memory).id, memoryId: memory.id } : {}) };
105
116
  });
117
+ const slots = result.results.map((r, i) => r.memoryId ? i : -1).filter(i => i >= 0);
118
+ const ordered = slots.map(i => result.results[i]).sort((a, b) => ({ project: 0, domain: 1, global: 2 }[a.memoryScopeType] - { project: 0, domain: 1, global: 2 }[b.memoryScopeType]) || b.score - a.score);
119
+ slots.forEach((slot, i) => { result.results[slot] = ordered[i]; });
106
120
  return result;
107
121
  }
108
122
  async ask(input) {
@@ -116,10 +130,11 @@ class EngineRetrieval extends KnowledgeRetrievalService {
116
130
  if (input.spaceId && !scope && input.spaceId !== `project:${registration.projectId}`) throw new DomainError('PROJECT_NOT_FOUND', '知识范围与项目归属不匹配。', { status: 404 });
117
131
  const project = scope ? { projectId: scope.id, knowledgePath: scope.knowledgePath } : registration;
118
132
  const entryId = String(input.entryId || input.entry || '').replace(/\\/g, '/');
133
+ if (isExcludedKnowledgePath(entryId)) throw new DomainError('PATH_OUTSIDE_ROOT', '会话证据与内部数据不能作为正式知识读取。', { status: 403 });
119
134
  const target = path.resolve(project.knowledgePath, entryId);
120
135
  if (!entryId || !/\.md$/i.test(target) || !this.layout.isPathInside(project.knowledgePath, target, { realpath: true })) throw new DomainError('PATH_OUTSIDE_ROOT', '知识文件越界。', { status: 403 });
121
136
  if (!fs.existsSync(target) || !fs.statSync(target).isFile() || fs.lstatSync(target).isSymbolicLink()) throw new DomainError('PROJECT_NOT_FOUND', '知识文件不存在。', { status: 404 });
122
- if (scope && entryId.startsWith('memories/') && (entryId !== 'memories/confirmed.md' || !this.memories?.validFile(scope))) throw new DomainError('PROJECT_NOT_FOUND', '记忆尚未完成确认写入。', { status: 404 });
137
+ if (entryId.startsWith('memories/') && (!scope || !this.memories?.readableEntry(scope, entryId, registration))) throw new DomainError('PROJECT_NOT_FOUND', '记忆尚未同步或不适用于当前项目。', { status: 404 });
123
138
  return { ok: true, projectId: project.projectId, projectSlug: project.projectId, entryId, source: 'markdown', chunks: [{ chunk_text: fs.readFileSync(target, 'utf8'), entry_id: entryId, space_id: scope ? `${scope.kind}:${scope.id}` : `project:${project.projectId}` }] };
124
139
  }
125
140
  async history(input) {
@@ -48,6 +48,7 @@ const ANALYSIS_PHASES = Object.freeze([
48
48
 
49
49
  const ERROR_CODES = Object.freeze([
50
50
  'INVALID_ARGUMENT',
51
+ 'MEMORY_DUPLICATE',
51
52
  'INVALID_TRIGGER',
52
53
  'SCHEMA_UNSUPPORTED',
53
54
  'DATA_CORRUPT',
@@ -312,7 +312,11 @@ class KnowledgePromotionService {
312
312
  if (!claim || claim.projectId !== projectId || !claim.runId || !claim.commitSha) throw new DomainError('INVALID_ARGUMENT', 'Active claim is required for knowledge promotion.');
313
313
  const operationId = input.operationId || claim.operationId || createId('op');
314
314
  const stagingPath = this.prepareStaging(projectId, claim.runId);
315
- const beforeSource = await gitStatus(config.repoPath);
315
+ // The isolated worker cannot read or write the live repository. A user may
316
+ // keep developing while it consumes frozen evidence; live status is neither
317
+ // its input nor proof of worker writes. Keep the guard for other analyzers.
318
+ const verifyLiveSource = this.analyzer?.sourceReadMode !== 'isolated-frozen-evidence';
319
+ const beforeSource = verifyLiveSource ? await gitStatus(config.repoPath) : null;
316
320
  try {
317
321
  if (!this.analyzer || typeof this.analyzer.runClaim !== 'function') throw new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer is unavailable.', { status: 503, retryable: true });
318
322
  const analyzerResult = await this.analyzer.runClaim({
@@ -329,7 +333,7 @@ class KnowledgePromotionService {
329
333
  }),
330
334
  });
331
335
  if (analyzerResult && analyzerResult.ok === false) throw new DomainError('INVALID_ARGUMENT', analyzerResult.error || 'Knowledge analyzer failed.', { status: 500, retryable: true });
332
- const afterSource = await gitStatus(config.repoPath);
336
+ const afterSource = verifyLiveSource ? await gitStatus(config.repoPath) : null;
333
337
  if (beforeSource != null && afterSource != null && beforeSource !== afterSource) {
334
338
  throw new DomainError('PROJECT_BUSY', 'Source repository changed during knowledge analysis.', { status: 409, retryable: true });
335
339
  }