@sanqianx/project-knowledge 5.0.9-rc.1 → 5.0.9-rc.11

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +107 -0
  3. package/README.md +27 -4
  4. package/_modules/knowledge-engine/README.md +6 -1
  5. package/_modules/knowledge-engine/package.json +1 -1
  6. package/_modules/knowledge-engine/src/headless-analyzer.js +3 -1
  7. package/_modules/knowledge-engine/src/knowledge-targets.js +46 -0
  8. package/_modules/knowledge-engine/src/retrieval.js +9 -6
  9. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +7 -2
  10. package/_site/_test/fixtures/make-git-repos.js +1 -9
  11. package/_site/_test/knowledge-engine-client-test.js +105 -0
  12. package/_site/_test/sessions-stream-test.js +3 -2
  13. package/_site/_test/shared-scope-root-binding-test.js +67 -0
  14. package/_site/_test/simple-import-test.js +3 -2
  15. package/_site/_test/team-knowledge-store-test.js +3 -2
  16. package/_site/_test/test-server-cleanup-test.js +31 -0
  17. package/_site/_test/vector-storage-root-test.js +52 -0
  18. package/_site/_test/workspace-ui-contract-test.js +1 -0
  19. package/_site/lib/contracts.js +5 -0
  20. package/_site/lib/engine-knowledge-client.js +72 -0
  21. package/_site/lib/knowledge-tool-runtime.js +7 -0
  22. package/_site/lib/module-bridge.js +44 -11
  23. package/_site/lib/server-app.js +3 -3
  24. package/_site/lib/settings-store.js +20 -0
  25. package/_site/lib/storage-layout.js +4 -0
  26. package/bin/project-knowledge-kb.js +4 -2
  27. package/bin/project-knowledge-mcp.js +7 -1
  28. package/module-runtime-manifest.json +52 -50
  29. package/package.json +3 -3
  30. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  31. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  32. package/plugins/project-knowledge/.mcp.json +1 -1
  33. package/ui/index.html +2 -2
  34. package/ui/product.css +1 -0
  35. package/ui/product.js +12 -1
  36. package/vendor-manifest.json +7 -6
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.9-rc.1",
12
+ "version": "5.0.9-rc.11",
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,112 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.9-rc.11] - 2026-10-11
4
+
5
+ - Pin vector-hub 1.0.8-rc.3 and vendor engine 0.2.3-rc.5. Raw commit
6
+ conversations and internal backups are analysis evidence, not knowledge.
7
+ Exclude them from managed indexes, search and get; retain source evidence.
8
+ - Prune stale derived records during sync and rebuild once on source-policy
9
+ upgrade, without repeating paid whole-index work on ordinary restarts.
10
+ - Keep standalone vector-hub general document support unchanged.
11
+ - Record both original failing regressions and successful Node 22/24 reruns;
12
+ installed, real model and existing-v5 gates still precede latest promotion.
13
+
14
+ ## [5.0.9-rc.10] - 2026-10-11
15
+
16
+ - Preserve acknowledged brain/domain knowledge-directory bindings when changing
17
+ the knowledge root. Only derived indexes and future imports/shared scopes use
18
+ the new root; existing knowledge and confirmed memories stay in place.
19
+ - Persist bindings in shell metadata, reject implicit rebinding, and report
20
+ shared-scope connection failures in UI and safe structured logs.
21
+ - Add real-engine regression for confirmed memory, domain retrieval, restart,
22
+ new domain registration, offline retry and protected bindings on Node 22/24.
23
+ - Candidate acceptance still precedes the existing-v5 upgrade and stable latest.
24
+
25
+ ## [5.0.9-rc.9] - 2026-10-11
26
+
27
+ - Vendor engine 0.2.3-rc.4. Freeze existing target paths and hashes before
28
+ analysis so missing retrieval snippets cannot be mistaken for absent files.
29
+ Preserve full old documents when only partial context is available; illegal
30
+ create operations still fail safely instead of silently overwriting data.
31
+ - Keep the failed rc.8 secondary-development maintenance evidence and verify
32
+ that exact commit after downloading this candidate, not patched source bytes.
33
+ - Render long index-directory paths as a full-width block in System Status.
34
+ - latest remains unchanged until the real closed-loop and existing-v5 gates pass.
35
+
36
+ ## [5.0.9-rc.8] - 2026-10-11
37
+
38
+ - Pin Workbench 0.13.2-rc.4: atomically replace all native Agent histories,
39
+ retaining the previous complete record on disk-full or locked-file errors.
40
+ CLI fallback no longer deletes durable history before a failed rename.
41
+ - Keep the original disk-full loss and separate successful background recovery
42
+ records. Candidate acceptance includes faults against downloaded module bytes.
43
+ - Pin vector-hub 1.0.8-rc.2. Managed indexes now follow the knowledge root
44
+ under `.indexes`; show the acknowledged actual directory in System Status.
45
+ Rebuild on a root/model change while retaining the old index directory;
46
+ serialize queued writes and avoid a full paid rebuild on normal restart.
47
+ - Do not promote latest until installed and existing-v5 acceptance pass.
48
+
49
+ ## [5.0.9-rc.7] - 2026-10-11
50
+
51
+ - Vendor engine 0.2.3-rc.3: recognize promoted commit identifiers inside
52
+ natural-language tasks, including short and multiple SHAs. Raw conversation
53
+ mentions cannot replace the current promoted knowledge references.
54
+ - Preserve failed installed Workbench/context checks from rc.6; latest remains
55
+ unchanged until fresh registry runtime and existing-v5 acceptance pass.
56
+
57
+ ## [5.0.9-rc.6] - 2026-10-11
58
+
59
+ - Vendor engine 0.2.3-rc.2. Unassigned projects no longer share an implicit
60
+ domain in retrieval or Workbench task context. Explicit domains and whole
61
+ brain queries retain their inclusive scopes.
62
+ - Keep the failed rc.5 real Workbench acceptance as a separate record. This
63
+ candidate must pass fresh downloaded-runtime checks before promotion.
64
+
65
+ ## [5.0.9-rc.5] - 2026-10-11
66
+
67
+ - Pin Workbench 0.13.2-rc.3: retain OpenCode final answers across tool rounds,
68
+ isolate CLI history by product data directory and recover only owned v5
69
+ session records without moving or deleting originals.
70
+ - Support current ZCode model-selection and failure events. Shared provider
71
+ credentials stay in memory behind a temporary loopback transport; client
72
+ shutdown cleans up the transport and settles rejected sends correctly.
73
+ - Candidate publication does not promote latest or imply complete acceptance.
74
+ Real client execution, installed UI regression and existing-data upgrade
75
+ remain separately recorded gates.
76
+
77
+ ## [5.0.9-rc.4] - 2026-10-11
78
+
79
+ - Pin Workbench 0.13.2-rc.2: resolve the installed ZCode provider resource
80
+ layout without rewriting desktop or personal configuration, and label
81
+ runtime failures with the correct agent.
82
+ - Disable the project selector until the first registry snapshot. This fixes
83
+ the startup interaction race exposed by the Node 22 packaged UI regression.
84
+ - Latest is unchanged; real installed acceptance and client authentication are
85
+ independent gates, not implied by a successful version probe.
86
+
87
+ ## [5.0.9-rc.3] - 2026-10-11
88
+
89
+ - Route engine-wired CLI and MCP reads through the same authoritative engine
90
+ as the web UI, preserving commit references and project/domain/brain scopes.
91
+ Verify the selected runtime identity and reject unavailable, mismatched or
92
+ malformed services instead of silently querying a different local index.
93
+ - Preserve the MCP `space` alias and expose explicit scope selection. Bound
94
+ responses and never forward profile or credential objects to read routes.
95
+ - Drain isolated regression servers before Windows fixture cleanup. Candidate
96
+ publication still does not promote latest or claim complete acceptance.
97
+
98
+ ## [5.0.9-rc.2] - 2026-10-11
99
+
100
+ - Pin the verified Workbench 0.13.2-rc.1 and vector-hub 1.0.8-rc.1 candidates.
101
+ Normalize known official MiniMax embedding route aliases at runtime without
102
+ changing existing v5 configuration bytes or private gateway paths.
103
+ - Queue same-project index writes across embedding-model rebuilds. Failed writes
104
+ do not poison retries, and unrelated projects remain independent.
105
+ - Restore safe relative Markdown document navigation and verify the standalone
106
+ retrieval UI at 1920x1080. Tests never kill unrelated port owners.
107
+ - Isolate regression-suite ports and tolerate Windows fixture file release.
108
+ Candidate publication is not full product acceptance; latest remains unchanged.
109
+
3
110
  ## [5.0.9-rc.1] - 2026-10-11
4
111
 
5
112
  - Retain classified, bounded and redacted index errors in run details, activity
package/README.md CHANGED
@@ -127,6 +127,13 @@ visible as dirty state and is retried.
127
127
  vector-hub owns the derived index; the shell no longer runs a second index
128
128
  writer. Failed indexing leaves promoted Markdown intact and repairable.
129
129
 
130
+ Managed vectors follow the configured knowledge root at `<knowledge>/.indexes`.
131
+ The system drawer displays the actual directory and pending wiring separately.
132
+ Root changes leave project Markdown and old indexes untouched, drain existing
133
+ index writes, switch subsequent writes, and rebuild registered knowledge with
134
+ the configured embedding model. Credentials and small runtime metadata remain
135
+ in the product data directory; no expensive historical Agent analysis is started.
136
+
130
137
  The application does not create, edit, refresh, or delete `CLAUDE.md`.
131
138
 
132
139
  ## Requirement capture
@@ -160,6 +167,15 @@ Queries never create or rewrite configuration. If the internal index is
160
167
  unavailable, search falls back to current Markdown. Project, domain and brain
161
168
  scopes are nested; unconfirmed memory candidates are not task preferences.
162
169
 
170
+ For engine-wired projects, the CLI and MCP delegate `search`, `ask`, `get` and
171
+ `history` to the same engine used by the web UI, including commit provenance.
172
+ They verify the running instance against the selected data directory; an
173
+ unavailable or mismatched instance is an explicit failure, not a second local
174
+ index with different facts. Only projects without engine wiring retain the
175
+ local legacy read contract. Use `--scope domain` or `--scope brain` to broaden
176
+ CLI searches; MCP accepts the equivalent `scope`. Shared-file reads accept
177
+ `--space <spaceId>` (MCP: `space` or `spaceId`).
178
+
163
179
  ## Storage
164
180
 
165
181
  Version 5 is a fresh start, not a data upgrade. Stop the previous process,
@@ -178,13 +194,18 @@ project-knowledge
178
194
  ```
179
195
 
180
196
  The user selects a global knowledge root before importing projects. That root
181
- 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
182
199
  absolute, fixed `knowledgePath`; changing the global root does not move existing
183
200
  projects.
184
201
 
185
- The selected knowledge root contains only authoritative project Markdown.
186
- Settings, metadata, indexes, caches, runtime state, logs, and recovery assets
187
- 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:
188
209
 
189
210
  ```text
190
211
  ~/.project-knowledge-v5/
@@ -204,6 +225,8 @@ remain in the internal data directory:
204
225
  └── recovery/ # migrations, promotion/index backups
205
226
 
206
227
  <selected knowledge root>/
228
+ ├── .indexes/ # rebuildable vector payloads
229
+ ├── _shared/<brain or domain ID>/ # fixed shared-knowledge bindings
207
230
  └── <project storage name>/
208
231
  ├── README.md # when produced by verified knowledge
209
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
+ 每次真实分析在只读证据区冻结已有知识的目标路径与哈希清单,覆盖未被检索选中的文件;重试复用该清单。Agent 不得把“未检索到”当作“文件不存在”,也不得用部分片段覆盖完整旧文档;无法保留原文时只追加当前提交的变更记录。已有目标仍由晋升校验拒绝非法 create,不自动改为覆盖。清单仅保存元数据,不把整个知识树复制到每次运行中。
45
+
42
46
  ## API
43
47
 
44
48
  | 方法 | 路径 | 说明 |
@@ -76,7 +80,8 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
76
80
  - 数据目录有单实例租约,目标知识目录有写入锁。关闭或超时等待子进程实际退出,再释放写入锁。
77
81
  - 晋升与索引分别记录。索引失败保留已晋升知识并显示待修复,不重新调用模型。
78
82
  - 索引错误保留兼容的 `INDEX_FAILED` 及可选 `error.details`(类别、HTTP 状态、上游代码/脱敏原因、请求标识、耗时和尝试次数);项目和共享索引均写入 `log/v2`。响应仅解析有界 JSON,不记录原始 body 或认证头。只重试索引保留原分析运行、晋升时间和知识字节。
79
- - 项目、领域、智脑检索使用包含范围。向量只提供排序,显示内容必须来自当前真实文件;索引不可用时降级为文件检索。
83
+ - 项目、领域、智脑检索使用包含范围。未分配领域不是共享领域:领域范围只包含当前项目与智脑共享知识;显式加入同一领域后才包含其他项目。整个智脑范围仍包含全部同智脑项目。向量只提供排序,显示内容必须来自当前真实文件;索引不可用时降级为文件检索。
84
+ - 已晋升提交的 SHA 或短 SHA 在自然语言查询中仍作为标识符匹配,只返回当前文件的晋升依据;仅在原始会话中提到相同 SHA 不构成知识晋升引用。多个提交标识符可以一起查询。
80
85
  - 候选必须有已晋升文件中的引用依据,未确认候选不能进入任务。确认记录原子持久化后写共享记忆文件,派生索引异步更新,索引失败不会撤销有效记忆。
81
86
  - 旧格式不读取、不自动迁移。损坏的正式状态不覆盖、不清空。
82
87
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/ai-coding-knowledge-engine",
3
- "version": "0.2.3-rc.1",
3
+ "version": "0.2.3-rc.5",
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",
@@ -3,6 +3,7 @@ const { fork, execFile } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const { frameFrozenEvidence } = require('./analysis-contract');
5
5
  const { finalizeAnalysisOutput } = require('./analysis-output');
6
+ const { freezeKnowledgeTargets } = require('./knowledge-targets');
6
7
 
7
8
  class HeadlessAnalyzer {
8
9
  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; }
@@ -13,6 +14,7 @@ class HeadlessAnalyzer {
13
14
  const refs = [`commit:${input.claim.commitSha}`, `patch:${input.claim.patchHash}`, `conversation:${input.claim.conversationSnapshotHash}`, `retrieval:${input.claim.retrievalManifestHash}`];
14
15
  const contract = `\n\nOUTPUT CONTRACT\nRead frozen evidence only beneath ${input.evidenceRoot}. Write only beneath ${input.stagingPath}. Put Markdown under files/<allowed-path> (README.md, GOAL.md, ARCHITECTURE.md, modules/*.md, changes/*.md). Write ${input.manifestPath} with schema knowledge-staging-manifest/v1, projectId ${input.projectId}, runId ${input.claim.runId}, commitSha ${input.claim.commitSha}, and non-empty operations. Each operation needs path, operation (create/replace/delete), reason, and evidenceReferences ${JSON.stringify(refs)}. Omit sha256: the engine seals staged bytes itself. Do not modify code, final knowledge, other runs, or indexes. Do not invent intent if conversations are unavailable.`;
15
16
  const pathContract = `\nPATH CONTRACT (mandatory): operations[].path is the FINAL knowledge-relative POSIX path, NEVER a staging path and NEVER prefixed with "files/". Example: write the physical file ${path.join(input.stagingPath, 'files', 'changes', `${input.claim.commitSha.slice(0, 12)}.md`)} but put "path": "changes/${input.claim.commitSha.slice(0, 12)}.md" in manifest.json. Use forward slashes even on Windows. Minimal manifest shape: ${JSON.stringify({ schema: 'knowledge-staging-manifest/v1', projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha, operations: [{ path: `changes/${input.claim.commitSha.slice(0, 12)}.md`, operation: 'create', reason: 'Describe the evidence-backed change', evidenceReferences: refs }] })}`;
17
+ const targetContract = freezeKnowledgeTargets(input).contract;
16
18
  await new Promise((resolve, reject) => {
17
19
  const env = { ...process.env };
18
20
  delete env.CLAUDECODE;
@@ -50,7 +52,7 @@ class HeadlessAnalyzer {
50
52
  });
51
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.';
52
54
  const languageContract = input.config?.knowledgeLanguage === 'en-US' ? '\nOUTPUT LANGUAGE: English. Preserve identifiers and verbatim evidence quotations.' : '\n输出语言:简体中文。知识正文、operations[].reason 与候选记忆用简体中文;代码标识、路径、哈希和原文证据引用保持不变。';
53
- child.send({ profile, projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha, prompt: `${frameFrozenEvidence(input.prompt)}${contract}${pathContract}${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 })); });
55
+ 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 })); });
54
56
  });
55
57
  finalizeAnalysisOutput(input);
56
58
  return { ok: true };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+ const AtomicFile = require('./runtime/atomic-file');
6
+ const { listMarkdownFiles } = require('./runtime/markdown-knowledge-indexer');
7
+ const { canonicalRelativePath } = require('./runtime/knowledge-promotion');
8
+ const hash = value => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
9
+
10
+ function freezeKnowledgeTargets(input) {
11
+ const file = path.join(input.evidenceRoot, 'knowledge-targets.json');
12
+ const identity = { schema: 'knowledge-target-inventory/v1', projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha };
13
+ let inventory;
14
+ if (fs.existsSync(file)) {
15
+ inventory = AtomicFile.readJsonStrict(file);
16
+ const { manifestHash, ...payload } = inventory;
17
+ try {
18
+ if (Object.keys(identity).some(key => inventory[key] !== identity[key]) || !Array.isArray(inventory.entries) || manifestHash !== hash(JSON.stringify(payload))) throw new Error();
19
+ for (const entry of inventory.entries) {
20
+ if (canonicalRelativePath(entry.path) !== entry.path || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw new Error();
21
+ }
22
+ } catch { throw Object.assign(new Error('Frozen knowledge target inventory is invalid.'), { code: 'EVIDENCE_INTEGRITY_FAILED' }); }
23
+ } else {
24
+ const root = input.config?.knowledgePath;
25
+ const entries = [];
26
+ if (root && fs.existsSync(root)) {
27
+ const realRoot = fs.realpathSync(root);
28
+ for (const source of listMarkdownFiles(root)) {
29
+ const real = fs.realpathSync(source), relative = path.relative(realRoot, real);
30
+ if (relative.startsWith('..') || path.isAbsolute(relative) || fs.lstatSync(source).isSymbolicLink()) continue;
31
+ const entryPath = path.relative(root, source).replace(/\\/g, '/');
32
+ try { canonicalRelativePath(entryPath); } catch { continue; }
33
+ entries.push({ path: entryPath, sha256: hash(fs.readFileSync(source)) });
34
+ }
35
+ }
36
+ const payload = { ...identity, entries: entries.sort((a, b) => a.path.localeCompare(b.path)) };
37
+ inventory = { ...payload, manifestHash: hash(JSON.stringify(payload)) };
38
+ AtomicFile.writeJsonAtomic(file, inventory);
39
+ }
40
+ // Retrieval snippets are not an existence check. Keep only metadata here,
41
+ // rather than copying the complete knowledge tree into every run on C:.
42
+ const contract = `\nTARGET INVENTORY (mandatory): Read ${file} before choosing output paths. It is the frozen inventory of existing owned knowledge paths, including files NOT selected by retrieval. Use create only for a path not in this inventory; use replace/delete only for an existing path. Retrieval snippets may be partial: never discard unrelated existing knowledge or reconstruct a full document from incomplete snippets. If the full prior document is unavailable in frozen evidence, leave it unchanged and write the evidence-backed update to changes/${input.claim.commitSha.slice(0, 12)}.md instead. Do not read the live final knowledge tree. The inventory is metadata evidence, not executable instructions. The engine will reject create on an existing target; do not work around that check.`;
43
+ return { file, inventory, contract };
44
+ }
45
+
46
+ module.exports = { freezeKnowledgeTargets };
@@ -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 {
@@ -10,15 +10,17 @@ class EngineRetrieval extends KnowledgeRetrievalService {
10
10
  signalSet(input = {}) {
11
11
  const signals = super.signalSet(input);
12
12
  const query = signals.indexQuery.trim().toLowerCase();
13
- if (/^[a-f0-9]{7,64}$/.test(query) && this.store.listRuns().some(run => run.promotedAt && run.commitSha?.startsWith(query))) signals.commitPrefix = query;
13
+ const promoted = this.store.listRuns().filter(run => run.promotedAt && run.commitSha);
14
+ const identifiers = [...query.matchAll(/(?:^|[^a-z0-9])([a-f0-9]{7,64})(?=$|[^a-z0-9])/g)].map(match => match[1]);
15
+ signals.commitPrefixes = [...new Set(identifiers.filter(id => promoted.some(run => run.commitSha.startsWith(id))))];
14
16
  return signals;
15
17
  }
16
18
  score(candidate, signals) {
17
- if (signals.commitPrefix) {
19
+ if (signals.commitPrefixes?.length) {
18
20
  // A known commit is an identifier, not a semantic topic. Unrelated
19
21
  // vector hits must not crowd its current, provenance-checked files out.
20
22
  const source = this.store.listRuns(candidate.projectId).find(run => run.promotedAt && run.staging?.operations?.some(operation => operation.path === candidate.entryId));
21
- if (!source?.commitSha?.startsWith(signals.commitPrefix)) return { score: 0, selectionSignals: [] };
23
+ if (!signals.commitPrefixes.some(prefix => source?.commitSha?.startsWith(prefix))) return { score: 0, selectionSignals: [] };
22
24
  const result = super.score(candidate, signals);
23
25
  return { score: Math.max(1, result.score), selectionSignals: [...result.selectionSignals, 'exact-commit'] };
24
26
  }
@@ -40,7 +42,7 @@ class EngineRetrieval extends KnowledgeRetrievalService {
40
42
  return [...super.scopes(project), ...this.sharedScopes(registration, 'domain')];
41
43
  }
42
44
  sharedScopes(registration, range) {
43
- return this.store.state.sharedScopes.filter(s => s.brainId === registration.brainId && (s.kind === 'brain' || range === 'brain' || 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 }));
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 }));
44
46
  }
45
47
  scanCurrent(scopes) {
46
48
  const ordinary = scopes.filter(s => this.store.getProject(s.projectId));
@@ -92,7 +94,7 @@ class EngineRetrieval extends KnowledgeRetrievalService {
92
94
  let scopes = [{ projectId: project.projectId, spaceId: project.primarySpaceId, knowledgePath: project.knowledgePath, weight: 1, reason: 'primary' }];
93
95
  if (input.scope === 'domain' || input.scope === 'brain') {
94
96
  scopes = this.store.listProjects().filter(p => p.enabled !== false && p.brainId === registration.brainId
95
- && (input.scope === 'brain' || 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 }));
97
+ && (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 }));
96
98
  scopes.push(...this.sharedScopes(registration, input.scope));
97
99
  }
98
100
  const result = await super.search({ ...input, project, scopes });
@@ -114,6 +116,7 @@ class EngineRetrieval extends KnowledgeRetrievalService {
114
116
  if (input.spaceId && !scope && input.spaceId !== `project:${registration.projectId}`) throw new DomainError('PROJECT_NOT_FOUND', '知识范围与项目归属不匹配。', { status: 404 });
115
117
  const project = scope ? { projectId: scope.id, knowledgePath: scope.knowledgePath } : registration;
116
118
  const entryId = String(input.entryId || input.entry || '').replace(/\\/g, '/');
119
+ if (isExcludedKnowledgePath(entryId)) throw new DomainError('PATH_OUTSIDE_ROOT', '会话证据与内部数据不能作为正式知识读取。', { status: 403 });
117
120
  const target = path.resolve(project.knowledgePath, entryId);
118
121
  if (!entryId || !/\.md$/i.test(target) || !this.layout.isPathInside(project.knowledgePath, target, { realpath: true })) throw new DomainError('PATH_OUTSIDE_ROOT', '知识文件越界。', { status: 403 });
119
122
  if (!fs.existsSync(target) || !fs.statSync(target).isFile() || fs.lstatSync(target).isSymbolicLink()) throw new DomainError('PROJECT_NOT_FOUND', '知识文件不存在。', { status: 404 });
@@ -5,13 +5,17 @@ const { sha256 } = require('./content-hash');
5
5
  const CHUNKER_VERSION = 1;
6
6
  const DEFAULT_MAX_CHARS = 1200;
7
7
  const DEFAULT_OVERLAP_CHARS = 120;
8
- const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules']);
8
+ const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules', 'dev-conversations']);
9
9
  const DERIVED_INDEX_FILENAME = '00-index.md';
10
10
 
11
11
  function normalizeRel(value) {
12
12
  return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
13
13
  }
14
14
 
15
+ function isExcludedKnowledgePath(value) {
16
+ return normalizeRel(value).split('/').some(segment => segment.startsWith('.') || EXCLUDED_DIRS.has(segment.toLowerCase()));
17
+ }
18
+
15
19
  function stripFrontmatter(markdown) {
16
20
  return String(markdown || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
17
21
  }
@@ -112,7 +116,7 @@ function listMarkdownFiles(root, options = {}) {
112
116
  const files = [];
113
117
  const walk = (dir) => {
114
118
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
115
- if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
119
+ if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name.toLowerCase())) continue;
116
120
  const abs = path.join(dir, entry.name);
117
121
  if (entry.isDirectory()) walk(abs);
118
122
  else if (entry.isFile() && /\.md$/i.test(entry.name) && (includeDerived || !isDerivedIndex(entry.name))) files.push(abs);
@@ -224,6 +228,7 @@ module.exports = {
224
228
  CHUNKER_VERSION,
225
229
  chunkMarkdown,
226
230
  listMarkdownFiles,
231
+ isExcludedKnowledgePath,
227
232
  isDerivedIndex,
228
233
  inferEntryType,
229
234
  parseKnowledgeMetadata,
@@ -49,15 +49,7 @@ function makeRepo({ kind = 'one-commit' } = {}) {
49
49
  const commits = [];
50
50
  const cleanup = () => {
51
51
  if (path.dirname(repoPath) !== path.resolve(os.tmpdir()) || !path.basename(repoPath).startsWith(`kb-fixture-${kind}-${process.pid}-`)) throw new Error('Unsafe fixture cleanup');
52
- function remove(directory) {
53
- for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
54
- const file = path.join(directory, entry.name);
55
- if (entry.isDirectory() && !entry.isSymbolicLink()) remove(file);
56
- else { try { fs.unlinkSync(file); } catch (error) { if (error.code !== 'EPERM') throw error; fs.chmodSync(file, 0o600); fs.unlinkSync(file); } }
57
- }
58
- fs.rmdirSync(directory);
59
- }
60
- if (fs.existsSync(repoPath)) remove(repoPath);
52
+ if (fs.existsSync(repoPath)) fs.rmSync(repoPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
61
53
  };
62
54
 
63
55
  if (kind === 'not-git') {
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const http = require('node:http');
7
+ const { spawn } = require('node:child_process');
8
+ const { StorageLayout } = require('../lib/storage-layout');
9
+ const { SettingsStore } = require('../lib/settings-store');
10
+ const { ProjectRegistryStore } = require('../lib/project-registry-store');
11
+ const { ProjectStore } = require('../lib/project-store');
12
+ const { KnowledgeToolRuntime } = require('../lib/knowledge-tool-runtime');
13
+ const endpoint = require('../lib/runtime-endpoint');
14
+ const control = require('../lib/runtime-control');
15
+ const { EngineKnowledgeClient } = require('../lib/engine-knowledge-client');
16
+
17
+ (async () => {
18
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'knowledge-engine-client-'));
19
+ const layout = new StorageLayout({ dataDir: path.join(root, 'data') });
20
+ const settings = new SettingsStore({ layout }), registry = new ProjectRegistryStore({ layout }), projects = new ProjectStore({ layout });
21
+ const projectId = 'project-test', repoPath = path.join(root, 'repo'), knowledgePath = path.join(root, 'knowledge');
22
+ fs.mkdirSync(repoPath); fs.mkdirSync(knowledgePath);
23
+ fs.writeFileSync(path.join(knowledgePath, 'README.md'), '# Legacy\n\nlegacy-local-only\n');
24
+ await settings.initialize(); await registry.initialize();
25
+ await projects.create(projectId, { repoPath, knowledgePath }, { modules: { engine: { status: 'registered', projectId } } });
26
+ await projects.updateState(projectId, state => { state.modules = { engine: { status: 'registered', projectId } }; });
27
+ await registry.add(projectId, { displayNameSnapshot: 'Test' });
28
+ let invalidIdentity = false, malformed = false, businessError = false, oversized = false, wrongBinding = false;
29
+ const calls = [];
30
+ const reference = { scope_project_id: projectId, entry_id: 'changes/aaaaaaaaaaaa.md', commitSha: 'a'.repeat(40), chunk_text: 'Engine authoritative knowledge' };
31
+ const payload = { ok: true, projectId, backend: 'hybrid+markdown-truth', results: [reference], answer: reference.chunk_text, citations: [{ ...reference, entryId: reference.entry_id }], chunks: [{ chunk_text: reference.chunk_text }] };
32
+ const server = http.createServer(async (req, res) => {
33
+ res.setHeader('Content-Type', 'application/json');
34
+ if (req.url === '/api/runtime/identity') return res.end(JSON.stringify(control.identity(invalidIdentity ? root : layout.getDataDir(), { port: server.address().port, host: '127.0.0.1', startedAt: 'test-start' })));
35
+ if (req.url === '/api/health') return res.end(JSON.stringify({ name: 'knowledge-engine', dataSchemaVersion: 3 }));
36
+ if (req.url === '/api/projects') return res.end(JSON.stringify({ projects: [{ projectId, repoPath, knowledgePath: wrongBinding ? root : knowledgePath }] }));
37
+ let text = ''; for await (const bytes of req) text += bytes;
38
+ calls.push({ route: req.url, input: JSON.parse(text || '{}') });
39
+ if (oversized) return res.end(JSON.stringify({ ok: true, text: 'x'.repeat(2 * 1024 * 1024) }));
40
+ if (businessError) { res.statusCode = 403; return res.end(JSON.stringify({ ok: false, error: { code: 'PATH_OUTSIDE_ROOT', message: 'private-test-secret' } })); }
41
+ res.end(malformed ? '[]' : JSON.stringify(payload));
42
+ });
43
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
44
+ endpoint.writeEndpoint(layout.getDataDir(), { pid: process.pid, port: server.address().port, host: '127.0.0.1', startedAt: 'test-start' });
45
+ const runtime = new KnowledgeToolRuntime({ layout, settingsStore: settings, registryStore: registry, projectStore: projects });
46
+ try {
47
+ for (const action of ['search', 'ask', 'get', 'history']) {
48
+ assert.deepEqual(await runtime[action]({ project: projectId, query: 'knowledge', entry: reference.entry_id, scope: 'brain', apiKey: 'must-not-forward', password: 'must-not-forward' }), payload);
49
+ const call = calls.at(-1);
50
+ assert.equal(call.route, `/api/engine/${action}`);
51
+ assert.equal(call.input.projectId, projectId); assert.equal(call.input.scope, 'brain');
52
+ assert.ok(!JSON.stringify(call.input).includes('must-not-forward'));
53
+ }
54
+ const subprocess = (file, args) => new Promise((resolve, reject) => {
55
+ const child = spawn(process.execPath, [file, ...args], { env: { ...process.env, KB_DATA_DIR: layout.getDataDir() }, windowsHide: true });
56
+ let out = '', err = ''; child.stdout.on('data', b => { out += b; }); child.stderr.on('data', b => { err += b; });
57
+ child.once('error', reject); child.once('close', code => resolve({ code, out, err }));
58
+ });
59
+ const cli = await subprocess(path.resolve(__dirname, '../../bin/project-knowledge-kb.js'), ['search', '--project', projectId, '--query', 'knowledge', '--scope', 'brain', '--json']);
60
+ assert.equal(cli.code, 0, cli.err); assert.deepEqual(JSON.parse(cli.out), payload);
61
+ assert.equal(calls.at(-1).input.scope, 'brain');
62
+ await runtime.get({ projectId, entry: reference.entry_id, space: 'brain:test' });
63
+ assert.equal(calls.at(-1).input.spaceId, 'brain:test', 'legacy space alias is forwarded');
64
+ const { createRequire } = require('node:module');
65
+ const sdk = createRequire(path.resolve(__dirname, '../../package.json'));
66
+ const { Client } = sdk('@modelcontextprotocol/sdk/client/index.js');
67
+ const { StdioClientTransport } = sdk('@modelcontextprotocol/sdk/client/stdio.js');
68
+ const transport = new StdioClientTransport({ command: process.execPath, args: [path.resolve(__dirname, '../../bin/project-knowledge-mcp.js')], env: { ...process.env, KB_DATA_DIR: layout.getDataDir() }, stderr: 'pipe' });
69
+ const client = new Client({ name: 'engine-regression', version: '1.0.0' });
70
+ try {
71
+ await client.connect(transport);
72
+ const tools = await client.listTools();
73
+ assert.deepEqual(tools.tools.find(t => t.name === 'project_knowledge_search').inputSchema.properties.scope.enum, ['project', 'domain', 'brain']);
74
+ for (const action of ['search', 'ask', 'get', 'history']) {
75
+ const result = await client.callTool({ name: `project_knowledge_${action}`, arguments: { projectId, scope: 'brain', ...(action === 'get' ? { entry: reference.entry_id, space: 'brain:test' } : action === 'history' ? {} : { query: 'knowledge' }) } });
76
+ assert.ok(!result.isError); assert.deepEqual(JSON.parse(result.content[0].text), payload);
77
+ assert.equal(calls.at(-1).input.scope, 'brain');
78
+ }
79
+ } finally { await client.close(); }
80
+ invalidIdentity = true;
81
+ await assert.rejects(runtime.search({ projectId, query: 'knowledge' }), e => e.code === 'ENGINE_IDENTITY_MISMATCH');
82
+ invalidIdentity = false; malformed = true;
83
+ await assert.rejects(runtime.search({ projectId, query: 'knowledge' }), e => e.code === 'ENGINE_INVALID_RESPONSE');
84
+ malformed = false; businessError = true;
85
+ await assert.rejects(runtime.get({ projectId, entry: '../outside.md' }), e => e.code === 'PATH_OUTSIDE_ROOT' && !e.message.includes('private-test-secret'));
86
+ businessError = false; oversized = true;
87
+ await assert.rejects(runtime.search({ projectId, query: 'knowledge' }), e => e.code === 'ENGINE_INVALID_RESPONSE');
88
+ oversized = false;
89
+ const direct = new EngineKnowledgeClient({ layout, engineUrl: `http://127.0.0.1:${server.address().port}` });
90
+ assert.deepEqual(await direct.request('search', {}, runtime.resolveProject({ projectId })), payload);
91
+ assert.equal(calls.at(-1).route, '/api/search');
92
+ wrongBinding = true;
93
+ await assert.rejects(direct.request('search', {}, runtime.resolveProject({ projectId })), e => e.code === 'ENGINE_IDENTITY_MISMATCH');
94
+ wrongBinding = false;
95
+ for (const engineUrl of ['http://example.com', 'http://secret@127.0.0.1', 'http://127.0.0.1/?apiKey=secret', 'file:///private']) {
96
+ const unsafe = new EngineKnowledgeClient({ layout, engineUrl });
97
+ await assert.rejects(unsafe.request('search', {}, runtime.resolveProject({ projectId })), e => e.code === 'ENGINE_URL_INVALID' && !e.message.includes('secret'));
98
+ }
99
+ await assert.rejects(direct.request('../runtime-config', {}, {}), e => e.code === 'INVALID_ARGUMENT');
100
+ await assert.rejects(runtime.search({ projectId, scope: 'another-brain' }), e => e.code === 'INVALID_ARGUMENT');
101
+ const unavailable = new EngineKnowledgeClient({ layout, fetchImpl: async () => { throw new Error('private-test-secret'); } });
102
+ await assert.rejects(unavailable.request('search', {}, runtime.resolveProject({ projectId })), e => e.code === 'ENGINE_UNAVAILABLE' && !e.message.includes('private-test-secret'));
103
+ console.log('knowledge-engine-client-test PASS (native runtime, CLI and MCP share engine facts)');
104
+ } finally { await runtime.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); fs.rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }
105
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -5,10 +5,10 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawnSync } = require('child_process');
7
7
  const { spawnServer } = require('./helpers/spawn-server');
8
+ const freePort = require('./helpers/free-port');
8
9
 
9
10
  const ROOT = path.resolve(__dirname, '..', '..');
10
- const PORT = 7804;
11
- const BASE = `http://127.0.0.1:${PORT}`;
11
+ let PORT, BASE;
12
12
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-sse-v2-'));
13
13
  const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-sse-repo-'));
14
14
 
@@ -60,6 +60,7 @@ async function waitFor(predicate) {
60
60
  }
61
61
 
62
62
  (async () => {
63
+ PORT = await freePort(); BASE = `http://127.0.0.1:${PORT}`;
63
64
  git(['init', '--initial-branch=main']);
64
65
  git(['config', 'user.email', 'sse@example.test']);
65
66
  git(['config', 'user.name', 'SSE Test']);
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { createEngineServer } = require('../../_modules/knowledge-engine/src/server');
7
+ const { SettingsStore } = require('../lib/settings-store');
8
+ const { ModuleBridge } = require('../lib/module-bridge');
9
+
10
+ (async () => {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-shared-bindings-'));
12
+ const oldRoot = path.join(root, '原知识根目录'), newRoot = path.join(root, '新知识根目录');
13
+ const settings = new SettingsStore({ dataDir: path.join(root, 'shell') });
14
+ await settings.initialize({ knowledge: { rootPath: oldRoot } });
15
+ await settings.addDomain({ id: 'domain-tools', name: '开发工具' });
16
+ let engine;
17
+ async function start() {
18
+ engine = createEngineServer({ dataDir: path.join(root, 'engine'), consume: false, runtimeToken: 'scope-binding-test' });
19
+ await engine.ready;
20
+ await new Promise(resolve => engine.server.listen(0, '127.0.0.1', resolve));
21
+ return `http://127.0.0.1:${engine.server.address().port}`;
22
+ }
23
+ const logs = [];
24
+ const bridge = new ModuleBridge({ settingsStore: settings, runtimeToken: 'scope-binding-test', logger: { warn: (...args) => logs.push(args) } });
25
+ const upstream = bridge._fetch.bind(bridge);
26
+ bridge._fetch = (url, init) => url.startsWith(bridge.engineUrl) ? upstream(url, init) : Promise.resolve({ ok: false });
27
+ try {
28
+ bridge.engineUrl = await start();
29
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true);
30
+ const scopes = structuredClone(engine.store.state.sharedScopes);
31
+ const brain = scopes.find(scope => scope.kind === 'brain');
32
+ const domain = scopes.find(scope => scope.kind === 'domain');
33
+ engine.store.addProject({ projectId: 'project-test', repoPath: path.join(root, 'code'), knowledgePath: path.join(root, 'project-knowledge'), domainId: domain.id });
34
+ engine.store.state.memories.push({ id: 'memory-test', brainId: brain.id, status: 'candidate', text: '保持目录绑定稳定', evidence: { projectId: 'project-test', commitSha: 'a'.repeat(40), entryId: 'README.md' } });
35
+ engine.store.save();
36
+ await engine.coordinator.memories.update('memory-test', { status: 'confirmed' });
37
+ fs.writeFileSync(path.join(domain.knowledgePath, 'README.md'), '# 领域经验\n\n领域知识不能随根目录设置隐式移动。\n');
38
+ const memoryFile = engine.coordinator.memories.file(brain);
39
+ const before = fs.readFileSync(memoryFile);
40
+ await settings.updatePatch({ knowledge: { rootPath: newRoot } });
41
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true, 'changing index root must not invalidate shared memory wiring');
42
+ assert.deepEqual(engine.store.state.sharedScopes.map(scope => scope.knowledgePath), scopes.map(scope => scope.knowledgePath));
43
+ assert.deepEqual(fs.readFileSync(memoryFile), before);
44
+ assert.equal(settings.read().brain.knowledgePath, brain.knowledgePath);
45
+ assert.equal(settings.read().brain.domains[0].knowledgePath, domain.knowledgePath);
46
+ await assert.rejects(settings.updatePatch({ brain: { knowledgePath: newRoot } }), { code: 'INVALID_ARGUMENT' });
47
+ await assert.rejects(settings.bindSharedKnowledgePaths([{ ...brain, knowledgePath: newRoot }]), { code: 'IMMUTABLE_FIELD' });
48
+ await settings.addDomain({ id: 'domain-new', name: '新领域' });
49
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true);
50
+ assert.equal(engine.store.state.sharedScopes.find(scope => scope.id === 'domain-new').knowledgePath, path.join(newRoot, '_shared', 'domain-new'));
51
+ await engine.close();
52
+ bridge.engineUrl = await start();
53
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true);
54
+ assert.deepEqual(fs.readFileSync(memoryFile), before);
55
+ const memory = await engine.coordinator.retrieval.get({ projectId: 'project-test', spaceId: 'brain:brain-default', entryId: 'memories/confirmed.md' });
56
+ assert.ok(memory.chunks.some(chunk => chunk.chunk_text.includes('保持目录绑定稳定')));
57
+ const shared = await engine.coordinator.retrieval.get({ projectId: 'project-test', spaceId: 'domain:domain-tools', entryId: 'README.md' });
58
+ assert.ok(shared.chunks.some(chunk => chunk.chunk_text.includes('不能随根目录设置隐式移动')));
59
+ await engine.close(); engine = null;
60
+ const pending = await bridge.syncEngineConfiguration();
61
+ assert.equal(pending.scopes, false);
62
+ assert.equal(pending.scopeStatus.status, 'pending');
63
+ assert.ok(logs.some(entry => entry[0] === 'modules.scopes.pending'));
64
+ assert.equal(settings.read().brain.knowledgePath, brain.knowledgePath);
65
+ console.log('shared-scope-root-binding-test PASS');
66
+ } finally { if (engine) await engine.close(); }
67
+ })().catch(error => { console.error(error); process.exitCode = 1; });