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

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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +96 -0
  3. package/README.md +27 -4
  4. package/_modules/knowledge-engine/README.md +4 -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 +7 -5
  9. package/_site/_test/fixtures/make-git-repos.js +1 -9
  10. package/_site/_test/knowledge-engine-client-test.js +105 -0
  11. package/_site/_test/sessions-stream-test.js +3 -2
  12. package/_site/_test/shared-scope-root-binding-test.js +67 -0
  13. package/_site/_test/simple-import-test.js +3 -2
  14. package/_site/_test/team-knowledge-store-test.js +3 -2
  15. package/_site/_test/test-server-cleanup-test.js +31 -0
  16. package/_site/_test/vector-storage-root-test.js +52 -0
  17. package/_site/_test/workspace-ui-contract-test.js +1 -0
  18. package/_site/lib/contracts.js +5 -0
  19. package/_site/lib/engine-knowledge-client.js +72 -0
  20. package/_site/lib/knowledge-tool-runtime.js +7 -0
  21. package/_site/lib/module-bridge.js +44 -11
  22. package/_site/lib/server-app.js +3 -3
  23. package/_site/lib/settings-store.js +20 -0
  24. package/_site/lib/storage-layout.js +4 -0
  25. package/bin/project-knowledge-kb.js +4 -2
  26. package/bin/project-knowledge-mcp.js +7 -1
  27. package/module-runtime-manifest.json +40 -38
  28. package/package.json +3 -3
  29. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  30. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  31. package/plugins/project-knowledge/.mcp.json +1 -1
  32. package/ui/index.html +2 -2
  33. package/ui/product.css +1 -0
  34. package/ui/product.js +12 -1
  35. package/vendor-manifest.json +6 -5
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const path = require('node:path');
4
+ const { StorageLayout } = require('../lib/storage-layout');
5
+ const { ModuleBridge } = require('../lib/module-bridge');
6
+ const root = path.resolve('D:/knowledge storage 中文');
7
+ const layout = new StorageLayout({ dataDir: path.resolve('D:/metadata') });
8
+ assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: root } }), path.join(root, '.indexes'));
9
+ assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: '' } }), path.join(layout.getDataDir(), 'vectorhub'));
10
+ const settingsStore = { read: () => ({ knowledge: { rootPath: root }, brain: { id: 'brain-default', domains: [] } }) };
11
+ const bridge = new ModuleBridge({ settingsStore, dataDir: layout.getDataDir() });
12
+ const spec = bridge._supervisedSpecs().find(spec => spec.name === 'vector-hub');
13
+ assert.equal(spec.env.VECTOR_HUB_ROOT, path.join(root, '.indexes'));
14
+ assert.equal(spec.env.VECTOR_HUB_CONFIG, path.join(layout.getDataDir(), 'vectorhub', 'config.json'));
15
+ const calls = [];
16
+ bridge._fetch = async (url, init) => { const body = JSON.parse(init.body); calls.push({ url, body }); return { ok: true, json: async () => url.includes('/api/internal/runtime-config') ? { ok: true, rootPath: path.join(root, '.indexes') } : {} }; };
17
+ bridge.syncEngineConfiguration().then(async status => {
18
+ assert.equal(calls.find(call => call.url.includes('/api/internal/runtime-config')).body.storageRoot, path.join(root, '.indexes'));
19
+ assert.equal(status.indexStorage.status, 'ready');
20
+ let currentRoot = root, release, started;
21
+ const entered = new Promise(resolve => { started = resolve; });
22
+ const held = new Promise(resolve => { release = resolve; });
23
+ const queued = new ModuleBridge({ dataDir: layout.getDataDir(), settingsStore: { read: () => ({ knowledge: { rootPath: currentRoot }, brain: { id: 'brain-default', domains: [] } }) } });
24
+ const applied = [];
25
+ queued._fetch = async (url, init) => {
26
+ const body = JSON.parse(init.body);
27
+ if (url.endsWith('/api/internal/runtime-config')) {
28
+ applied.push(body.storageRoot);
29
+ if (applied.length === 1) { started(); await held; }
30
+ return { ok: true, json: async () => ({ rootPath: body.storageRoot }) };
31
+ }
32
+ return { ok: true, json: async () => null };
33
+ };
34
+ const first = queued.syncEngineConfiguration(); await entered;
35
+ currentRoot = path.join(root, 'new root');
36
+ const second = queued.syncEngineConfiguration(); release();
37
+ await first;
38
+ const latest = await second;
39
+ assert.deepEqual(applied, [path.join(root, '.indexes'), path.join(currentRoot, '.indexes')]);
40
+ assert.equal(latest.indexStorage.actualPath, path.join(currentRoot, '.indexes'));
41
+ assert.equal(latest.indexStorage.status, 'ready');
42
+ const broken = new ModuleBridge({ dataDir: layout.getDataDir(), settingsStore: { read: () => { throw new Error('corrupt settings'); } } });
43
+ let revoked;
44
+ broken._fetch = async (url, init) => {
45
+ if (url.endsWith('/api/internal/runtime-config')) revoked = JSON.parse(init.body);
46
+ return { ok: false };
47
+ };
48
+ const unavailable = await broken.syncEngineConfiguration();
49
+ assert.deepEqual(revoked, { settings: null });
50
+ assert.equal(unavailable.indexStorage.status, 'pending');
51
+ console.log('vector-storage-root-test PASS');
52
+ }).catch(error => { console.error(error); process.exitCode = 1; });
@@ -11,6 +11,7 @@ for (const view of ['home', 'work', 'search', 'memory', 'projects', 'activity'])
11
11
  assert.ok(html.includes(`id="view-${view}"`));
12
12
  }
13
13
  assert.equal((html.match(/data-view=/g) || []).length, 6, 'diagnostics must not become another primary product');
14
+ assert.match(html, /<select\b[^>]*id="projectSelect"[^>]*\bdisabled\b/, 'project selection is disabled until the initial registry snapshot has loaded');
14
15
  for (const id of ['workFrame', 'vectorFrame', 'bridgeFrame', 'engineFrame', 'importDialog', 'importCode', 'importKnowledge', 'importDomain', 'setupImportRoot', 'createDomain', 'logsDialog', 'systemDrawer', 'copyReference']) assert.equal((html.match(new RegExp(`id="${id}"`, 'g')) || []).length, 1);
15
16
  assert.ok(html.includes('data-action="import"') && html.includes('不分析全部历史'));
16
17
  assert.ok(!html.includes('srcdoc') && !/data-simulate|data-analyze|data-install-hook/.test(html));
@@ -71,6 +71,11 @@ const ERROR_CODES = Object.freeze([
71
71
  'KNOWLEDGE_WRITE_MOVED',
72
72
  'MODEL_CONFIG_MOVED',
73
73
  'MODULE_UNREACHABLE',
74
+ 'ENGINE_UNAVAILABLE',
75
+ 'ENGINE_IDENTITY_MISMATCH',
76
+ 'ENGINE_INVALID_RESPONSE',
77
+ 'ENGINE_REQUEST_FAILED',
78
+ 'ENGINE_URL_INVALID',
74
79
  ]);
75
80
 
76
81
  const IMMUTABLE_PROJECT_FIELDS = Object.freeze([
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+ const { DomainError } = require('./contracts');
3
+ const endpoint = require('./runtime-endpoint');
4
+ const control = require('./runtime-control');
5
+
6
+ class EngineKnowledgeClient {
7
+ constructor({ layout, engineUrl, fetchImpl = fetch, timeoutMs = 20000 }) {
8
+ this.layout = layout;
9
+ this.engineUrl = engineUrl || process.env.KB_KNOWLEDGE_ENGINE_URL;
10
+ this.fetch = fetchImpl;
11
+ this.timeoutMs = timeoutMs;
12
+ }
13
+
14
+ async json(url, options = {}) {
15
+ let response;
16
+ try { response = await this.fetch(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(this.timeoutMs) }); }
17
+ catch { throw new DomainError('ENGINE_UNAVAILABLE', '知识引擎连接失败或超时,请在系统状态中检查服务。', { status: 503 }); }
18
+ let payload;
19
+ try {
20
+ const reader = response.body.getReader();
21
+ const chunks = []; let size = 0;
22
+ try {
23
+ while (true) {
24
+ const part = await reader.read(); if (part.done) break;
25
+ size += part.value.byteLength;
26
+ if (size > 2 * 1024 * 1024) { await reader.cancel(); throw new Error('response limit'); }
27
+ chunks.push(Buffer.from(part.value));
28
+ }
29
+ } finally { reader.releaseLock(); }
30
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
31
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid envelope');
32
+ } catch { throw new DomainError('ENGINE_INVALID_RESPONSE', '知识引擎返回了无效响应。', { status: 502 }); }
33
+ if (!response.ok || payload.ok === false) {
34
+ const code = /^[A-Z][A-Z0-9_]{0,79}$/.test(payload.error?.code || '') ? payload.error.code : 'ENGINE_REQUEST_FAILED';
35
+ throw new DomainError(code, '知识引擎未完成读取,请检查项目接线或请求参数。', { status: response.ok ? 502 : response.status });
36
+ }
37
+ return payload;
38
+ }
39
+
40
+ async target(project) {
41
+ if (this.engineUrl) {
42
+ let url; try { url = new URL(this.engineUrl); } catch {}
43
+ if (!url || !['http:', 'https:'].includes(url.protocol) || !endpoint.isLoopbackHost(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) throw new DomainError('ENGINE_URL_INVALID', '知识引擎地址必须是无凭据的本机服务地址。');
44
+ const health = await this.json(new URL('/api/health', url));
45
+ const registered = await this.json(new URL('/api/projects', url));
46
+ const match = registered.projects?.find(item => item.projectId === project.projectId && item.enabled !== false);
47
+ if (health.name !== 'knowledge-engine' || health.dataSchemaVersion !== 3 || !match || !this.layout.pathsEqual(match.repoPath, project.repoPath) || !this.layout.pathsEqual(match.knowledgePath, project.knowledgePath)) throw new DomainError('ENGINE_IDENTITY_MISMATCH', '知识引擎与当前项目目录绑定不匹配。', { status: 409 });
48
+ return { url, prefix: '/api' };
49
+ }
50
+ const live = endpoint.readLiveEndpoint(this.layout.getDataDir(), { removeStale: false });
51
+ if (!live) throw new DomainError('ENGINE_UNAVAILABLE', '知识库服务尚未运行,请先启动 project-knowledge。', { status: 503 });
52
+ const url = new URL(`http://${live.host.includes(':') ? `[${live.host}]` : live.host}:${live.port}`);
53
+ const identity = await this.json(new URL('/api/runtime/identity', url));
54
+ if (identity.schema !== control.SCHEMA || identity.name !== 'project-knowledge' || identity.pid !== live.pid || identity.port !== live.port || identity.startedAt !== live.startedAt || !identity.dataDir || !this.layout.pathsEqual(identity.dataDir, this.layout.getDataDir())) throw new DomainError('ENGINE_IDENTITY_MISMATCH', '知识库实例与当前数据目录不匹配,已拒绝读取。', { status: 409 });
55
+ return { url, prefix: '/api/engine' };
56
+ }
57
+
58
+ async request(action, input, project) {
59
+ if (!['search', 'ask', 'get', 'history'].includes(action)) throw new DomainError('INVALID_ARGUMENT', '仅支持知识读取操作。');
60
+ if (input.scope !== undefined && !['project', 'domain', 'brain'].includes(input.scope)) throw new DomainError('INVALID_ARGUMENT', '知识范围必须是项目、领域或智脑。');
61
+ const target = await this.target(project);
62
+ // Forward only the read contract, never profiles, credentials or context objects.
63
+ const body = { projectId: project.projectId };
64
+ for (const key of ['query', 'limit', 'entryId', 'entry', 'scope', 'spaceId']) if (input[key] !== undefined) body[key] = input[key];
65
+ if (body.spaceId === undefined && input.space !== undefined) body.spaceId = input.space;
66
+ const result = await this.json(new URL(`${target.prefix}/${action}`, target.url), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
67
+ const valid = action === 'get' ? Array.isArray(result.chunks) : Array.isArray(result.results);
68
+ if (result.ok !== true || !valid || (action === 'ask' && (typeof result.answer !== 'string' || !Array.isArray(result.citations)))) throw new DomainError('ENGINE_INVALID_RESPONSE', '知识引擎返回了无效读取结果。', { status: 502 });
69
+ return result;
70
+ }
71
+ }
72
+ module.exports = { EngineKnowledgeClient };
@@ -12,6 +12,7 @@ const { EMBEDDING_DIMENSIONS } = require('./knowledge-schema');
12
12
  const { DomainError } = require('./contracts');
13
13
  const { KnowledgeRetrievalService } = require('./knowledge-retrieval-service');
14
14
  const { ConversationStore } = require('./conversation-store');
15
+ const { EngineKnowledgeClient } = require('./engine-knowledge-client');
15
16
 
16
17
  function gitRoot(candidate) {
17
18
  const resolved = path.resolve(candidate || process.cwd());
@@ -97,6 +98,7 @@ class KnowledgeToolRuntime {
97
98
  this.settingsStore = options.settingsStore || new SettingsStore({ layout: this.layout });
98
99
  this.registryStore = options.registryStore || new ProjectRegistryStore({ layout: this.layout });
99
100
  this.projectStore = options.projectStore || new ProjectStore({ layout: this.layout });
101
+ this.engineClient = options.engineClient || new EngineKnowledgeClient({ layout: this.layout, engineUrl: options.engineUrl, fetchImpl: options.fetchImpl });
100
102
  this.database = null;
101
103
  this.embedder = null;
102
104
  this.conversationStore = options.conversationStore || new ConversationStore({
@@ -279,10 +281,13 @@ class KnowledgeToolRuntime {
279
281
 
280
282
  async search(input = {}) {
281
283
  const project = this.resolveProject(input);
284
+ if (this.projectStore.readState(project.projectId).modules?.engine) return this.engineClient.request('search', input, project);
282
285
  return this.retrievalService.search({ ...input, project });
283
286
  }
284
287
 
285
288
  async ask(input = {}) {
289
+ const project = this.resolveProject(input);
290
+ if (this.projectStore.readState(project.projectId).modules?.engine) return this.engineClient.request('ask', input, project);
286
291
  const searched = await this.search(input);
287
292
  const citations = searched.results.slice(0, 5).map((result, index) => ({
288
293
  index: index + 1,
@@ -300,6 +305,7 @@ class KnowledgeToolRuntime {
300
305
 
301
306
  async get(input = {}) {
302
307
  const project = this.resolveProject(input);
308
+ if (this.projectStore.readState(project.projectId).modules?.engine) return this.engineClient.request('get', input, project);
303
309
  const entryId = String(input.entryId || input.entry || '').trim().replace(/\\/g, '/');
304
310
  if (!entryId) throw new DomainError('INVALID_ARGUMENT', 'entry is required.');
305
311
  const target = path.resolve(project.knowledgePath, ...entryId.split('/'));
@@ -321,6 +327,7 @@ class KnowledgeToolRuntime {
321
327
 
322
328
  async history(input = {}) {
323
329
  const project = this.resolveProject(input);
330
+ if (this.projectStore.readState(project.projectId).modules?.engine) return this.engineClient.request('history', input, project);
324
331
  const changesRoot = path.join(project.knowledgePath, 'changes');
325
332
  const results = markdownFiles(project.knowledgePath).filter(file => this.layout.isPathInside(changesRoot, file, { realpath: true })).map(file => {
326
333
  const text = fs.readFileSync(file, 'utf8');
@@ -24,6 +24,7 @@
24
24
 
25
25
  const { spawn, spawnSync } = require('child_process');
26
26
  const path = require('path');
27
+ const { StorageLayout } = require('./storage-layout');
27
28
  const fs = require('fs');
28
29
  const crypto = require('crypto');
29
30
  const { once } = require('events');
@@ -310,10 +311,19 @@ class ModuleBridge {
310
311
  } };
311
312
  }
312
313
 
313
- async syncEngineConfiguration() {
314
- if (this.syncingConfig) return this.configStatus || { scopes: false, model: false };
315
- this.syncingConfig = true;
316
- const status = { scopes: false, model: false, embedding: false };
314
+ syncEngineConfiguration() {
315
+ if (this.configSyncPromise) return this.configSyncPromise.catch(() => undefined).then(() => this.syncEngineConfiguration());
316
+ const pending = this._syncEngineConfiguration().finally(() => { if (this.configSyncPromise === pending) this.configSyncPromise = null; });
317
+ this.configSyncPromise = pending;
318
+ return pending;
319
+ }
320
+
321
+ async _syncEngineConfiguration() {
322
+ const dataDir = this.dataDir || require('./data-dir').getDataDir();
323
+ let indexPath = null;
324
+ try { indexPath = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read()); }
325
+ catch { /* Corrupt settings must not switch storage or prevent credential revocation. */ }
326
+ const status = { scopes: false, scopeStatus: { status: 'pending' }, model: false, embedding: false, indexStorage: { expectedPath: indexPath, actualPath: null, status: 'pending' } };
317
327
  const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
318
328
  try {
319
329
  if (this.settingsStore) {
@@ -321,11 +331,28 @@ class ModuleBridge {
321
331
  if (settings.knowledge.rootPath) {
322
332
  const brain = settings.brain;
323
333
  const root = path.join(settings.knowledge.rootPath, '_shared');
324
- const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: path.join(root, brain.id) }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: path.join(root, d.id) }))];
325
- status.scopes = (await this._fetch(`${this.engineUrl}/api/scopes`, { method: 'POST', headers, body: JSON.stringify({ scopes }) })).ok;
334
+ const response = await this._fetch(`${this.engineUrl}/api/snapshot`);
335
+ if (!response.ok) throw new DomainError('ENGINE_UNAVAILABLE', '共享知识接线暂时不可用。');
336
+ const prior = (await response.json()).sharedScopes || [];
337
+ // Root settings govern new directories and derived indexes, never move
338
+ // existing knowledge. Retain engine-acknowledged bindings in the spine.
339
+ const binding = (item, kind) => {
340
+ const existing = prior.find(scope => scope.id === item.id && scope.kind === kind && scope.brainId === brain.id);
341
+ if (existing && !path.isAbsolute(existing.knowledgePath || '')) throw new DomainError('DATA_CORRUPT', '共享目录绑定损坏。');
342
+ return item.knowledgePath || existing?.knowledgePath || path.join(root, item.id);
343
+ };
344
+ const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: binding(brain, 'brain') }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: binding(d, 'domain') }))];
345
+ const applied = await this._fetch(`${this.engineUrl}/api/scopes`, { method: 'POST', headers, body: JSON.stringify({ scopes }) });
346
+ if (!applied.ok) throw new DomainError('SHARED_SCOPE_APPLY_FAILED', '共享目录接线未完成。');
347
+ await this.settingsStore.bindSharedKnowledgePaths?.(scopes);
348
+ status.scopes = true; status.scopeStatus = { status: 'ready' };
326
349
  }
327
350
  }
328
- } catch { /* A scope failure must not prevent revoking a stale model key. */ }
351
+ } catch (error) {
352
+ status.scopeStatus = { status: 'pending', code: ['DATA_CORRUPT', 'IMMUTABLE_FIELD', 'ENGINE_UNAVAILABLE', 'SHARED_SCOPE_APPLY_FAILED'].includes(error.code) ? error.code : 'ENGINE_UNAVAILABLE' };
353
+ this.logger.warn('modules.scopes.pending', 'Shared knowledge wiring is pending; existing data is retained.', { context: { code: status.scopeStatus.code } });
354
+ // A scope failure must not prevent revoking a stale model key.
355
+ }
329
356
  try {
330
357
  let profile = null;
331
358
  try {
@@ -341,10 +368,15 @@ class ModuleBridge {
341
368
  const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
342
369
  if (resolved.ok) settings = await resolved.json();
343
370
  } catch { /* Revocation wins over a previously cached runtime key. */ }
344
- const applied = await this._fetch(`${this.vectorHubUrl}/api/internal/runtime-config`, { method: 'POST', headers, body: JSON.stringify({ settings }) });
371
+ const applied = await this._fetch(`${this.vectorHubUrl}/api/internal/runtime-config`, { method: 'POST', headers, body: JSON.stringify({ settings, ...(indexPath ? { storageRoot: indexPath } : {}) }) });
345
372
  status.embedding = applied.ok && !!settings;
373
+ if (applied.ok) {
374
+ const result = await applied.json();
375
+ status.indexStorage.actualPath = result.rootPath || null;
376
+ status.indexStorage.status = result.rootPath && path.resolve(result.rootPath) === indexPath ? 'ready' : 'pending';
377
+ }
346
378
  } catch { /* Public status contains no resolved configuration. */ }
347
- this.syncingConfig = false; this.configStatus = status;
379
+ this.configStatus = status;
348
380
  return status;
349
381
  }
350
382
 
@@ -559,12 +591,13 @@ class ModuleBridge {
559
591
  this.logger.warn('modules.supervisor', '@sanqianx/vector-hub runtime not installed; expecting an external service.', {});
560
592
  } else {
561
593
  const dataDir = this.dataDir || require('./data-dir').getDataDir();
562
- const root = path.join(dataDir, 'vectorhub');
594
+ const configRoot = path.join(dataDir, 'vectorhub');
595
+ const root = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read());
563
596
  specs.push({
564
597
  name: 'vector-hub',
565
598
  command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
566
599
  url: this.vectorHubUrl,
567
- env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(root, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(root, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
600
+ env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(configRoot, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(configRoot, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
568
601
  });
569
602
  }
570
603
  }
@@ -921,7 +921,7 @@ function createRequestHandler(runtime, options = {}) {
921
921
  }
922
922
  if (method === 'GET' && pathname === '/api/brain') {
923
923
  const settings = runtime.settingsStore.read();
924
- return send(res, 200, { ok: true, brain: settings.brain, knowledgeRoot: settings.knowledge.rootPath, configuration: runtime.moduleBridge.configStatus || null });
924
+ return send(res, 200, { ok: true, brain: settings.brain, knowledgeRoot: settings.knowledge.rootPath, indexStoragePath: runtime.layout.getVectorRootPath(settings), configuration: runtime.moduleBridge.configStatus || null });
925
925
  }
926
926
  if (method === 'PATCH' && pathname === '/api/brain') {
927
927
  const body = await readJsonBody(req);
@@ -982,8 +982,8 @@ function createRequestHandler(runtime, options = {}) {
982
982
  if (body.logging) throw new DomainError('IMMUTABLE_FIELD', 'Logging storage and capture policy are not user settings.', { status: 409 });
983
983
  if (!Object.keys(patch).length) throw new DomainError('INVALID_ARGUMENT', 'Only the knowledge root is mutable on this route.');
984
984
  const settings = await runtime.settingsStore.updatePatch(patch);
985
- await runtime.moduleBridge.syncEngineConfiguration();
986
- return send(res, 200, { ok: true, settings: { ...settings, ai: publicAiProfilesConfig(settings.ai) } });
985
+ const configuration = await runtime.moduleBridge.syncEngineConfiguration();
986
+ return send(res, 200, { ok: true, settings: { ...settings, ai: publicAiProfilesConfig(settings.ai) }, configuration });
987
987
  }
988
988
 
989
989
  if (method === 'GET' && pathname === '/api/projects') return send(res, 200, { ok: true, projects: listProjectViews(runtime.registryStore, runtime.projectStore) });
@@ -1,6 +1,7 @@
1
1
  const { SCHEMAS, DomainError, publicAiProfilesConfig } = require('./contracts');
2
2
  const AtomicFile = require('./atomic-file');
3
3
  const { StorageLayout } = require('./storage-layout');
4
+ const path = require('node:path');
4
5
 
5
6
  function defaultSettings() {
6
7
  return {
@@ -43,9 +44,12 @@ function validateSettings(settings) {
43
44
  }
44
45
  settings.brain = settings.brain || { id: 'brain-default', name: '我的智脑', domains: [] };
45
46
  if (settings.brain.id !== 'brain-default' || typeof settings.brain.name !== 'string' || !settings.brain.name.trim() || settings.brain.name.length > 100 || !Array.isArray(settings.brain.domains)) throw new DomainError('DATA_CORRUPT', 'Brain metadata is invalid.', { status: 500 });
47
+ const validBinding = value => value === undefined || typeof value === 'string' && path.isAbsolute(value);
48
+ if (!validBinding(settings.brain.knowledgePath)) throw new DomainError('DATA_CORRUPT', 'Brain knowledge binding is invalid.', { status: 500 });
46
49
  const ids = new Set();
47
50
  for (const domain of settings.brain.domains) {
48
51
  if (!domain || !/^domain-[a-z0-9-]+$/.test(domain.id) || ids.has(domain.id) || typeof domain.name !== 'string' || !domain.name.trim() || domain.name.length > 100) throw new DomainError('DATA_CORRUPT', 'Domain metadata is invalid.', { status: 500 });
52
+ if (!validBinding(domain.knowledgePath)) throw new DomainError('DATA_CORRUPT', 'Domain knowledge binding is invalid.', { status: 500 });
49
53
  ids.add(domain.id);
50
54
  }
51
55
  const logging = settings.logging || {};
@@ -92,6 +96,7 @@ class SettingsStore {
92
96
 
93
97
  async updatePatch(patch) {
94
98
  return this.atomic.withFileLock(this.lockPath, async () => {
99
+ if (patch?.brain && (Object.prototype.hasOwnProperty.call(patch.brain, 'knowledgePath') || patch.brain.domains?.some?.(domain => Object.prototype.hasOwnProperty.call(domain, 'knowledgePath')))) throw new DomainError('INVALID_ARGUMENT', '共享知识目录绑定只允许受控接线写入。');
95
100
  if (patch && patch.logging && typeof patch.logging === 'object') {
96
101
  const unknown = Object.keys(patch.logging).find(key => key !== 'levels');
97
102
  if (unknown) throw new DomainError('INVALID_ARGUMENT', `Logging setting is read-only or unsupported: ${unknown}.`, { details: { field: unknown } });
@@ -120,6 +125,21 @@ class SettingsStore {
120
125
  });
121
126
  }
122
127
 
128
+ async bindSharedKnowledgePaths(scopes) {
129
+ return this.atomic.withFileLock(this.lockPath, async () => {
130
+ const settings = this.read();
131
+ let changed = false;
132
+ for (const scope of scopes) {
133
+ const target = scope.kind === 'brain' && scope.id === settings.brain.id ? settings.brain : settings.brain.domains.find(domain => domain.id === scope.id);
134
+ if (!target || !path.isAbsolute(scope.knowledgePath || '')) throw new DomainError('INVALID_ARGUMENT', '共享知识绑定无效。');
135
+ if (target.knowledgePath && !this.layout.pathsEqual(target.knowledgePath, scope.knowledgePath)) throw new DomainError('IMMUTABLE_FIELD', '共享知识目录不能隐式移动。', { status: 409 });
136
+ if (!target.knowledgePath) { target.knowledgePath = path.resolve(scope.knowledgePath); changed = true; }
137
+ }
138
+ if (changed) { settings.updatedAt = new Date().toISOString(); validateSettings(settings); this.atomic.writeJsonAtomic(this.filePath, settings); }
139
+ return settings;
140
+ });
141
+ }
142
+
123
143
  readPublicView(options = {}) {
124
144
  const settings = this.read(options);
125
145
  return {
@@ -49,6 +49,10 @@ class StorageLayout {
49
49
  getRegistryLockPath() { return path.join(this.dataDir, 'runtime', 'locks', 'projects.lock'); }
50
50
 
51
51
  getIndexPath() { return path.join(this.dataDir, 'index', 'knowledge.lancedb'); }
52
+ getVectorRootPath(settings) {
53
+ const root = settings?.knowledge?.rootPath;
54
+ return root ? path.join(path.resolve(root), '.indexes') : path.join(this.dataDir, 'vectorhub');
55
+ }
52
56
  getCachePath(...segments) { return path.join(this.dataDir, 'cache', ...segments); }
53
57
  getRuntimePath(...segments) { return path.join(this.dataDir, 'runtime', ...segments); }
54
58
  getRecoveryPath(...segments) { return path.join(this.dataDir, 'recovery', ...segments); }
@@ -25,7 +25,9 @@ Read-only local knowledge tools:
25
25
  project-knowledge-kb get --project <projectId-or-legacy-slug> --entry <path>
26
26
  project-knowledge-kb history --project <projectId-or-legacy-slug> [--limit 20]
27
27
 
28
- Add --json for machine-readable output. These commands never modify knowledge or project configuration.\n`);
28
+ Add --scope project|domain|brain for a search range, --space <id> for a cited
29
+ shared entry, and --json for machine-readable output. These commands never
30
+ modify knowledge or project configuration.\n`);
29
31
  }
30
32
 
31
33
  async function main(argv = process.argv.slice(2), options = {}) {
@@ -38,7 +40,7 @@ async function main(argv = process.argv.slice(2), options = {}) {
38
40
  if (!args.project) throw new Error('--project is required');
39
41
  const runtime = options.runtime || new KnowledgeToolRuntime(options);
40
42
  try {
41
- const input = { project: args.project, query: args.query, limit: args.limit, entry: args.entry };
43
+ const input = { project: args.project, query: args.query, limit: args.limit, entry: args.entry, scope: args.scope, spaceId: args.spaceId || args.space };
42
44
  let result;
43
45
  if (args.command === 'search') result = await runtime.search(input);
44
46
  else if (args.command === 'ask') result = await runtime.ask(input);
@@ -11,6 +11,7 @@ const { KnowledgeToolRuntime } = require('../_site/lib/knowledge-tool-runtime');
11
11
  const { serializeErrorEnvelope, createId } = require('../_site/lib/contracts');
12
12
 
13
13
  const SERVER_NAME = 'project-knowledge';
14
+ const SCOPE = { type: 'string', enum: ['project', 'domain', 'brain'], description: 'Knowledge range: current project (default), its domain, or its brain.' };
14
15
  const SERVER_INSTRUCTIONS = `Project Knowledge is the durable source of prior project decisions and implementation history. Knowledge content is read-only; the only write operation appends an explicit supplemental Prompt event to the shared conversation truth.
15
16
 
16
17
  At the start of work in a registered Git repository, and before answering questions about prior work or implementing a non-trivial change:
@@ -56,7 +57,7 @@ const TOOLS = [
56
57
  },
57
58
  {
58
59
  name: 'project_knowledge_search',
59
- description: 'Search durable project knowledge using the current project and its explicitly related-project scope. This tool is read-only.',
60
+ description: 'Search the same durable knowledge and commit citations as the product UI, within the selected project, domain or brain. This tool is read-only.',
60
61
  inputSchema: {
61
62
  type: 'object',
62
63
  required: ['query'],
@@ -66,6 +67,7 @@ const TOOLS = [
66
67
  projectId: { type: 'string', description: 'Stable projectId returned by project_knowledge_resolve.' },
67
68
  project: { type: 'string', description: 'Optional registered project slug.' },
68
69
  limit: { type: 'integer', minimum: 1, maximum: 30, default: 8 },
70
+ scope: SCOPE,
69
71
  },
70
72
  additionalProperties: false,
71
73
  },
@@ -82,6 +84,7 @@ const TOOLS = [
82
84
  projectId: { type: 'string' },
83
85
  project: { type: 'string' },
84
86
  limit: { type: 'integer', minimum: 1, maximum: 30, default: 8 },
87
+ scope: SCOPE,
85
88
  },
86
89
  additionalProperties: false,
87
90
  },
@@ -95,6 +98,8 @@ const TOOLS = [
95
98
  properties: {
96
99
  entry: { type: 'string', minLength: 1, description: 'Entry identifier returned by search.' },
97
100
  space: { type: 'string', description: 'Optional scoped knowledge space identifier.' },
101
+ spaceId: { type: 'string', description: 'Knowledge space identifier returned by a citation; alias of space.' },
102
+ scope: SCOPE,
98
103
  repoPath: { type: 'string' },
99
104
  projectId: { type: 'string' },
100
105
  project: { type: 'string' },
@@ -112,6 +117,7 @@ const TOOLS = [
112
117
  projectId: { type: 'string' },
113
118
  project: { type: 'string' },
114
119
  limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
120
+ scope: SCOPE,
115
121
  },
116
122
  additionalProperties: false,
117
123
  },