@sanqianx/project-knowledge 5.0.8 → 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 (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +109 -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/coordinator.js +68 -36
  7. package/_modules/knowledge-engine/src/diagnostics.js +1 -1
  8. package/_modules/knowledge-engine/src/headless-analyzer.js +3 -1
  9. package/_modules/knowledge-engine/src/index-request.js +49 -0
  10. package/_modules/knowledge-engine/src/knowledge-targets.js +46 -0
  11. package/_modules/knowledge-engine/src/retrieval.js +7 -5
  12. package/_modules/knowledge-engine/src/runtime/conversation-store.js +1 -1
  13. package/_modules/knowledge-engine/src/server.js +1 -1
  14. package/_modules/knowledge-engine/ui/app.js +1 -1
  15. package/_site/_test/candidate-release-contract-test.js +14 -0
  16. package/_site/_test/conversation-store-test.js +2 -0
  17. package/_site/_test/fixtures/make-git-repos.js +1 -9
  18. package/_site/_test/index-error-display-contract-test.js +10 -0
  19. package/_site/_test/knowledge-engine-client-test.js +105 -0
  20. package/_site/_test/product-brand-contract-test.js +25 -0
  21. package/_site/_test/release-version-sync-test.js +7 -1
  22. package/_site/_test/sessions-stream-test.js +3 -2
  23. package/_site/_test/shared-scope-root-binding-test.js +67 -0
  24. package/_site/_test/simple-import-test.js +3 -2
  25. package/_site/_test/team-knowledge-store-test.js +3 -2
  26. package/_site/_test/test-server-cleanup-test.js +31 -0
  27. package/_site/_test/vector-storage-root-test.js +52 -0
  28. package/_site/_test/workspace-ui-contract-test.js +1 -0
  29. package/_site/lib/contracts.js +5 -0
  30. package/_site/lib/conversation-store.js +1 -1
  31. package/_site/lib/engine-knowledge-client.js +72 -0
  32. package/_site/lib/knowledge-tool-runtime.js +7 -0
  33. package/_site/lib/module-bridge.js +44 -11
  34. package/_site/lib/server-app.js +3 -3
  35. package/_site/lib/settings-store.js +20 -0
  36. package/_site/lib/storage-layout.js +4 -0
  37. package/_site/scripts/pack-module-candidates.js +1 -1
  38. package/_site/scripts/sync-release-version.js +12 -10
  39. package/_site/scripts/verify-module-runtime.js +7 -2
  40. package/_site/scripts/verify-product-runtime.js +1 -0
  41. package/bin/project-knowledge-kb.js +4 -2
  42. package/bin/project-knowledge-mcp.js +7 -1
  43. package/module-runtime-manifest.json +41 -38
  44. package/package.json +6 -6
  45. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  46. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  47. package/plugins/project-knowledge/.mcp.json +1 -1
  48. package/ui/favicon.svg +5 -38
  49. package/ui/index.html +4 -4
  50. package/ui/product.css +2 -1
  51. package/ui/product.js +13 -2
  52. package/vendor-manifest.json +12 -10
@@ -45,6 +45,8 @@ const { ConversationStore, sha256 } = require('../lib/conversation-store');
45
45
  assert.strictEqual(store.readEvents(projectId).length, 1);
46
46
  assert.strictEqual(store.readEvents(projectId)[0].content, privatePrompt, 'full prompt belongs in the private business store');
47
47
  assert.strictEqual(store.readEvents(projectId)[0].contentHash, sha256(privatePrompt));
48
+ await store.appendBridgeEvent(projectId, { ...store.readEvents(projectId)[0], eventId: 'event-zcode-1', sequence: 12, source: 'zcode' });
49
+ assert.strictEqual(store.readEvents(projectId).find(event => event.eventId === 'event-zcode-1').source, 'zcode');
48
50
  assert(!JSON.stringify(logRecords).includes(privatePrompt), 'conversation body must never enter structured logs');
49
51
  assert(JSON.stringify(logRecords).includes(sha256(privatePrompt)), 'logs may retain body hash for correlation');
50
52
 
@@ -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,10 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const source = fs.readFileSync(path.resolve(__dirname, '../../ui/product.js'), 'utf8');
6
+ for (const field of ['upstreamMessage', 'failureKind', 'upstreamCode', 'requestId', 'attempt', 'httpStatus', 'durationMs']) {
7
+ assert.ok(source.includes(`esc(r.error.details.${field}`), `index detail must be escaped: ${field}`);
8
+ }
9
+ assert.ok(source.includes("['failed', 'index-pending'].includes(r.state)"));
10
+ console.log('index-error-display-contract-test PASS (real rendering/export covered by engine test:web:faults)');
@@ -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; });
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const ui = path.resolve(__dirname, '../../ui');
8
+ const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8');
9
+ const css = fs.readFileSync(path.join(ui, 'product.css'), 'utf8');
10
+ const mark = fs.readFileSync(path.join(ui, 'favicon.svg'), 'utf8');
11
+ const favicon = html.match(/<link\b(?=[^>]*\brel="icon")[^>]*\bhref="([^"]+)"/);
12
+ const sidebar = html.match(/<img\b(?=[^>]*\bclass="brand-mark")[^>]*\bsrc="([^"]+)"/);
13
+
14
+ assert(favicon, 'Product needs an SVG favicon.');
15
+ assert(sidebar, 'Sidebar must use the product mark, not a second drawn icon.');
16
+ assert.equal(sidebar[1], favicon[1], 'Favicon and sidebar must share the exact asset URL.');
17
+ assert.equal(favicon[1].split('?')[0], '/favicon.svg');
18
+ assert.match(favicon[1], /\?v=/, 'Brand changes need a new browser cache identity.');
19
+ assert.match(mark, /viewBox="0 0 64 64"/);
20
+ assert.match(mark, /fill="#516f89"/);
21
+ assert.equal((mark.match(/<path\b/g) || []).length, 1, 'Keep one geometric silhouette.');
22
+ assert.doesNotMatch(mark, /<(?:script|image|text|filter|linearGradient|radialGradient)\b/i);
23
+ assert.doesNotMatch(html, /<span class="brand-mark">知<\/span>/);
24
+ assert.match(css, /\.brand-mark\{[^}]*width:32px;[^}]*height:32px;[^}]*flex:none/);
25
+ console.log('PASS product brand: shared SVG, cache identity, fixed-size layout, no decorative dependencies');
@@ -90,8 +90,14 @@ try {
90
90
  ]);
91
91
  const scripts = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).scripts;
92
92
  assert.strictEqual(scripts.preversion, 'npm run release:verify');
93
- assert.strictEqual(scripts.version, 'node _site/scripts/sync-release-version.js --stage');
93
+ assert.strictEqual(scripts.version, 'node _site/scripts/sync-release-version.js --stage --web-only');
94
94
  assert.strictEqual(scripts.postversion, 'npm run release:verify');
95
+ const desktopBytes = FILES.filter(file => file.startsWith('desktop/')).map(file => fs.readFileSync(path.join(TEMP, file)));
96
+ value.version = '9.8.8-rc.1'; fs.writeFileSync(rootPackage, JSON.stringify(value, null, 2) + '\n');
97
+ assert.strictEqual(run(['--web-only']).status, 0);
98
+ assert.strictEqual(run(['--check', '--web-only']).status, 0);
99
+ assert.notStrictEqual(run(['--check']).status, 0, 'desktop still needs explicit synchronization for desktop releases');
100
+ FILES.filter(file => file.startsWith('desktop/')).forEach((file, i) => assert.deepStrictEqual(fs.readFileSync(path.join(TEMP, file)), desktopBytes[i]));
95
101
  console.log('release-version-sync-test PASS');
96
102
  } finally {
97
103
  fs.rmSync(TEMP, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
@@ -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; });
@@ -4,10 +4,10 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
6
  const { spawnServer } = require('./helpers/spawn-server');
7
+ const freePort = require('./helpers/free-port');
7
8
 
8
9
  const ROOT = path.resolve(__dirname, '..', '..');
9
- const PORT = 7824;
10
- const BASE = `http://127.0.0.1:${PORT}`;
10
+ let PORT, BASE;
11
11
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-import-v2-'));
12
12
  const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-import-repo-'));
13
13
  const knowledgeRoot = path.join(dataDir, 'knowledge');
@@ -36,6 +36,7 @@ async function waitForServer() {
36
36
  }
37
37
 
38
38
  (async () => {
39
+ PORT = await freePort(); BASE = `http://127.0.0.1:${PORT}`;
39
40
  git(['init', '--initial-branch=main']);
40
41
  git(['config', 'user.email', 'import@example.test']);
41
42
  git(['config', 'user.name', 'Import Test']);
@@ -4,11 +4,11 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
6
  const { spawnServer } = require('./helpers/spawn-server');
7
+ const freePort = require('./helpers/free-port');
7
8
  const githubTeamStore = require('../lib/github-team-store');
8
9
 
9
10
  const ROOT = path.resolve(__dirname, '..', '..');
10
- const PORT = Number(process.env.KB_TEAM_KNOWLEDGE_PORT || 7837);
11
- const BASE_URL = `http://127.0.0.1:${PORT}`;
11
+ let PORT, BASE_URL;
12
12
  const TEMP = fs.mkdtempSync(path.join(os.tmpdir(), `kb-team-v2-${process.pid}-`));
13
13
  const DATA_DIR = path.join(TEMP, 'data');
14
14
  const SOURCE_ONE = path.join(TEMP, 'source-one');
@@ -51,6 +51,7 @@ async function json(method, route, body) {
51
51
  }
52
52
 
53
53
  (async () => {
54
+ PORT = process.env.KB_TEAM_KNOWLEDGE_PORT ? Number(process.env.KB_TEAM_KNOWLEDGE_PORT) : await freePort(); BASE_URL = `http://127.0.0.1:${PORT}`;
54
55
  let spawned;
55
56
  try {
56
57
  initializeRepo(SOURCE_ONE, 'Source one');
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnServer } = require('./helpers/spawn-server');
6
+ const freePort = require('./helpers/free-port');
7
+ const { readEndpoint } = require('../lib/runtime-endpoint');
8
+
9
+ (async () => {
10
+ const port = await freePort();
11
+ const spawned = spawnServer({ root: path.resolve(__dirname, '../..'), port, tag: 'graceful-cleanup' });
12
+ let stopped = false;
13
+ try {
14
+ for (let attempt = 0; attempt < 100; attempt++) {
15
+ const endpoint = readEndpoint(spawned.dataDir);
16
+ if (endpoint && (await fetch(`http://127.0.0.1:${port}/api/health`).catch(() => null))?.ok) break;
17
+ await new Promise(resolve => setTimeout(resolve, 100));
18
+ }
19
+ assert.equal(readEndpoint(spawned.dataDir)?.pid, spawned.child.pid);
20
+ let output = '';
21
+ spawned.child.stderr.on('data', bytes => { output += bytes; });
22
+ await spawned.cleanup(); stopped = true;
23
+ assert.notEqual(spawned.child.exitCode, null, output);
24
+ assert.equal(spawned.child.signalCode, null, 'normal cleanup must drain the server instead of force killing it');
25
+ assert.equal(spawned.child.exitCode, 0, output);
26
+ assert.ok(!fs.existsSync(spawned.dataDir));
27
+ assert.ok(!fs.existsSync(spawned.bridgeHome));
28
+ await assert.rejects(fetch(`http://127.0.0.1:${port}/api/health`));
29
+ console.log('test-server-cleanup-test PASS (verified graceful shutdown before fixture removal)');
30
+ } finally { if (!stopped) await spawned.cleanup(); }
31
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -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([
@@ -4,7 +4,7 @@ const { SCHEMAS, DomainError, validateProjectId } = require('./contracts');
4
4
  const AtomicFile = require('./atomic-file');
5
5
  const { StorageLayout } = require('./storage-layout');
6
6
 
7
- const SOURCES = new Set(['claude-code', 'codex', 'opencode']);
7
+ const SOURCES = new Set(['claude-code', 'codex', 'opencode', 'zcode']);
8
8
  const ROLES = new Set(['user', 'assistant', null]);
9
9
  const SNAPSHOT_STATUSES = new Set(['available', 'no-new-user-prompt', 'unavailable', 'ambiguous']);
10
10
  const BINDING_KINDS = new Set(['direct', 'shared-spanning', 'explicit']);
@@ -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');