@sanqianx/project-knowledge 4.7.0 → 5.0.0
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.
- package/.agents/plugins/marketplace.json +17 -17
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +836 -799
- package/README.md +58 -41
- package/_modules/knowledge-engine/README.md +91 -0
- package/_modules/knowledge-engine/package.json +38 -0
- package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
- package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
- package/_modules/knowledge-engine/src/bin.js +52 -0
- package/_modules/knowledge-engine/src/bridge-client.js +24 -0
- package/_modules/knowledge-engine/src/coordinator.js +263 -0
- package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
- package/_modules/knowledge-engine/src/memories.js +85 -0
- package/_modules/knowledge-engine/src/retrieval.js +116 -0
- package/_modules/knowledge-engine/src/runtime/README.md +7 -0
- package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
- package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
- package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
- package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
- package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
- package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
- package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
- package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
- package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
- package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
- package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
- package/_modules/knowledge-engine/src/server.js +154 -0
- package/_modules/knowledge-engine/src/store.js +236 -0
- package/_modules/knowledge-engine/ui/app.css +172 -0
- package/_modules/knowledge-engine/ui/app.js +297 -0
- package/_modules/knowledge-engine/ui/index.html +95 -0
- package/_site/README.md +30 -30
- package/_site/_test/ai-profile-resolver-test.js +137 -137
- package/_site/_test/automation-queue-test.js +14 -14
- package/_site/_test/automation-ui-test.js +40 -40
- package/_site/_test/background-task-registry-test.js +43 -43
- package/_site/_test/baseline-schema-test.js +91 -91
- package/_site/_test/chat-claudecodeui-match-test.js +92 -92
- package/_site/_test/claude-executable-discovery-test.js +62 -62
- package/_site/_test/claude-workbench-test.js +170 -170
- package/_site/_test/codex-conversation-projection-test.js +2 -1
- package/_site/_test/commit-conversation-binding-test.js +95 -95
- package/_site/_test/commit-evidence-test.js +110 -110
- package/_site/_test/conversation-api-test.js +109 -109
- package/_site/_test/conversation-store-test.js +136 -136
- package/_site/_test/desktop-browser-compat-test.js +28 -28
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
- package/_site/_test/explicit-commit-processor-test.js +37 -37
- package/_site/_test/fixtures/make-git-repos.js +12 -1
- package/_site/_test/folder-picker-output-test.js +19 -19
- package/_site/_test/fresh-hook-reimport-test.js +53 -0
- package/_site/_test/fresh-product-data-test.js +42 -0
- package/_site/_test/full-integration-e2e-test.js +165 -165
- package/_site/_test/git-validation-test.js +2 -2
- package/_site/_test/hook-runtime-endpoint-test.js +88 -88
- package/_site/_test/hook-status-repair-api-test.js +152 -152
- package/_site/_test/hook-trigger-test.js +85 -85
- package/_site/_test/import-preflight-api-test.js +234 -233
- package/_site/_test/index-writer-concurrency-test.js +135 -135
- package/_site/_test/integration-adapters-test.js +171 -171
- package/_site/_test/integration-surface-coverage-test.js +164 -164
- package/_site/_test/knowledge-language-control-test.js +170 -170
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
- package/_site/_test/knowledge-query-test.js +54 -54
- package/_site/_test/knowledge-retrieval-service-test.js +77 -77
- package/_site/_test/knowledge-storage-startup-test.js +52 -52
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
- package/_site/_test/logging-api-test.js +89 -89
- package/_site/_test/logging-sse-no-gap-test.js +88 -88
- package/_site/_test/logging-ui-test.js +106 -104
- package/_site/_test/markdown-delta-overlay-test.js +85 -85
- package/_site/_test/markdown-maintenance-api-test.js +75 -75
- package/_site/_test/mcp-server-test.js +149 -149
- package/_site/_test/module-artifact-integrity-test.js +27 -0
- package/_site/_test/module-boundary-test.js +30 -0
- package/_site/_test/module-bridge-eventbridge-test.js +7 -0
- package/_site/_test/module-model-configuration-test.js +44 -0
- package/_site/_test/module-process-lifecycle-test.js +24 -0
- package/_site/_test/module-stream-proxy-test.js +34 -0
- package/_site/_test/non-release-ci-test.js +34 -34
- package/_site/_test/offline-boundary-isolation-test.js +32 -32
- package/_site/_test/packaged-ui-smoke-test.js +11 -9
- package/_site/_test/path-consistency-test.js +151 -151
- package/_site/_test/pending-sweep-test.js +9 -9
- package/_site/_test/post-commit-automation-test.js +87 -87
- package/_site/_test/product-diagnostics-ui-test.js +75 -0
- package/_site/_test/product-import-ui-test.js +92 -0
- package/_site/_test/project-delete-recovery-test.js +64 -64
- package/_site/_test/project-goal-editor-test.js +144 -144
- package/_site/_test/project-lifecycle-transaction-test.js +106 -106
- package/_site/_test/prompt-settings-test.js +115 -115
- package/_site/_test/protected-architecture-gate-test.js +122 -122
- package/_site/_test/refactor-characterization-test.js +36 -36
- package/_site/_test/release-version-sync-test.js +1 -1
- package/_site/_test/requirement-recorder-test.js +173 -173
- package/_site/_test/run-all-tests.js +159 -156
- package/_site/_test/shared-contracts-test.js +58 -58
- package/_site/_test/startup-analysis-disabled-test.js +10 -10
- package/_site/_test/storage-foundation-test.js +78 -78
- package/_site/_test/structured-logger-test.js +101 -101
- package/_site/_test/tracking-start-test.js +109 -108
- package/_site/_test/workbench-permission-test.js +117 -117
- package/_site/_test/workspace-ui-contract-test.js +28 -43
- package/_site/lib/ai-profile-resolver.js +78 -78
- package/_site/lib/ai-workspace.js +101 -101
- package/_site/lib/automation-config.js +140 -140
- package/_site/lib/bridge-adapter.js +1 -0
- package/_site/lib/bridge-consumer-service.js +402 -402
- package/_site/lib/claude-cli-runner.js +1510 -1510
- package/_site/lib/commit-conversation-binder.js +234 -234
- package/_site/lib/commit-processing-ledger.js +27 -27
- package/_site/lib/commit-prompt.js +202 -202
- package/_site/lib/commit-reconciler.js +406 -406
- package/_site/lib/contracts.js +284 -279
- package/_site/lib/conversation-query-service.js +144 -144
- package/_site/lib/conversation-store.js +385 -385
- package/_site/lib/data-dir.js +6 -74
- package/_site/lib/engine-connection.js +67 -0
- package/_site/lib/evidence-bundle.js +228 -228
- package/_site/lib/folder-picker-output.js +21 -21
- package/_site/lib/git-runner.js +62 -62
- package/_site/lib/github-team-store.js +1172 -1172
- package/_site/lib/hook-manager.js +203 -202
- package/_site/lib/index-service.js +173 -173
- package/_site/lib/integration-installer.js +908 -907
- package/_site/lib/kb-framework.js +176 -176
- package/_site/lib/kb-validator.js +124 -124
- package/_site/lib/knowledge-promotion.js +435 -435
- package/_site/lib/knowledge-retrieval-service.js +401 -401
- package/_site/lib/knowledge-tool-runtime.js +359 -359
- package/_site/lib/llm-client.js +161 -161
- package/_site/lib/markdown-knowledge-indexer.js +230 -230
- package/_site/lib/module-bridge.js +191 -36
- package/_site/lib/post-commit-automation.js +87 -87
- package/_site/lib/product-data.js +24 -0
- package/_site/lib/project-lifecycle-service.js +535 -497
- package/_site/lib/project-store.js +268 -262
- package/_site/lib/requirement-recorder.js +278 -278
- package/_site/lib/runtime-endpoint.js +155 -155
- package/_site/lib/scanner.js +298 -298
- package/_site/lib/server-app.js +1579 -1520
- package/_site/lib/settings-store.js +133 -113
- package/_site/lib/storage-layout.js +162 -162
- package/_site/lib/structured-logger.js +574 -574
- package/_site/scripts/folder-picker.ps1 +156 -156
- package/_site/scripts/hook-trigger.js +157 -156
- package/_site/scripts/install-module-candidates.js +69 -0
- package/_site/scripts/pack-module-candidates.js +72 -0
- package/_site/scripts/sync-release-version.js +2 -2
- package/_site/scripts/vendor-modules.js +47 -0
- package/_site/scripts/verify-module-runtime.js +45 -0
- package/_site/scripts/verify-product-runtime.js +34 -0
- package/bin/project-knowledge-mcp.js +194 -194
- package/docs/README.zh-CN.md +42 -29
- package/docs/fresh-v5-start.md +28 -0
- package/docs/project-registry-schema.md +22 -22
- package/docs/testing-strategy.md +33 -33
- package/module-runtime-manifest.json +220 -0
- package/package.json +14 -9
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
- package/templates/project-readme.md +34 -34
- package/ui/favicon.svg +38 -38
- package/ui/index.html +52 -149
- package/ui/product.css +9 -0
- package/ui/product.js +412 -0
- package/vendor-manifest.json +45 -0
- package/_site/_test/data-dir-migration-test.js +0 -77
- package/_site/_test/import-ui-flow-test.js +0 -171
- package/_site/_test/knowledge-migration-test.js +0 -75
- package/_site/_test/legacy-forward-compat-test.js +0 -257
- package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
- package/_site/_test/p0-data-migration-characterization-test.js +0 -37
- package/_site/_test/p0-e2e-gate-test.js +0 -347
- package/_site/_test/project-control-panel-task14-test.js +0 -63
- package/_site/_test/project-layout-v2-migration-test.js +0 -127
- package/_site/_test/server-runtime-migration-safety-test.js +0 -27
- package/_site/_test/task15-20-ui-flow-test.js +0 -148
- package/_site/_test/ui-i18n-toggle-test.js +0 -114
- package/_site/_test/ui-smoke-test.js +0 -73
- package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
- package/_site/lib/data-state-classifier.js +0 -40
- package/_site/lib/legacy-data-manifest.js +0 -26
- package/_site/lib/migration-service.js +0 -442
- package/ui/app.css +0 -58
- package/ui/app.js +0 -607
- package/ui/i18n.js +0 -146
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const { EventEmitter } = require('node:events');
|
|
6
|
+
const { RuntimeLayout, realpathWithMissingLeaf } = require('./runtime/layout');
|
|
7
|
+
const AtomicFile = require('./runtime/atomic-file');
|
|
8
|
+
const { ConversationStore } = require('./runtime/conversation-store');
|
|
9
|
+
const { CommitReconciler } = require('./runtime/commit-reconciler');
|
|
10
|
+
const { KnowledgePromotionService } = require('./runtime/knowledge-promotion');
|
|
11
|
+
const { EngineRetrieval } = require('./retrieval');
|
|
12
|
+
const { BridgeClient } = require('./bridge-client');
|
|
13
|
+
const { HeadlessAnalyzer } = require('./headless-analyzer');
|
|
14
|
+
const { MemoryService } = require('./memories');
|
|
15
|
+
const { execGit } = require('./runtime/git-runner');
|
|
16
|
+
|
|
17
|
+
class EngineCoordinator extends EventEmitter {
|
|
18
|
+
constructor(store, options = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.store = store;
|
|
21
|
+
this.layout = new RuntimeLayout(store.dataDir);
|
|
22
|
+
this.vectorUrl = options.vectorUrl || 'http://127.0.0.1:8787';
|
|
23
|
+
this.bridge = options.bridge || new BridgeClient(options.bridgeUrl);
|
|
24
|
+
this.consume = options.consume === true;
|
|
25
|
+
this.executionEnabled = options.managed !== true;
|
|
26
|
+
this.concurrency = Math.max(1, Math.min(2, Number(options.concurrency) || 2));
|
|
27
|
+
this.active = new Map();
|
|
28
|
+
this.jobs = new Set();
|
|
29
|
+
this.initializeHook = options.initializeHook;
|
|
30
|
+
this.profiles = new Map();
|
|
31
|
+
this.closed = false;
|
|
32
|
+
this.sharedIndexJobs = new Map();
|
|
33
|
+
this.sharedIndexDirty = new Set();
|
|
34
|
+
this.sharedIndexQueue = Promise.resolve();
|
|
35
|
+
this.memories = new MemoryService(store, { onMaterialized: scope => { this.indexSharedScope(scope).catch(() => {}); } });
|
|
36
|
+
this.projectStore = {
|
|
37
|
+
readConfig: id => { const p = store.getProject(id); if (!p) throw Object.assign(new Error('项目未登记。'), { code: 'PROJECT_NOT_FOUND' }); return { ...p, displayName: p.name, automation: { enabled: true } }; },
|
|
38
|
+
readState: id => store.readRuntime(id),
|
|
39
|
+
updateState: (id, fn) => store.updateRuntime(id, fn),
|
|
40
|
+
readRequirements: () => [],
|
|
41
|
+
};
|
|
42
|
+
const registryStore = { listIds: () => store.listProjects().map(p => p.projectId), readDisplaySnapshot: id => store.getProject(id) };
|
|
43
|
+
this.retrieval = new EngineRetrieval({ layout: this.layout, projectStore: this.projectStore, registryStore, store, memories: this.memories, vectorUrl: this.vectorUrl });
|
|
44
|
+
this.conversations = new ConversationStore({ layout: this.layout, projectStore: this.projectStore });
|
|
45
|
+
this.analyzer = options.analyzer || new HeadlessAnalyzer({ resolveProfile: id => this.profiles.get(id) || this.profiles.get('default'), timeoutMs: options.timeoutMs });
|
|
46
|
+
this.promotion = new KnowledgePromotionService({ layout: this.layout, projectStore: this.projectStore, analyzer: { runClaim: async input => {
|
|
47
|
+
const run = this.currentRun(input.projectId);
|
|
48
|
+
store.updateRun(run.runId, { analysisRunId: input.claim.runId, phase: 'analyzing' });
|
|
49
|
+
this.notify(run, '正在分析冻结的提交证据');
|
|
50
|
+
if (run.debugFailure) throw Object.assign(new Error('调试模式:模拟分析失败。'), { code: 'DEBUG_SIMULATED_FAILURE', retryable: true });
|
|
51
|
+
await this.analyzer.runClaim(input);
|
|
52
|
+
store.updateRun(run.runId, { phase: 'promoting' });
|
|
53
|
+
this.notify(run, '校验并晋升知识');
|
|
54
|
+
return { ok: true };
|
|
55
|
+
} } });
|
|
56
|
+
this.reconciler = new CommitReconciler({ layout: this.layout, projectStore: this.projectStore, registryStore, conversationStore: this.conversations, retrievalService: this.retrieval, claimProcessor: this.promotion });
|
|
57
|
+
this.ready = this.initialize();
|
|
58
|
+
}
|
|
59
|
+
currentRun(id) { return this.store.getRun(this.active.get(id)); }
|
|
60
|
+
setProfile(id, profile) { if (profile) this.profiles.set(id, profile); else this.profiles.delete(id); }
|
|
61
|
+
notify(run, summary) {
|
|
62
|
+
const event = this.store.addEvent({ type: 'knowledge.run', projectId: run.projectId, runId: run.runId, commitSha: run.commitSha, phase: run.phase, summary });
|
|
63
|
+
this.emit('progress', event);
|
|
64
|
+
}
|
|
65
|
+
async initialize() {
|
|
66
|
+
await this.initializeHook?.();
|
|
67
|
+
if (this.executionEnabled) await this.promotion.recoverAll();
|
|
68
|
+
await this.memories.recover();
|
|
69
|
+
for (const run of this.store.state.runs) {
|
|
70
|
+
if (run.state === 'running') this.store.updateRun(run.runId, { state: 'queued', phase: 'recovering' });
|
|
71
|
+
}
|
|
72
|
+
if (this.consume) {
|
|
73
|
+
await this.drain().catch(() => {});
|
|
74
|
+
this.poll = setInterval(() => this.drain().catch(() => {}), 2000);
|
|
75
|
+
this.poll.unref();
|
|
76
|
+
}
|
|
77
|
+
this.pump();
|
|
78
|
+
if (this.executionEnabled) for (const run of this.store.state.runs.filter(r => r.state === 'index-pending')) this.retry(run.runId);
|
|
79
|
+
}
|
|
80
|
+
async activate() {
|
|
81
|
+
await this.ready;
|
|
82
|
+
if (this.executionEnabled && this.consume) return;
|
|
83
|
+
await this.promotion.recoverAll();
|
|
84
|
+
this.executionEnabled = true;
|
|
85
|
+
this.consume = true;
|
|
86
|
+
await this.drain();
|
|
87
|
+
if (!this.poll) { this.poll = setInterval(() => this.drain(), 2000); this.poll.unref(); }
|
|
88
|
+
for (const run of this.store.state.runs.filter(r => r.state === 'index-pending')) this.retry(run.runId);
|
|
89
|
+
this.pump();
|
|
90
|
+
}
|
|
91
|
+
enqueue(input) {
|
|
92
|
+
const p = this.store.getProject(input.projectId);
|
|
93
|
+
if (!p || p.enabled === false) throw Object.assign(new Error('项目未登记或已停用。'), { code: 'PROJECT_NOT_FOUND', status: 404 });
|
|
94
|
+
const run = this.store.addRun(input);
|
|
95
|
+
if (input.debugFailure) this.store.updateRun(run.runId, { debugFailure: true });
|
|
96
|
+
this.emit('progress', { projectId: run.projectId, runId: run.runId, commitSha: run.commitSha, phase: run.phase });
|
|
97
|
+
this.ready.then(() => this.pump());
|
|
98
|
+
return run;
|
|
99
|
+
}
|
|
100
|
+
async removeProject(projectId) {
|
|
101
|
+
await this.ready;
|
|
102
|
+
// An indexing job can still hold a write lease even after its state changes.
|
|
103
|
+
if (this.active.has(projectId)) throw Object.assign(new Error('此项目正在执行知识维护,请等待写入结束后再解绑。'), { status: 409, code: 'PROJECT_BUSY' });
|
|
104
|
+
const result = this.store.removeProject(projectId);
|
|
105
|
+
if (result.removed) this.emit('progress', this.store.addEvent({ type: 'project.unregister', projectId, summary: `已解绑项目,取消 ${result.cancelledRuns} 个未开始任务;知识与提交证据保留。` }));
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
drain() {
|
|
109
|
+
if (this.drainJob) return this.drainJob;
|
|
110
|
+
if (this.closed) return Promise.resolve();
|
|
111
|
+
const job = this.drainJournals().finally(() => { this.drainJob = null; });
|
|
112
|
+
this.drainJob = job;
|
|
113
|
+
return job;
|
|
114
|
+
}
|
|
115
|
+
async drainJournals() {
|
|
116
|
+
this.draining = true;
|
|
117
|
+
try {
|
|
118
|
+
await this.bridge.registerConsumer('knowledge-engine', { journals: ['*'] });
|
|
119
|
+
for (const journal of await this.bridge.listJournals()) {
|
|
120
|
+
if (this.closed) break;
|
|
121
|
+
const cursor = this.store.state.journalCursors[journal.journalId] || 0;
|
|
122
|
+
const events = await this.bridge.readEvents({ journalId: journal.journalId, fromSequence: cursor + 1, limit: 100 });
|
|
123
|
+
let through = cursor;
|
|
124
|
+
for (const event of events) {
|
|
125
|
+
if (this.closed) break;
|
|
126
|
+
const p = this.store.listProjects().find(p => p.enabled !== false && ((journal.journalId !== 'global' && p.journalId === journal.journalId)
|
|
127
|
+
|| ((journal.journalId === 'global' || !p.journalId) && event.repoIdentity?.workspaceRoot && this.layout.pathsEqual(p.repoPath, event.repoIdentity.workspaceRoot))));
|
|
128
|
+
if (p) {
|
|
129
|
+
if (event.schema === 'ai-coding-event/v1' && ['user_prompt', 'assistant_response'].includes(event.eventType)) await this.conversations.appendBridgeEvent(p.projectId, { ...event, journalId: journal.journalId });
|
|
130
|
+
if (event.schema === 'git-commit-boundary/v1' && /^[a-f0-9]{7,64}$/i.test(event.commitSha || '')) {
|
|
131
|
+
if (!fs.existsSync(this.layout.getProjectCommitBoundaryPath(p.projectId, event.commitSha))) this.conversations.writeBoundary(p.projectId, { ...event, journalId: journal.journalId, parentShas: event.parents || [], bridgeCursorAtCommit: event.sequence, journalSequence: event.sequence, operationId: event.operationId || `bridge-${journal.journalId}-${event.sequence}`, committedAt: event.committedAt || event.capturedAt });
|
|
132
|
+
const baseline = p.runtime.lastAnalyzedCommit || p.trackingStartCommit;
|
|
133
|
+
const completed = this.reconciler.processingLedger.read(p.projectId, event.commitSha);
|
|
134
|
+
const historical = baseline && (baseline === event.commitSha || (await execGit(p.repoPath, ['merge-base', '--is-ancestor', event.commitSha, baseline])).ok);
|
|
135
|
+
if (!completed && !historical && this.store.getProject(p.projectId)?.enabled) this.enqueue({ projectId: p.projectId, commitSha: event.commitSha, trigger: 'git-hook', journalId: journal.journalId, journalSequence: event.sequence });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
through = event.sequence;
|
|
139
|
+
}
|
|
140
|
+
// All evidence projections and queued jobs are durable before ACK.
|
|
141
|
+
if (through > cursor) this.store.setJournalCursor(journal.journalId, through);
|
|
142
|
+
await this.bridge.ackConsumerCursor('knowledge-engine', through, { journalId: journal.journalId });
|
|
143
|
+
}
|
|
144
|
+
this.journalError = null;
|
|
145
|
+
} catch { this.journalError = '事件日志连接异常,已保留游标并等待恢复。'; }
|
|
146
|
+
finally { this.draining = false; }
|
|
147
|
+
}
|
|
148
|
+
pump() {
|
|
149
|
+
if (this.closed || !this.executionEnabled) return;
|
|
150
|
+
const queued = this.store.state.runs.filter(r => r.state === 'queued').sort((a, b) => a.sequence - b.sequence);
|
|
151
|
+
for (const run of queued) {
|
|
152
|
+
if (this.active.size >= this.concurrency) break;
|
|
153
|
+
if (this.active.has(run.projectId)) continue;
|
|
154
|
+
if (this.store.state.runs.some(previous => previous.projectId === run.projectId && previous.sequence < run.sequence && previous.state === 'failed' && !previous.promotedAt)) {
|
|
155
|
+
if (run.phase !== 'waiting-previous') { this.store.updateRun(run.runId, { phase: 'waiting-previous' }); this.notify(run, '等待前一条失败提交恢复,其他项目继续运行'); }
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
this.active.set(run.projectId, run.runId);
|
|
159
|
+
const job = this.execute(run).catch(() => { this.closed = true; this.journalError = '持久化失败,已停止维护,请检查数据目录。'; }).finally(() => { this.active.delete(run.projectId); this.jobs.delete(job); this.pump(); });
|
|
160
|
+
this.jobs.add(job);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async execute(run) {
|
|
164
|
+
if (run.promotedAt) { await this.index(run); return; }
|
|
165
|
+
this.store.updateRun(run.runId, { state: 'running', phase: 'preparing' });
|
|
166
|
+
this.notify(run, '准备提交证据');
|
|
167
|
+
try {
|
|
168
|
+
const project = this.store.getProject(run.projectId);
|
|
169
|
+
const canonicalPath = realpathWithMissingLeaf(project.knowledgePath);
|
|
170
|
+
const targetKey = crypto.createHash('sha256').update(process.platform === 'win32' ? canonicalPath.toLowerCase() : canonicalPath).digest('hex');
|
|
171
|
+
const result = await AtomicFile.withFileLock(this.layout.getRuntimePath('locks', `${targetKey}.lock`), async () => {
|
|
172
|
+
const state = this.store.readRuntime(run.projectId);
|
|
173
|
+
if (state.lastAnalyzedCommit === run.commitSha) return { ok: true, recovered: true };
|
|
174
|
+
return this.reconciler.processCommitEvent({ projectId: run.projectId, commitSha: run.commitSha, operationId: run.runId });
|
|
175
|
+
}, { timeoutMs: 15 * 60 * 1000 });
|
|
176
|
+
if (!result.ok) throw Object.assign(new Error(result.error?.message || '知识维护失败。'), result.error);
|
|
177
|
+
const claimRunId = result.runId || run.analysisRunId;
|
|
178
|
+
let staging = null;
|
|
179
|
+
if (claimRunId && fs.existsSync(this.promotion.manifestPath(run.projectId, claimRunId))) staging = this.promotion.readManifest(run.projectId, claimRunId);
|
|
180
|
+
this.store.updateRun(run.runId, { phase: 'promoted', promotedAt: new Date().toISOString(), staging });
|
|
181
|
+
this.memories.capture(run);
|
|
182
|
+
this.notify(run, '知识已晋升,正在更新索引');
|
|
183
|
+
await this.index(run);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const message = error.code === 'AI_PROFILE_REQUIRED' ? '请配置后台默认模型。' : error.code === 'ANALYSIS_OUTPUT_MISSING' ? 'Agent 未生成知识维护文件,证据与原知识已保留,可重试。' : '知识维护失败,证据与原知识已保留。';
|
|
186
|
+
this.store.updateRun(run.runId, { state: 'failed', phase: 'failed', finishedAt: new Date().toISOString(), error: { code: error.code || 'ANALYSIS_FAILED', message, retryable: error.retryable === true } });
|
|
187
|
+
this.notify(run, '知识维护失败');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async index(run) {
|
|
191
|
+
const project = this.store.getProject(run.projectId);
|
|
192
|
+
try {
|
|
193
|
+
this.store.updateRun(run.runId, { phase: 'indexing', indexState: 'running' });
|
|
194
|
+
const response = await fetch(new URL('/api/sync', this.vectorUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project: project.vectorProjectId || project.projectId, sourceDir: project.knowledgePath }), signal: AbortSignal.timeout(120000) });
|
|
195
|
+
const payload = await response.json();
|
|
196
|
+
if (!response.ok || payload.ok !== true) throw new Error('INDEX_FAILED');
|
|
197
|
+
await this.store.updateRuntime(run.projectId, state => { state.index.dirty = false; state.index.status = 'ready'; });
|
|
198
|
+
this.store.updateRun(run.runId, { state: 'succeeded', phase: 'ready', indexState: 'succeeded', error: null, finishedAt: new Date().toISOString() });
|
|
199
|
+
this.notify(run, '知识已更新并可检索');
|
|
200
|
+
} catch {
|
|
201
|
+
this.store.updateRun(run.runId, { state: 'index-pending', phase: 'index-pending', indexState: 'failed', finishedAt: new Date().toISOString(), error: { code: 'INDEX_FAILED', message: '知识已更新,索引待修复。', retryable: true } });
|
|
202
|
+
this.notify(run, '知识已更新,索引待修复');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
syncSharedIndexes() { for (const scope of this.store.state.sharedScopes) this.indexSharedScope(scope).catch(() => {}); }
|
|
206
|
+
async indexSharedScope(scope) {
|
|
207
|
+
if (this.closed) return;
|
|
208
|
+
if (this.sharedIndexJobs.has(scope.id)) { this.sharedIndexDirty.add(scope.id); return this.sharedIndexJobs.get(scope.id); }
|
|
209
|
+
// Shared updates are small but may be numerous; serialize imports rather
|
|
210
|
+
// than burst model requests for every domain after startup.
|
|
211
|
+
const job = this.sharedIndexQueue.catch(() => {}).then(async () => {
|
|
212
|
+
if (this.closed || !this.store.state.sharedScopes.some(s => s.id === scope.id)) return;
|
|
213
|
+
let index;
|
|
214
|
+
try {
|
|
215
|
+
const files = require('./runtime/markdown-knowledge-indexer').listMarkdownFiles(scope.knowledgePath);
|
|
216
|
+
const digest = crypto.createHash('sha256');
|
|
217
|
+
for (const file of files.sort()) {
|
|
218
|
+
if (!this.layout.isPathInside(scope.knowledgePath, file, { realpath: true })) continue;
|
|
219
|
+
digest.update(path.relative(scope.knowledgePath, file)).update('\0').update(fs.readFileSync(file));
|
|
220
|
+
}
|
|
221
|
+
const fingerprint = digest.digest('hex');
|
|
222
|
+
const current = this.store.state.sharedScopes.find(s => s.id === scope.id);
|
|
223
|
+
if (!current) return;
|
|
224
|
+
if (current.index?.status === 'ready' && current.index.fingerprint === fingerprint) {
|
|
225
|
+
const health = await fetch(new URL('/api/projects', this.vectorUrl), { signal: AbortSignal.timeout(8000) });
|
|
226
|
+
const payload = await health.json();
|
|
227
|
+
if (health.ok && payload.projects?.some(p => p.name === `scope-${scope.kind}-${scope.id}` && p.hasIndex && this.layout.pathsEqual(p.sourceDir || '', scope.knowledgePath))) return;
|
|
228
|
+
}
|
|
229
|
+
const response = await fetch(new URL('/api/import', this.vectorUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectName: `scope-${scope.kind}-${scope.id}`, sourceDir: scope.knowledgePath, watch: false }), signal: AbortSignal.timeout(120000) });
|
|
230
|
+
const payload = await response.json();
|
|
231
|
+
if (!response.ok || payload.ok !== true) throw new Error('INDEX_FAILED');
|
|
232
|
+
index = { status: 'ready', fingerprint, updatedAt: new Date().toISOString() };
|
|
233
|
+
} catch { index = { status: 'pending', message: '共享知识已保存,索引待恢复。' }; }
|
|
234
|
+
// Configuration can replace projection objects while the import awaits.
|
|
235
|
+
const current = this.store.state.sharedScopes.find(s => s.id === scope.id);
|
|
236
|
+
if (!current) return;
|
|
237
|
+
const changed = current.index?.status !== index.status || current.index?.fingerprint !== index.fingerprint;
|
|
238
|
+
current.index = index;
|
|
239
|
+
this.store.save();
|
|
240
|
+
if (changed) this.emit('progress', this.store.addEvent({ type: 'scope.index', brainId: scope.brainId, scopeId: scope.id, summary: current.index.status === 'ready' ? '共享知识索引已更新' : current.index.message }));
|
|
241
|
+
}).finally(() => { this.sharedIndexJobs.delete(scope.id); if (this.sharedIndexDirty.delete(scope.id) && !this.closed) this.indexSharedScope(scope).catch(() => {}); });
|
|
242
|
+
this.sharedIndexQueue = job;
|
|
243
|
+
this.sharedIndexJobs.set(scope.id, job); return job;
|
|
244
|
+
}
|
|
245
|
+
retry(id) {
|
|
246
|
+
const run = this.store.getRun(id);
|
|
247
|
+
if (!run || !['failed', 'index-pending'].includes(run.state)) throw Object.assign(new Error('只有失败或索引待修复的运行可以重试。'), { status: 409 });
|
|
248
|
+
this.store.updateRun(id, { state: 'queued', phase: run.promotedAt ? 'index-pending' : 'queued', finishedAt: null, error: null, debugFailure: false });
|
|
249
|
+
this.pump();
|
|
250
|
+
return run;
|
|
251
|
+
}
|
|
252
|
+
async close() {
|
|
253
|
+
this.closed = true; clearInterval(this.poll);
|
|
254
|
+
this.bridge.close?.();
|
|
255
|
+
await this.analyzer.close?.();
|
|
256
|
+
// A consumer may still be persisting evidence or an ACK. Keep the writer
|
|
257
|
+
// lease until that work settles; the next process must never overlap it.
|
|
258
|
+
await Promise.all([this.drainJob, ...this.jobs, ...this.sharedIndexJobs.values()]);
|
|
259
|
+
this.removeAllListeners(); this.profiles.clear();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { EngineCoordinator };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { fork, execFile } = require('node:child_process');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const AtomicFile = require('./runtime/atomic-file');
|
|
7
|
+
const { canonicalRelativePath } = require('./runtime/knowledge-promotion');
|
|
8
|
+
const { RuntimeLayout } = require('./runtime/layout');
|
|
9
|
+
const { frameFrozenEvidence, requireAnalysisOutput } = require('./analysis-contract');
|
|
10
|
+
|
|
11
|
+
class HeadlessAnalyzer {
|
|
12
|
+
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
|
+
async runClaim(input) {
|
|
14
|
+
if (this.closed) throw Object.assign(new Error('分析服务正在关闭。'), { code: 'WORKER_STOPPED', retryable: true });
|
|
15
|
+
const profile = this.resolveProfile && await this.resolveProfile(input.projectId);
|
|
16
|
+
if (!profile || !profile.apiKey || !(profile.mainModel || profile.models?.default)) throw Object.assign(new Error('请先在工作台配置后台维护的默认模型。'), { code: 'AI_PROFILE_REQUIRED', status: 503, retryable: true });
|
|
17
|
+
const refs = [`commit:${input.claim.commitSha}`, `patch:${input.claim.patchHash}`, `conversation:${input.claim.conversationSnapshotHash}`, `retrieval:${input.claim.retrievalManifestHash}`];
|
|
18
|
+
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.`;
|
|
19
|
+
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 }] })}`;
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
const env = { ...process.env };
|
|
22
|
+
delete env.CLAUDECODE;
|
|
23
|
+
const child = fork(this.workerPath, [], { cwd: input.stagingPath, env, execArgv: [], detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
24
|
+
let requested = false, outcome, escalation, termination = Promise.resolve();
|
|
25
|
+
let exited;
|
|
26
|
+
const completion = new Promise(done => { exited = done; });
|
|
27
|
+
const stop = error => {
|
|
28
|
+
if (requested) return;
|
|
29
|
+
requested = true;
|
|
30
|
+
outcome = error;
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
if (child.connected) child.send({ type: 'cancel' }, () => {});
|
|
33
|
+
escalation = setTimeout(() => {
|
|
34
|
+
// Windows SIGTERM kills the worker without allowing SDK cleanup.
|
|
35
|
+
// Abort cooperatively first; escalation must kill its whole tree.
|
|
36
|
+
if (process.platform === 'win32') termination = new Promise(done => execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => done()));
|
|
37
|
+
else { try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } }
|
|
38
|
+
}, 2000);
|
|
39
|
+
escalation.unref();
|
|
40
|
+
};
|
|
41
|
+
this.children.set(child, { stop, completion });
|
|
42
|
+
const timer = setTimeout(() => stop(Object.assign(new Error('知识分析超时。'), { code: 'MODEL_TIMEOUT', retryable: true })), this.timeoutMs);
|
|
43
|
+
child.on('message', message => {
|
|
44
|
+
if (message?.type === 'complete') stop();
|
|
45
|
+
else if (message?.type === 'failure') stop(Object.assign(new Error('后台 Agent 分析失败,请检查模型配置。'), { code: ['AI_RUNTIME_REQUIRED', 'ANALYSIS_OUTPUT_MISSING'].includes(message.code) ? message.code : 'MODEL_FAILED', retryable: true }));
|
|
46
|
+
});
|
|
47
|
+
child.on('error', () => stop(Object.assign(new Error('无法启动后台分析进程。'), { code: 'WORKER_START_FAILED', retryable: true })));
|
|
48
|
+
// A completion message is not an exit: retain the directory lock until
|
|
49
|
+
// the worker has actually stopped writing, including timeout/shutdown.
|
|
50
|
+
child.on('close', async () => {
|
|
51
|
+
clearTimeout(timer); clearTimeout(escalation); await termination; this.children.delete(child); exited();
|
|
52
|
+
if (!requested) outcome = Object.assign(new Error('后台分析进程意外退出。'), { code: 'WORKER_EXITED', retryable: true });
|
|
53
|
+
if (outcome) reject(outcome); else resolve();
|
|
54
|
+
});
|
|
55
|
+
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.';
|
|
56
|
+
const languageContract = input.config?.knowledgeLanguage === 'en-US' ? '\nOUTPUT LANGUAGE: English. Preserve identifiers and verbatim evidence quotations.' : '\n输出语言:简体中文。知识正文、operations[].reason 与候选记忆用简体中文;代码标识、路径、哈希和原文证据引用保持不变。';
|
|
57
|
+
child.send({ profile, 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 })); });
|
|
58
|
+
});
|
|
59
|
+
requireAnalysisOutput(input.manifestPath);
|
|
60
|
+
const manifest = AtomicFile.readJsonStrict(input.manifestPath);
|
|
61
|
+
const layout = new RuntimeLayout(input.stagingPath);
|
|
62
|
+
for (const operation of manifest.operations || []) {
|
|
63
|
+
if (operation.operation === 'delete' || operation.sha256 != null) continue;
|
|
64
|
+
const relative = canonicalRelativePath(operation.path);
|
|
65
|
+
const file = path.join(input.stagingPath, 'files', ...relative.split('/'));
|
|
66
|
+
if (!layout.isPathInside(input.stagingPath, file, { realpath: true }) || fs.lstatSync(file).isSymbolicLink()) throw Object.assign(new Error('分析产物越界。'), { code: 'PATH_OUTSIDE_ROOT' });
|
|
67
|
+
operation.sha256 = `sha256:${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`;
|
|
68
|
+
}
|
|
69
|
+
AtomicFile.writeJsonAtomic(input.manifestPath, manifest);
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
async close() { this.closed = true; const running = [...this.children.values()]; for (const job of running) job.stop(Object.assign(new Error('分析服务正在关闭。'), { code: 'WORKER_STOPPED', retryable: true })); await Promise.all(running.map(job => job.completion)); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { HeadlessAnalyzer };
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { RuntimeLayout } = require('./runtime/layout');
|
|
7
|
+
const { validateProjectId, DomainError } = require('./runtime/contracts');
|
|
8
|
+
|
|
9
|
+
class MemoryService {
|
|
10
|
+
constructor(store, options = {}) { this.store = store; this.layout = new RuntimeLayout(store.dataDir); this.onMaterialized = options.onMaterialized; }
|
|
11
|
+
async configure(scopes) {
|
|
12
|
+
if (!Array.isArray(scopes)) throw new DomainError('INVALID_ARGUMENT', '共享范围必须是数组。');
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
const projected = scopes.map(scope => {
|
|
15
|
+
validateProjectId(scope.id); validateProjectId(scope.brainId);
|
|
16
|
+
if (scope.domainId) validateProjectId(scope.domainId);
|
|
17
|
+
if (ids.has(scope.id) || !['brain', 'domain'].includes(scope.kind) || !path.isAbsolute(scope.knowledgePath || '')) throw new DomainError('INVALID_ARGUMENT', '共享范围绑定无效。');
|
|
18
|
+
ids.add(scope.id);
|
|
19
|
+
const index = this.store.state.sharedScopes.find(s => s.id === scope.id)?.index;
|
|
20
|
+
return { id: scope.id, kind: scope.kind, brainId: scope.brainId, domainId: scope.domainId || null, name: String(scope.name || scope.id), knowledgePath: path.resolve(scope.knowledgePath), ...(index ? { index } : {}) };
|
|
21
|
+
});
|
|
22
|
+
const overlaps = (a, b) => this.layout.isPathInside(a, b, { realpath: true }) || this.layout.isPathInside(b, a, { realpath: true });
|
|
23
|
+
for (const scope of projected) {
|
|
24
|
+
if (this.store.listProjects().some(p => overlaps(p.knowledgePath, scope.knowledgePath) || overlaps(p.repoPath, scope.knowledgePath)) || projected.some(s => s.id !== scope.id && overlaps(s.knowledgePath, scope.knowledgePath))) throw new DomainError('PATH_OUTSIDE_ROOT', '共享目录与其他知识或代码目录冲突。', { status: 409 });
|
|
25
|
+
const prior = this.store.state.sharedScopes.find(s => s.id === scope.id);
|
|
26
|
+
if (prior && !this.layout.pathsEqual(prior.knowledgePath, scope.knowledgePath)) throw new DomainError('IMMUTABLE_FIELD', '共享目录已绑定,不能隐式移动知识。', { status: 409 });
|
|
27
|
+
}
|
|
28
|
+
// The shell remains the registry owner; this is a whitelisted projection.
|
|
29
|
+
this.store.state.sharedScopes = projected; this.store.save();
|
|
30
|
+
for (const scope of projected) fs.mkdirSync(scope.knowledgePath, { recursive: true });
|
|
31
|
+
await this.recover();
|
|
32
|
+
return projected;
|
|
33
|
+
}
|
|
34
|
+
list({ brainId, status } = {}) { return this.store.state.memories.filter(m => (!brainId || m.brainId === brainId) && (!status || m.status === status)); }
|
|
35
|
+
capture(run) {
|
|
36
|
+
const project = this.store.getProject(run.projectId);
|
|
37
|
+
if (!project || !run.promotedAt) return;
|
|
38
|
+
const candidates = Array.isArray(run.staging?.memoryCandidates) ? run.staging.memoryCandidates : [];
|
|
39
|
+
for (const raw of candidates.slice(0, 20)) {
|
|
40
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
41
|
+
const text = String(raw.text || '').trim();
|
|
42
|
+
const quote = String(raw.quote || '').trim();
|
|
43
|
+
const entryId = String(raw.entryId || '');
|
|
44
|
+
const file = path.resolve(project.knowledgePath, entryId);
|
|
45
|
+
if (!text || text.length > 2000 || quote.length < 8 || !run.staging.operations?.some(op => op.operation !== 'delete' && op.path === entryId) || !this.layout.isPathInside(project.knowledgePath, file, { realpath: true }) || !fs.existsSync(file) || !fs.readFileSync(file, 'utf8').includes(quote)) continue;
|
|
46
|
+
const fingerprint = crypto.createHash('sha256').update(`${project.brainId || 'brain-default'}:${text.toLowerCase().replace(/\s+/g, ' ')}`).digest('hex');
|
|
47
|
+
if (this.store.state.memories.some(m => m.fingerprint === fingerprint)) continue;
|
|
48
|
+
this.store.state.memories.push({ id: `memory-${crypto.randomUUID()}`, fingerprint, brainId: project.brainId || 'brain-default', domainId: project.domainId || null, text, status: 'candidate', evidence: { projectId: project.projectId, commitSha: run.commitSha, runId: run.runId, entryId, quote }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
|
49
|
+
}
|
|
50
|
+
this.store.save();
|
|
51
|
+
}
|
|
52
|
+
async update(id, input) {
|
|
53
|
+
return AtomicFile.withFileLock(this.layout.getRuntimePath('locks', 'memories.lock'), async () => {
|
|
54
|
+
const memory = this.store.state.memories.find(m => m.id === id);
|
|
55
|
+
if (!memory) throw new DomainError('PROJECT_NOT_FOUND', '记忆不存在。', { status: 404 });
|
|
56
|
+
if (input.text !== undefined && (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 2000)) throw new DomainError('INVALID_ARGUMENT', '记忆内容必须为 1–2000 个字符。');
|
|
57
|
+
if (input.status !== undefined && !['confirmed', 'ignored'].includes(input.status)) throw new DomainError('INVALID_ARGUMENT', '记忆状态无效。');
|
|
58
|
+
if (memory.status === 'ignored') throw new DomainError('INVALID_ARGUMENT', '该候选已忽略。', { status: 409 });
|
|
59
|
+
if (input.status === 'confirmed' && !this.store.state.sharedScopes.some(s => s.kind === 'brain' && s.brainId === memory.brainId)) throw new DomainError('INVALID_ARGUMENT', '请先绑定智脑共享目录。', { status: 409 });
|
|
60
|
+
Object.assign(memory, { text: input.text?.trim() || memory.text, status: input.status || memory.status, updatedAt: new Date().toISOString() });
|
|
61
|
+
// Persist confirmation before writing. Retrieval verifies the materialized
|
|
62
|
+
// file against this state, so a crash cannot inject unconfirmed content.
|
|
63
|
+
this.store.save();
|
|
64
|
+
await this.recover();
|
|
65
|
+
return memory;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
content(brainId) {
|
|
69
|
+
return '# 已确认的长期记忆\n\n' + this.list({ brainId, status: 'confirmed' }).map(m => `## ${m.id}\n\n${m.text}\n\n来源:${m.evidence.projectId} · ${m.evidence.commitSha} · ${m.evidence.entryId}\n`).join('\n');
|
|
70
|
+
}
|
|
71
|
+
file(scope) { return path.join(scope.knowledgePath, 'memories', 'confirmed.md'); }
|
|
72
|
+
async recover() {
|
|
73
|
+
for (const scope of this.store.state.sharedScopes.filter(s => s.kind === 'brain')) {
|
|
74
|
+
const file = this.file(scope);
|
|
75
|
+
if (!this.layout.isPathInside(scope.knowledgePath, file, { realpath: true }) || fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new DomainError('PATH_OUTSIDE_ROOT', '记忆文件目录越界。', { status: 403 });
|
|
76
|
+
const content = this.content(scope.brainId);
|
|
77
|
+
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== content) {
|
|
78
|
+
AtomicFile.writeFileAtomic(file, content);
|
|
79
|
+
await this.onMaterialized?.(scope);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
validFile(scope) { const file = this.file(scope); return fs.existsSync(file) && fs.readFileSync(file, 'utf8') === this.content(scope.brainId); }
|
|
84
|
+
}
|
|
85
|
+
module.exports = { MemoryService };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { KnowledgeRetrievalService } = require('./runtime/knowledge-retrieval-service');
|
|
5
|
+
const { listMarkdownFiles } = require('./runtime/markdown-knowledge-indexer');
|
|
6
|
+
const { DomainError } = require('./runtime/contracts');
|
|
7
|
+
|
|
8
|
+
class EngineRetrieval extends KnowledgeRetrievalService {
|
|
9
|
+
constructor(options) { super(options); this.store = options.store; this.memories = options.memories; this.vectorUrl = options.vectorUrl || 'http://127.0.0.1:8787'; }
|
|
10
|
+
resolve(input = {}) {
|
|
11
|
+
let project = this.store.getProject(input.projectId || input.projectSlug || input.project);
|
|
12
|
+
if (!project && (input.repoPath || input.path)) project = this.store.listProjects().find(p => this.layout.pathsEqual(p.repoPath, input.repoPath || input.path));
|
|
13
|
+
if (!project || project.enabled === false) throw new DomainError('PROJECT_NOT_FOUND', '项目尚未登记。', { status: 404 });
|
|
14
|
+
return project;
|
|
15
|
+
}
|
|
16
|
+
project(input = {}) {
|
|
17
|
+
if (input.project && input.project.projectId) return input.project;
|
|
18
|
+
const p = this.resolve(input);
|
|
19
|
+
return { projectId: p.projectId, projectSlug: p.projectId, primarySpaceId: `project:${p.projectId}`, knowledgePath: p.knowledgePath, relatedProjectIds: p.relatedProjectIds || [], index: this.store.readRuntime(p.projectId).index };
|
|
20
|
+
}
|
|
21
|
+
scopes(project) {
|
|
22
|
+
const registration = this.store.getProject(project.projectId);
|
|
23
|
+
return [...super.scopes(project), ...this.sharedScopes(registration, 'domain')];
|
|
24
|
+
}
|
|
25
|
+
sharedScopes(registration, range) {
|
|
26
|
+
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 }));
|
|
27
|
+
}
|
|
28
|
+
scanCurrent(scopes) {
|
|
29
|
+
const ordinary = scopes.filter(s => this.store.getProject(s.projectId));
|
|
30
|
+
const shared = scopes.filter(s => !this.store.getProject(s.projectId));
|
|
31
|
+
const result = super.scanCurrent(ordinary);
|
|
32
|
+
if (!shared.length) return result;
|
|
33
|
+
const scanner = new KnowledgeRetrievalService({ layout: this.layout, projectStore: { readState: () => ({ index: {} }) } });
|
|
34
|
+
const extra = scanner.scanCurrent(shared);
|
|
35
|
+
for (const [id, candidate] of extra.candidates) {
|
|
36
|
+
const scope = this.store.state.sharedScopes.find(s => s.id === candidate.projectId);
|
|
37
|
+
if (candidate.entryId.startsWith('memories/') && (scope.kind !== 'brain' || candidate.entryId !== 'memories/confirmed.md' || !this.memories?.validFile(scope))) continue;
|
|
38
|
+
result.candidates.set(id, candidate);
|
|
39
|
+
}
|
|
40
|
+
result.scopeHealth.push(...extra.scopeHealth);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
async recallIndex(scopes, query, current, health) {
|
|
44
|
+
if (!query) { health.index = 'unavailable'; return; }
|
|
45
|
+
const mappings = scopes.map(s => ({ scope: s, registration: this.store.getProject(s.projectId) || (() => { const shared = this.store.state.sharedScopes.find(p => p.id === s.projectId); return shared ? { vectorProjectId: `scope-${shared.kind}-${shared.id}` } : null; })() })).filter(m => m.registration);
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL('/api/search', this.vectorUrl);
|
|
48
|
+
url.searchParams.set('q', query);
|
|
49
|
+
url.searchParams.set('projects', mappings.map(m => m.registration.vectorProjectId || m.scope.projectId).join(','));
|
|
50
|
+
url.searchParams.set('maxResults', String(this.candidateLimit));
|
|
51
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
52
|
+
if (!response.ok) throw new Error('VECTOR_UNAVAILABLE');
|
|
53
|
+
const payload = await response.json();
|
|
54
|
+
if (payload.errors && payload.errors.length) throw new Error('VECTOR_QUERY_FAILED');
|
|
55
|
+
// Vectors only contribute a rank. Displayed text/hash always comes from
|
|
56
|
+
// current canonical files, so deleted or stale index bodies cannot leak.
|
|
57
|
+
for (const row of payload.results || []) {
|
|
58
|
+
const mapping = mappings.find(m => (m.registration.vectorProjectId || m.scope.projectId) === row.project);
|
|
59
|
+
if (!mapping) continue;
|
|
60
|
+
const uri = row.uri || row.documentUri || '';
|
|
61
|
+
const entry = path.isAbsolute(uri) ? path.relative(mapping.scope.knowledgePath, uri).replace(/\\/g, '/') : String(uri).replace(/\\/g, '/');
|
|
62
|
+
for (const candidate of current.values()) {
|
|
63
|
+
if (candidate.spaceId === mapping.scope.spaceId && candidate.entryId === entry) {
|
|
64
|
+
candidate.indexScore = Math.max(candidate.indexScore || 0, Math.max(0, Number(row.score) || 0));
|
|
65
|
+
candidate.indexChannels = ['vector-hub'];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
health.index = payload.projectsSkipped?.length ? 'partial' : 'ok';
|
|
70
|
+
} catch { health.index = 'degraded'; health.reason = 'vector-hub-unavailable'; }
|
|
71
|
+
}
|
|
72
|
+
async search(input = {}) {
|
|
73
|
+
const registration = this.resolve(input);
|
|
74
|
+
const project = this.project({ projectId: registration.projectId });
|
|
75
|
+
let scopes = [{ projectId: project.projectId, spaceId: project.primarySpaceId, knowledgePath: project.knowledgePath, weight: 1, reason: 'primary' }];
|
|
76
|
+
if (input.scope === 'domain' || input.scope === 'brain') {
|
|
77
|
+
scopes = this.store.listProjects().filter(p => p.enabled !== false && p.brainId === registration.brainId
|
|
78
|
+
&& (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 }));
|
|
79
|
+
scopes.push(...this.sharedScopes(registration, input.scope));
|
|
80
|
+
}
|
|
81
|
+
const result = await super.search({ ...input, project, scopes });
|
|
82
|
+
result.results = result.results.map(row => {
|
|
83
|
+
const run = this.store.listRuns(row.scope_project_id).find(r => r.promotedAt && r.staging?.operations?.some(op => op.path === row.entry_id));
|
|
84
|
+
const source = this.store.getProject(row.scope_project_id) || this.store.state.sharedScopes.find(s => s.id === row.scope_project_id);
|
|
85
|
+
return { ...row, projectName: source?.name || row.scope_project_id, path: row.entry_id, snippet: row.chunk_text, score: row.relevance_score, projectId: row.scope_project_id, commitSha: run?.commitSha || null };
|
|
86
|
+
});
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
async ask(input) {
|
|
90
|
+
const result = await this.search(input);
|
|
91
|
+
const citations = result.results.slice(0, 5).map((r, i) => ({ index: i + 1, ref: i + 1, projectId: r.scope_project_id, projectName: r.projectName, spaceId: r.space_id, commitSha: r.commitSha, score: r.relevance_score, entryId: r.entry_id, path: r.entry_id, title: r.title, text: r.chunk_text }));
|
|
92
|
+
return { ...result, answer: citations.length ? citations.map(r => `${r.index}. ${r.title}: ${r.text}`).join('\n') : '知识库中没有找到相关内容。', citations };
|
|
93
|
+
}
|
|
94
|
+
async get(input) {
|
|
95
|
+
const registration = this.resolve(input);
|
|
96
|
+
const scope = input.spaceId && this.store.state.sharedScopes.find(s => `${s.kind}:${s.id}` === input.spaceId && s.brainId === registration.brainId);
|
|
97
|
+
if (input.spaceId && !scope && input.spaceId !== `project:${registration.projectId}`) throw new DomainError('PROJECT_NOT_FOUND', '知识范围与项目归属不匹配。', { status: 404 });
|
|
98
|
+
const project = scope ? { projectId: scope.id, knowledgePath: scope.knowledgePath } : registration;
|
|
99
|
+
const entryId = String(input.entryId || input.entry || '').replace(/\\/g, '/');
|
|
100
|
+
const target = path.resolve(project.knowledgePath, entryId);
|
|
101
|
+
if (!entryId || !/\.md$/i.test(target) || !this.layout.isPathInside(project.knowledgePath, target, { realpath: true })) throw new DomainError('PATH_OUTSIDE_ROOT', '知识文件越界。', { status: 403 });
|
|
102
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile() || fs.lstatSync(target).isSymbolicLink()) throw new DomainError('PROJECT_NOT_FOUND', '知识文件不存在。', { status: 404 });
|
|
103
|
+
if (scope && entryId.startsWith('memories/') && (entryId !== 'memories/confirmed.md' || !this.memories?.validFile(scope))) throw new DomainError('PROJECT_NOT_FOUND', '记忆尚未完成确认写入。', { status: 404 });
|
|
104
|
+
return { ok: true, projectId: project.projectId, projectSlug: project.projectId, entryId, source: 'markdown', chunks: [{ chunk_text: fs.readFileSync(target, 'utf8'), entry_id: entryId, space_id: scope ? `${scope.kind}:${scope.id}` : `project:${project.projectId}` }] };
|
|
105
|
+
}
|
|
106
|
+
async history(input) {
|
|
107
|
+
const project = this.resolve(input);
|
|
108
|
+
const results = listMarkdownFiles(project.knowledgePath).filter(file => this.layout.isPathInside(path.join(project.knowledgePath, 'changes'), file, { realpath: true })).map(file => {
|
|
109
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
110
|
+
return { entry_id: path.relative(project.knowledgePath, file).replace(/\\/g, '/'), title: (/^#\s+(.+)$/m.exec(content) || [null, path.basename(file)])[1], chunk_text: content.slice(0, 900), updated_at: fs.statSync(file).mtime.toISOString() };
|
|
111
|
+
}).sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, Math.min(100, Math.max(1, Number(input.limit) || 20)));
|
|
112
|
+
return { ok: true, projectId: project.projectId, projectSlug: project.projectId, backend: 'markdown', source: 'markdown', results };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { EngineRetrieval };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 迁入的知识运行逻辑
|
|
2
|
+
|
|
3
|
+
来源:project-knowledge-base/_site/lib,基线 0c62f528f66794ca5de055a137f632dfc5c57bc3。
|
|
4
|
+
|
|
5
|
+
复用提交证据冻结、会话边界绑定、检索事实校验、提示词、安全工具策略、晋升事务及崩溃恢复。只迁入知识运行职责,没有复制外壳的项目注册表、SettingsStore、凭据或旧 LanceDB/embedding 写入能力。
|
|
6
|
+
|
|
7
|
+
构造函数改为注入 layout 与只读项目投影/运行状态适配器;内容哈希提取为零依赖文件。项目和配置的所有权仍在外壳,引擎本地登记只是运行所需投影。向量召回接 vector-hub,不启动第二份索引。
|