@sanqianx/project-knowledge 5.0.18 → 5.0.19
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +9 -0
- package/_modules/knowledge-engine/src/analysis-contract.js +4 -3
- package/_modules/knowledge-engine/src/coordinator.js +1 -0
- package/_modules/knowledge-engine/src/memories.js +10 -5
- package/_modules/knowledge-engine/src/memory-eligibility.js +74 -0
- package/_modules/knowledge-engine/src/retrieval-ranking.js +19 -1
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +1 -0
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +12 -9
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +2 -0
- package/_site/_test/candidate-bin-content-test.js +35 -0
- package/_site/_test/candidate-product-assets-test.js +13 -0
- package/_site/_test/conversation-origin-test.js +15 -0
- package/_site/_test/fixtures/retrieval-gold-queries.json +32 -0
- package/_site/_test/module-index-proxy-test.js +33 -0
- package/_site/_test/module-terminal-restart-test.js +63 -0
- package/_site/_test/registry-events-test.js +38 -0
- package/_site/_test/workbench-context-receipts-test.js +22 -0
- package/_site/lib/commit-conversation-binder.js +1 -0
- package/_site/lib/conversation-store.js +2 -0
- package/_site/lib/engine-connection.js +4 -1
- package/_site/lib/live-gateway.js +3 -1
- package/_site/lib/module-bridge.js +19 -10
- package/_site/lib/registry-events.js +16 -0
- package/_site/lib/server-app.js +10 -1
- package/_site/scripts/pack-module-candidates.js +21 -4
- package/_site/scripts/verify-retrieval-gold.js +83 -0
- package/docs/leader-glm-workbench-playbook.md +4 -1
- package/docs/release-5.0.19-acceptance.md +25 -0
- package/module-runtime-manifest.json +104 -102
- package/package.json +109 -108
- 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/ui/product.js +29 -4
- package/vendor-manifest.json +9 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.0.19] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
- 工作台展示来自真实 SDK 事件的后台任务、工具活动及完成结果;主回复结束后可继续输入,后台任务继续归属原会话,切换和刷新可恢复。
|
|
6
|
+
- 移除外壳列表定时轮询,复用实时连接推送摘要变化并增量更新侧栏;草稿按服务入口、项目和会话隔离,保护发送失败、迟到创建和附件读取的原始归属。
|
|
7
|
+
- 修复新会话创建时的旧会话误用、追加消息 409、汇总回复重复展示,以及重启读取项目列表失败导致的重复登记与接线缓存问题。
|
|
8
|
+
- 候选内容清单按 npm 实际安装行为规范 CLI shebang 的换行符,避免 Windows 工作树打包后产生错误的内容哈希告警;普通文件与脚本正文继续逐字节校验。
|
|
9
|
+
- 自动长期记忆仅接受具有冻结人类原句与来源证据的跨项目长期规范;代理派单、单次修复和项目版本事实保留为项目知识。旧候选不自动改写。
|
|
10
|
+
- 纳入 Workbench 0.13.2-rc.21 和经过真实向量回归的引擎修复;固定 30 条检索查询全部通过,109 处引用一致,真实修复提交完成分析、晋升、索引并由下一轮 GLM 实际采用。验收范围见 5.0.19 记录。
|
|
11
|
+
|
|
3
12
|
## [5.0.17] - 2026-10-15
|
|
4
13
|
|
|
5
14
|
- 修复网页首屏被慢模块拖住、开始工作 Agent 探测同步阻塞与返回页面状态不刷新的问题;独立读取、明确失败状态和缓存保留,iframe 不重建。
|
|
@@ -14,10 +14,11 @@ function frameFrozenEvidence(prompt) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function memoryCandidateContract(entryId) {
|
|
17
|
-
return `\nMEMORY CANDIDATE CONTRACT:
|
|
18
|
-
Shape: [{"text":"the
|
|
17
|
+
return `\nMEMORY CANDIDATE CONTRACT: An automatic long-term memory may ONLY be the owner's own explicit, durable, cross-project statement: a global convention or a domain norm the owner stated in a frozen user event whose inputOrigin is "human". Assistant replies, generated knowledge documents, code, test fixtures and agent-delegation prompts are never owner statements, even when they contain preference-like wording. Project architecture, version facts, one-off fixes and temporary task discipline belong in project knowledge only: do not propose them as candidates. With no such owner statement, omit candidates entirely.
|
|
18
|
+
Shape: [{"text":"the owner's verbatim durable statement or a contiguous excerpt of quote; never a paraphrase","entryId":${JSON.stringify(entryId)},"quote":"the owner's exact original sentence, copied verbatim into that written document","sourceEventId":"the eventId of that frozen human user event","sourceHash":"that event's contentHash","scopeType":"domain" or "global","eligibility":{"durable":true,"crossProject":true,"reason":"why this statement is long-term rather than one-off, grounded in the owner's wording","scopeReason":"why it applies across the whole domain or all projects"}}].
|
|
19
|
+
The engine independently verifies every field against the frozen conversation snapshot of this claim: sourceEventId must be one of its user events with inputOrigin "human"; sourceHash must equal that event's contentHash; quote must appear verbatim both in that event's content and in the entryId document; eligibility must be fully attested with non-empty reasons; scopeType must be "domain" or "global" — automatic candidates have no project scope. A failed check only rejects the candidate, never the knowledge promotion, so attest honestly: self-reported labels cannot substitute for the frozen source, and one-time instructions, project-local conventions or delegated task orders must not be proposed.
|
|
19
20
|
entryId MUST equal an operations[].path for a create/replace in THIS manifest. It is the final knowledge-relative .md document path, NOT a candidate identifier, NOT an absolute path, NOT files/<path>. For example, when the quoted text is in files/${entryId}, entryId is ${JSON.stringify(entryId)}. If another written document contains the quote, use that document's exact operations[].path instead.
|
|
20
|
-
quote must appear verbatim in that document and have at least 8 characters; text is 1–2000 characters. Check the document
|
|
21
|
+
quote must appear verbatim in that document and have at least 8 characters; text is 1–2000 characters. Check the document, the entryId and the frozen event before submitting the manifest. Prefer "domain" when the owner's wording explicitly spans the current domain and "global" when it spans all projects; do not invent scope target IDs. Candidates remain unconfirmed until the user reviews content and scope.`;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function requireAnalysisOutput(manifestPath) {
|
|
@@ -41,6 +41,7 @@ class EngineCoordinator extends EventEmitter {
|
|
|
41
41
|
this.sharedIndexDirty = new Set();
|
|
42
42
|
this.sharedIndexQueue = Promise.resolve();
|
|
43
43
|
this.memories = new MemoryService(store, {
|
|
44
|
+
readSnapshot: (projectId, commitSha) => this.conversations.readSnapshot(projectId, commitSha),
|
|
44
45
|
getIndexStatus: scope => scope && this.memoryIndexUpdates.has(scope.id) && this.sharedIndexJobs.has(scope.id) ? 'pending' : null,
|
|
45
46
|
onMaterialized: scope => { this.memoryIndexUpdates.add(scope.id); this.indexSharedScope(scope).catch(() => {}); },
|
|
46
47
|
onSyncChange: (scope, code) => {
|
|
@@ -5,6 +5,7 @@ const crypto = require('node:crypto');
|
|
|
5
5
|
const AtomicFile = require('./runtime/atomic-file');
|
|
6
6
|
const { RuntimeLayout } = require('./runtime/layout');
|
|
7
7
|
const { validateProjectId, DomainError } = require('./runtime/contracts');
|
|
8
|
+
const { MemoryEligibility } = require('./memory-eligibility');
|
|
8
9
|
|
|
9
10
|
const TYPES = ['global', 'domain', 'project'];
|
|
10
11
|
const normalized = text => String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
@@ -15,6 +16,7 @@ class MemoryService {
|
|
|
15
16
|
this.store = store; this.layout = new RuntimeLayout(store.dataDir); this.onMaterialized = options.onMaterialized;
|
|
16
17
|
this.syncErrors = new Map(); this.closed = false; this.onSyncChange = options.onSyncChange; this.writes = new Set();
|
|
17
18
|
this.getIndexStatus = options.getIndexStatus;
|
|
19
|
+
this.eligibility = new MemoryEligibility({ readSnapshot: options.readSnapshot || null });
|
|
18
20
|
}
|
|
19
21
|
lock() { return this.layout.getRuntimePath('locks', 'memories.lock'); }
|
|
20
22
|
scopeOf(memory) { return { type: memory.scopeType || 'global', id: memory.scopeId || memory.brainId || 'brain-default' }; }
|
|
@@ -101,6 +103,7 @@ class MemoryService {
|
|
|
101
103
|
const project = this.store.getProject(run.projectId);
|
|
102
104
|
if (!project || !run.promotedAt) return report;
|
|
103
105
|
const candidates = Array.isArray(run.staging?.memoryCandidates) ? run.staging.memoryCandidates : [];
|
|
106
|
+
const snapshot = this.eligibility.resolve(run);
|
|
104
107
|
const before = this.store.state.memories, next = [...before];
|
|
105
108
|
for (const [index, raw] of candidates.slice(0, 20).entries()) {
|
|
106
109
|
const reject = code => report.rejected.push({ index, code });
|
|
@@ -114,12 +117,14 @@ class MemoryService {
|
|
|
114
117
|
if (!this.layout.isPathInside(project.knowledgePath, file, { realpath: true })) { reject('PATH_OUTSIDE_ROOT'); continue; }
|
|
115
118
|
try { if (!fs.readFileSync(file, 'utf8').includes(quote)) { reject('QUOTE_MISMATCH'); continue; } }
|
|
116
119
|
catch { reject('EVIDENCE_UNREADABLE'); continue; }
|
|
120
|
+
const provenance = this.eligibility.verify(raw, snapshot, project);
|
|
121
|
+
if (!provenance.ok) { reject(provenance.code); continue; }
|
|
117
122
|
const brainId = project.brainId || 'brain-default';
|
|
118
|
-
const scopeType = raw.scopeType
|
|
119
|
-
const scopeId = scopeType === 'global' ? brainId :
|
|
123
|
+
const scopeType = raw.scopeType;
|
|
124
|
+
const scopeId = scopeType === 'global' ? brainId : project.domainId;
|
|
120
125
|
const key = fingerprint(brainId, scopeType, scopeId, text);
|
|
121
|
-
if (next.some(m => m.captureFingerprint === key || fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key || !m.scopeType && m.evidence
|
|
122
|
-
next.push({ id: `memory-${crypto.randomUUID()}`, fingerprint: key, captureFingerprint: key, brainId, domainId: project.domainId || null, scopeType, scopeId, text, originalText: text, status: 'candidate', evidence: { projectId: project.projectId, commitSha: run.commitSha, runId: run.runId, entryId, quote }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
|
126
|
+
if (next.some(m => m.captureFingerprint === key || fingerprint(m.brainId || 'brain-default', this.scopeOf(m).type, this.scopeOf(m).id, m.text) === key || !m.scopeType && m.evidence?.projectId === project.projectId && normalized(m.originalText || m.text) === normalized(text))) { report.deduplicated++; continue; }
|
|
127
|
+
next.push({ id: `memory-${crypto.randomUUID()}`, fingerprint: key, captureFingerprint: key, brainId, domainId: project.domainId || null, scopeType, scopeId, text, originalText: text, status: 'candidate', sourceEventId: provenance.sourceEventId, sourceHash: provenance.sourceHash, eligibility: provenance.eligibility, evidence: { projectId: project.projectId, commitSha: run.commitSha, runId: run.runId, entryId, quote }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
|
123
128
|
report.accepted++;
|
|
124
129
|
}
|
|
125
130
|
if (report.accepted) {
|
|
@@ -183,7 +188,7 @@ class MemoryService {
|
|
|
183
188
|
// Preserve legacy global bytes until the user actually edits that record.
|
|
184
189
|
const scopeText = m.scopeType ? `适用范围:${{ global: '全局', domain: '领域', project: '项目' }[scope.type]} · ${scope.id}\n\n` : '';
|
|
185
190
|
const text = m.scopeType ? m.text.replace(/^\s*(#{1,6})\s/gm, '\\$1 ') : m.text;
|
|
186
|
-
const source = m.sourceType === 'manual' ? '手动添加(用户确认)' : `${m.evidence.projectId} · ${m.evidence.commitSha} · ${m.evidence.entryId}
|
|
191
|
+
const source = m.sourceType === 'manual' ? '手动添加(用户确认)' : m.evidence ? `${m.evidence.projectId} · ${m.evidence.commitSha} · ${m.evidence.entryId}` : '历史记录(来源未记录)';
|
|
187
192
|
return `## ${m.id}\n\n${scopeText}${text}\n\n来源:${source}\n`;
|
|
188
193
|
}).join('\n');
|
|
189
194
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Hard provenance gate for automatic long-term memory admission. The
|
|
4
|
+
// maintenance model owns the semantic judgment (its prompt contract demands
|
|
5
|
+
// durable, cross-project owner preferences); this gate never re-classifies
|
|
6
|
+
// text and never trusts model self-reports over the frozen snapshot of the
|
|
7
|
+
// claim being captured.
|
|
8
|
+
const SHARED_SCOPES = new Set(['domain', 'global']);
|
|
9
|
+
|
|
10
|
+
function indexUserEvents(snapshot) {
|
|
11
|
+
const events = new Map();
|
|
12
|
+
for (const turn of snapshot?.turns || []) {
|
|
13
|
+
for (const event of turn.userEvents || []) {
|
|
14
|
+
if (event?.eventId) events.set(String(event.eventId), event);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return events;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function conversationReferences(run) {
|
|
21
|
+
const references = new Set();
|
|
22
|
+
for (const operation of run?.staging?.operations || []) {
|
|
23
|
+
for (const reference of operation.evidenceReferences || []) {
|
|
24
|
+
if (typeof reference === 'string' && reference.startsWith('conversation:')) references.add(reference.slice('conversation:'.length));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return references;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class MemoryEligibility {
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.readSnapshot = typeof options.readSnapshot === 'function' ? options.readSnapshot : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The snapshot must exist AND be bound to this run's promoted operations
|
|
36
|
+
// through a `conversation:<snapshotHash>` evidence reference, so a foreign
|
|
37
|
+
// or later-frozen conversation can never vouch for this claim.
|
|
38
|
+
resolve(run) {
|
|
39
|
+
if (!this.readSnapshot) return null;
|
|
40
|
+
const references = conversationReferences(run);
|
|
41
|
+
if (!references.size) return null;
|
|
42
|
+
let snapshot = null;
|
|
43
|
+
try { snapshot = this.readSnapshot(run.projectId, run.commitSha); } catch { return null; }
|
|
44
|
+
return snapshot && references.has(snapshot.snapshotHash) ? snapshot : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
verify(candidate, snapshot, project) {
|
|
48
|
+
if (!snapshot) return { ok: false, code: 'SNAPSHOT_UNAVAILABLE' };
|
|
49
|
+
const sourceEventId = String(candidate.sourceEventId || '');
|
|
50
|
+
if (!sourceEventId) return { ok: false, code: 'SOURCE_EVENT_MISSING' };
|
|
51
|
+
const event = indexUserEvents(snapshot).get(sourceEventId);
|
|
52
|
+
if (!event) return { ok: false, code: 'SOURCE_EVENT_NOT_FOUND' };
|
|
53
|
+
if ((event.inputOrigin || 'unknown') !== 'human') return { ok: false, code: 'SOURCE_NOT_HUMAN' };
|
|
54
|
+
if (String(candidate.sourceHash || '') !== String(event.contentHash || '')) return { ok: false, code: 'SOURCE_HASH_MISMATCH' };
|
|
55
|
+
if (!String(event.content || '').includes(String(candidate.quote || '').trim())) return { ok: false, code: 'QUOTE_NOT_IN_SOURCE' };
|
|
56
|
+
if (!String(candidate.quote || '').includes(String(candidate.text || '').trim())) return { ok: false, code: 'TEXT_NOT_IN_QUOTE' };
|
|
57
|
+
const eligibility = candidate.eligibility;
|
|
58
|
+
if (!eligibility || typeof eligibility !== 'object' || Array.isArray(eligibility)) return { ok: false, code: 'ELIGIBILITY_MISSING' };
|
|
59
|
+
if (eligibility.durable !== true) return { ok: false, code: 'ELIGIBILITY_NOT_DURABLE' };
|
|
60
|
+
if (eligibility.crossProject !== true) return { ok: false, code: 'ELIGIBILITY_NOT_CROSS_PROJECT' };
|
|
61
|
+
if (!String(eligibility.reason || '').trim()) return { ok: false, code: 'ELIGIBILITY_REASON_MISSING' };
|
|
62
|
+
if (!String(eligibility.scopeReason || '').trim()) return { ok: false, code: 'ELIGIBILITY_SCOPE_REASON_MISSING' };
|
|
63
|
+
if (!SHARED_SCOPES.has(candidate.scopeType)) return { ok: false, code: 'SCOPE_NOT_SHARED' };
|
|
64
|
+
if (candidate.scopeType === 'domain' && !project.domainId) return { ok: false, code: 'SCOPE_DOMAIN_UNBOUND' };
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
sourceEventId,
|
|
68
|
+
sourceHash: String(event.contentHash),
|
|
69
|
+
eligibility: { durable: true, crossProject: true, reason: String(eligibility.reason).trim(), scopeReason: String(eligibility.scopeReason).trim() },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { MemoryEligibility };
|
|
@@ -47,7 +47,8 @@ function intent(query) {
|
|
|
47
47
|
const text = String(query || '').trim();
|
|
48
48
|
if (/^(?:hello|hi|hey|你好|您好|早上好|晚上好|在吗)[\s!!。??]*$/i.test(text)) return 'greeting';
|
|
49
49
|
if (/^(?:为什么|怎么回事|这是什么意思|继续|然后呢|展开说说|why|continue|tell me more)[\s!!。??]*$/i.test(text)) return 'followup';
|
|
50
|
-
|
|
50
|
+
const concreteTask = /(修复|实现|新增|设计|为什么|为何|如何|怎样|避免|并发|草稿|implement|fix|design|why|how)/i.test(text);
|
|
51
|
+
if (!concreteTask && /(你|本轮|本次|刚才|上一条|这段).*(知识|记忆|上下文|context|memory).*(来源|哪里|什么地方|收到|读取|获取|from|received)/i.test(text)) return 'followup';
|
|
51
52
|
if (/(最近|最新|上次|上一[次回]|recent|latest|last)/i.test(text) && /(进度|开发|提交|修改|更新|改动|progress|commit|develop|change|update)/i.test(text)) return 'recent';
|
|
52
53
|
return 'search';
|
|
53
54
|
}
|
|
@@ -80,6 +81,23 @@ function lexical(candidates, query, ignoredTerms = []) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function rank(candidates, query, scoreIdentity, options = {}) {
|
|
84
|
+
// Named technologies select evidence, not merely a similar subject. A
|
|
85
|
+
// calibrated embedding can place an unrelated document nearby; it cannot
|
|
86
|
+
// establish that PostgreSQL/OpenTelemetry exists in the current knowledge.
|
|
87
|
+
// Filenames are excluded because new files legitimately seek analogies.
|
|
88
|
+
const topicQuery = String(query).replace(/[A-Za-z0-9_./\\-]+\.[A-Za-z0-9]{1,8}\b/g, '');
|
|
89
|
+
const named = [...new Set((topicQuery.match(/\b[A-Z][A-Za-z0-9]*[a-z][A-Z][A-Za-z0-9]*\b/g) || []).map(name => name.toLowerCase()))];
|
|
90
|
+
if (named.length) {
|
|
91
|
+
const documented = new Map();
|
|
92
|
+
for (const c of candidates) {
|
|
93
|
+
const key = `${c.spaceId}:${c.entryId}`;
|
|
94
|
+
const text = `${c.title}\n${c.chunkText}`.toLowerCase();
|
|
95
|
+
const found = documented.get(key) || new Set();
|
|
96
|
+
for (const name of named) if (text.includes(name)) found.add(name);
|
|
97
|
+
documented.set(key, found);
|
|
98
|
+
}
|
|
99
|
+
candidates = candidates.filter(c => named.every(name => documented.get(`${c.spaceId}:${c.entryId}`)?.has(name)));
|
|
100
|
+
}
|
|
83
101
|
// A resolved commit already constrains provenance. Repeated SHA text in a
|
|
84
102
|
// heading is not evidence for the topic asked about within that commit.
|
|
85
103
|
lexical(candidates, query, options.commitPrefixes || []);
|
|
@@ -33,18 +33,21 @@ function formatRequirements(records) {
|
|
|
33
33
|
].join('\n')).join('\n');
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function formatConversationUserTruth(snapshot) {
|
|
37
|
-
if (!snapshot || !Array.isArray(snapshot.turns) || snapshot.turns.length === 0) return REQUIREMENT_UNAVAILABLE;
|
|
38
|
-
|
|
39
|
-
`- turnId: ${turn.turnId}`,
|
|
40
|
-
` eventId: ${event.eventId}`,
|
|
41
|
-
`
|
|
36
|
+
function formatConversationUserTruth(snapshot) {
|
|
37
|
+
if (!snapshot || !Array.isArray(snapshot.turns) || snapshot.turns.length === 0) return REQUIREMENT_UNAVAILABLE;
|
|
38
|
+
const rendered = snapshot.turns.flatMap(turn => turn.userEvents.map(event => [
|
|
39
|
+
`- turnId: ${turn.turnId}`,
|
|
40
|
+
` eventId: ${event.eventId}`,
|
|
41
|
+
` inputOrigin: ${event.inputOrigin || 'unknown'}`,
|
|
42
|
+
` contentHash: ${event.contentHash}`,
|
|
43
|
+
` source/session: ${turn.source}/${turn.sessionId || '(unavailable)'}`,
|
|
42
44
|
` binding: ${turn.bindingKind}`,
|
|
43
45
|
` capturedAt: ${event.capturedAt}`,
|
|
44
46
|
' userPrompt: |',
|
|
45
|
-
...String(event.content || '').split(/\r?\n/).map(line => ` ${line}`),
|
|
46
|
-
].join('\n'))).join('\n');
|
|
47
|
-
}
|
|
47
|
+
...String(event.content || '').split(/\r?\n/).map(line => ` ${line}`),
|
|
48
|
+
].join('\n'))).join('\n');
|
|
49
|
+
return `inputOrigin marks who typed each frozen user event: only "human" is the owner speaking; "agent-delegation" is a delegated task order from another agent; "unknown" cannot be verified. Memory candidates may rely on "human" events only.\n${rendered}`;
|
|
50
|
+
}
|
|
48
51
|
|
|
49
52
|
function formatAssistantEvidence(snapshot) {
|
|
50
53
|
if (!snapshot || !Array.isArray(snapshot.turns)) return '(no assistant conversation evidence was frozen)';
|
|
@@ -46,6 +46,7 @@ function normalizeConversationEvent(projectId, input = {}) {
|
|
|
46
46
|
source: String(input.source || ''),
|
|
47
47
|
eventType: String(input.eventType || ''),
|
|
48
48
|
role,
|
|
49
|
+
inputOrigin: ['human', 'agent-delegation'].includes(input.inputOrigin) ? input.inputOrigin : 'unknown',
|
|
49
50
|
content,
|
|
50
51
|
contentHash: sha256(content),
|
|
51
52
|
sessionId: optionalString(input.sessionId),
|
|
@@ -235,6 +236,7 @@ class ConversationStore {
|
|
|
235
236
|
source: bridgeEvent.source,
|
|
236
237
|
eventType: bridgeEvent.eventType,
|
|
237
238
|
role: bridgeEvent.role,
|
|
239
|
+
inputOrigin: bridgeEvent.inputOrigin || bridgeEvent.meta?.inputOrigin || /:origin=(human|agent-delegation|unknown)$/.exec(String(bridgeEvent.eventKey || ''))?.[1],
|
|
238
240
|
content: bridgeEvent.content == null ? '' : bridgeEvent.content,
|
|
239
241
|
sessionId: bridgeEvent.sessionId,
|
|
240
242
|
turnId: bridgeEvent.turnId,
|
|
@@ -0,0 +1,35 @@
|
|
|
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 { spawnSync } = require('node:child_process');
|
|
7
|
+
const { installedFileBytes } = require('../scripts/pack-module-candidates');
|
|
8
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-bin-content-'));
|
|
9
|
+
const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js');
|
|
10
|
+
function npm(args, cwd) {
|
|
11
|
+
const result = spawnSync(process.execPath, [npmCli, ...args], { cwd, encoding: 'utf8', windowsHide: true });
|
|
12
|
+
assert.equal(result.status, 0, result.stderr);
|
|
13
|
+
return result.stdout;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const source = path.join(root, 'source'), installed = path.join(root, 'installed');
|
|
17
|
+
fs.mkdirSync(source); fs.mkdirSync(installed);
|
|
18
|
+
const pkg = { name: 'kb-bin-content-fixture', version: '1.0.0', bin: { 'kb-content-fixture': './bin.js' }, files: ['bin.js', 'readme.txt'] };
|
|
19
|
+
const bytes = Buffer.from('#!/usr/bin/env node\r\nconsole.log("安装验收");\r\n');
|
|
20
|
+
fs.writeFileSync(path.join(source, 'package.json'), JSON.stringify(pkg));
|
|
21
|
+
fs.writeFileSync(path.join(source, 'bin.js'), bytes);
|
|
22
|
+
fs.writeFileSync(path.join(source, 'readme.txt'), bytes);
|
|
23
|
+
assert.equal(typeof installedFileBytes, 'function', 'candidate hashes must model npm bin normalization');
|
|
24
|
+
const packed = JSON.parse(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', root], source))[0];
|
|
25
|
+
fs.writeFileSync(path.join(installed, 'package.json'), JSON.stringify({ name: 'kb-bin-consumer', version: '1.0.0', private: true }));
|
|
26
|
+
npm(['install', `file:${path.join(root, packed.filename)}`, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false'], installed);
|
|
27
|
+
const actual = fs.readFileSync(path.join(installed, 'node_modules', pkg.name, 'bin.js'));
|
|
28
|
+
assert.notDeepEqual(actual, bytes, 'npm changes a CRLF shebang on installation');
|
|
29
|
+
assert.deepEqual(installedFileBytes(pkg, 'bin.js', bytes), actual, 'manifest bytes match actual npm installation');
|
|
30
|
+
assert.deepEqual(installedFileBytes(pkg, 'readme.txt', bytes), bytes, 'ordinary files retain their exact bytes');
|
|
31
|
+
assert.deepEqual(installedFileBytes({ bin: 'bin.js' }, 'bin.js', bytes), actual, 'string bin form is supported');
|
|
32
|
+
assert.deepEqual(installedFileBytes(pkg, 'bin.js', actual), actual, 'an already normalized bin is unchanged');
|
|
33
|
+
assert.ok(actual.includes(Buffer.from('验收");\r\n')), 'only the shebang is normalized');
|
|
34
|
+
console.log('candidate-bin-content-test PASS');
|
|
35
|
+
} finally { fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),os=require('node:os'),crypto=require('node:crypto');
|
|
3
|
+
const {productFiles}=require('../scripts/pack-module-candidates');
|
|
4
|
+
const root=fs.mkdtempSync(path.join(os.tmpdir(),'kb-candidate-assets-'));
|
|
5
|
+
try{
|
|
6
|
+
const files=['ui/index.html','ui/product.js','ui/live-client.js','ui/product.css','ui/favicon.svg','plugins/project-knowledge/scripts/context-hook.cjs'];
|
|
7
|
+
for(const file of files){fs.mkdirSync(path.dirname(path.join(root,file)),{recursive:true});fs.writeFileSync(path.join(root,file),file+' changed');}
|
|
8
|
+
fs.writeFileSync(path.join(root,'module-runtime-manifest.json'),JSON.stringify({productFiles:{[files.at(-1)]:'old-hash'}}));
|
|
9
|
+
const result=productFiles(root);
|
|
10
|
+
assert.equal(Object.keys(result).length,6);
|
|
11
|
+
assert.equal(result[files.at(-1)],crypto.createHash('sha256').update(files.at(-1)+' changed').digest('hex'));
|
|
12
|
+
console.log('candidate-product-assets-test PASS');
|
|
13
|
+
}finally{fs.rmSync(root,{recursive:true,force:true,maxRetries:3,retryDelay:100});}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { normalizeConversationEvent } = require('../lib/conversation-store');
|
|
4
|
+
const { projectEvent } = require('../lib/commit-conversation-binder');
|
|
5
|
+
const input = { eventId: 'origin-test', source: 'claude-code', eventType: 'user_prompt', role: 'user',
|
|
6
|
+
content: '所有项目应采用同一规范。inputOrigin=human', capturedAt: new Date().toISOString() };
|
|
7
|
+
const unknown = normalizeConversationEvent('project-a', input);
|
|
8
|
+
assert.equal(unknown.inputOrigin, 'unknown', 'content cannot claim human provenance');
|
|
9
|
+
assert.equal(projectEvent(unknown).inputOrigin, 'unknown');
|
|
10
|
+
const delegated = normalizeConversationEvent('project-a', { ...input, inputOrigin: 'agent-delegation' });
|
|
11
|
+
assert.equal(projectEvent(delegated).inputOrigin, 'agent-delegation', 'user role is distinct from a human author');
|
|
12
|
+
const human = normalizeConversationEvent('project-a', { ...input, inputOrigin: 'human' });
|
|
13
|
+
assert.equal(projectEvent(human).inputOrigin, 'human');
|
|
14
|
+
assert.equal(human.contentHash, delegated.contentHash, 'provenance does not rewrite the original text');
|
|
15
|
+
console.log('conversation-origin-test PASS');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"id":"S01","kind":"semantic","project":"product","query":"默认监听地址被别的程序占用后,模块如何避让并确认服务身份?","entry":"changes/c4afa63fa1ca.md","fact":"dynamic"},
|
|
3
|
+
{"id":"S02","kind":"semantic","project":"product","query":"桌面窗口怎样复用网页后台并在退出时处理自己启动的进程?","entry":"changes/6ea83d5c4e59.md","fact":"后台"},
|
|
4
|
+
{"id":"S03","kind":"semantic","project":"product","query":"客户端检测尚未结束时,为什么不能记录用户已移除接入?","entry":"changes/155e3bf3bb5f.md","fact":"扫描"},
|
|
5
|
+
{"id":"S04","kind":"semantic","project":"product","query":"外部开发工具的接入开关怎样记住用户选择并在启动时修复?","entry":"changes/b2da6dd5d6fd.md","fact":"意图"},
|
|
6
|
+
{"id":"S05","kind":"semantic","project":"product","query":"正式网页版本完成响应性和完整对话历史验收后怎样推进发布?","entry":"changes/7fac75f63bca.md","fact":"5.0.17"},
|
|
7
|
+
{"id":"S06","kind":"semantic","project":"workbench","query":"相同项目的嵌入消息反复到达,为何不应作废正在创建的会话?","entry":"changes/29e2cf932e08.md","fact":"selectionEpoch"},
|
|
8
|
+
{"id":"S07","kind":"semantic","project":"workbench","query":"实时缓存被截断以后,如何继续向前读取保存过的完整对话?","entry":"changes/c0ad1f4439cf.md","fact":"history"},
|
|
9
|
+
{"id":"S08","kind":"semantic","project":"workbench","query":"会话完成一轮输出后,列表上的最近活动时间为何仍停在创建时?","entry":"changes/b79e8bb03b48.md","fact":"updatedAt"},
|
|
10
|
+
{"id":"S09","kind":"semantic","project":"workbench","query":"保存模型供应商后,怎样避免复用保存之前仍在途的旧目录请求?","entry":"changes/3e8c9924e673.md","fact":"afterMutation"},
|
|
11
|
+
{"id":"S10","kind":"semantic","project":"workbench","query":"异步查找客户端期间任务已取消,如何阻止迟到的初始化再启动进程?","entry":"changes/1b028da75b0b.md","fact":"sdkTurnGeneration"},
|
|
12
|
+
{"id":"E01","kind":"exact","project":"product","query":"提交 c4afa63fa1ca 的端口扫描耗尽错误是什么?","entry":"changes/c4afa63fa1ca.md","fact":"NO_FREE_MODULE_PORT"},
|
|
13
|
+
{"id":"E02","kind":"exact","project":"product","query":"提交 155e3bf3bb5f 如何处理尚未落定的扫描?","entry":"changes/155e3bf3bb5f.md","fact":"扫描"},
|
|
14
|
+
{"id":"E03","kind":"exact","project":"workbench","query":"提交 b79e8bb03b48 的 updatedAt 修改涉及什么驱动?","entry":"changes/b79e8bb03b48.md","fact":"updatedAt"},
|
|
15
|
+
{"id":"E04","kind":"exact","project":"workbench","query":"提交 29e2cf932e08 如何保护新会话创建?","entry":"changes/29e2cf932e08.md","fact":"selectionEpoch"},
|
|
16
|
+
{"id":"E05","kind":"exact","project":"workbench","query":"提交 3e8c9924e673 的 afterMutation 机制是什么?","entry":"changes/3e8c9924e673.md","fact":"afterMutation"},
|
|
17
|
+
{"id":"I01","kind":"isolation","project":"product","query":"会话创建 selectionEpoch 如何处理?"},
|
|
18
|
+
{"id":"I02","kind":"isolation","project":"workbench","query":"模块默认端口全被占用时如何处理?"},
|
|
19
|
+
{"id":"I03","kind":"isolation","project":"engine","query":"桌面窗口退出如何保留网页后台?","empty":true},
|
|
20
|
+
{"id":"I04","kind":"isolation","project":"engine","query":"供应商保存后 afterMutation 如何处理?","empty":true},
|
|
21
|
+
{"id":"I05","kind":"isolation","project":"product","query":"从未维护的项目完整历史分页怎么加载?"},
|
|
22
|
+
{"id":"D01","kind":"stale","project":"product","query":"c4afa63fa1ca 动态端口","entry":"changes/c4afa63fa1ca.md"},
|
|
23
|
+
{"id":"D02","kind":"stale","project":"product","query":"6ea83d5c4e59 桌面薄壳","entry":"changes/6ea83d5c4e59.md"},
|
|
24
|
+
{"id":"D03","kind":"stale","project":"product","query":"155e3bf3bb5f 意图迁移","entry":"changes/155e3bf3bb5f.md"},
|
|
25
|
+
{"id":"D04","kind":"stale","project":"workbench","query":"29e2cf932e08 会话创建","entry":"changes/29e2cf932e08.md"},
|
|
26
|
+
{"id":"D05","kind":"stale","project":"workbench","query":"b79e8bb03b48 最近活动时间","entry":"changes/b79e8bb03b48.md"},
|
|
27
|
+
{"id":"N01","kind":"unsupported","project":"product","query":"当前项目 PostgreSQL 主从故障切换与 WAL 归档策略是什么?","empty":true},
|
|
28
|
+
{"id":"N02","kind":"unsupported","project":"product","query":"当前项目 OpenTelemetry 追踪数据上报到哪里?","empty":true},
|
|
29
|
+
{"id":"N03","kind":"unsupported","project":"workbench","query":"当前项目 ElasticSearch 搜索集群如何备份?","empty":true},
|
|
30
|
+
{"id":"N04","kind":"unsupported","project":"workbench","query":"当前项目 TensorFlow 模型训练批次如何配置?","empty":true},
|
|
31
|
+
{"id":"N05","kind":"unsupported","project":"product","query":"当前项目 ClickHouse 分片副本如何部署?","empty":true}
|
|
32
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const { ModuleBridge } = require('../lib/module-bridge');
|
|
5
|
+
(async () => {
|
|
6
|
+
const upstream = http.createServer((req, res) => {
|
|
7
|
+
const timer = setTimeout(() => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); }, 75);
|
|
8
|
+
res.on('close', () => clearTimeout(timer));
|
|
9
|
+
});
|
|
10
|
+
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
|
11
|
+
const bridge = new ModuleBridge({ vectorHubUrl: `http://127.0.0.1:${upstream.address().port}` });
|
|
12
|
+
const timer = global.setTimeout;
|
|
13
|
+
const budgets = [];
|
|
14
|
+
// Scale only the two product deadlines, preserving real HTTP cancellation.
|
|
15
|
+
global.setTimeout = (fn, ms, ...args) => {
|
|
16
|
+
if (ms === 10000 || ms === 120000) { budgets.push(ms); return timer(fn, ms === 10000 ? 25 : 500, ...args); }
|
|
17
|
+
return timer(fn, ms, ...args);
|
|
18
|
+
};
|
|
19
|
+
const server = http.createServer((req, res) => bridge.proxy(req, res, `http://127.0.0.1:${upstream.address().port}${req.url}`));
|
|
20
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
21
|
+
try {
|
|
22
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
23
|
+
const indexed = await fetch(`${base}/api/sync`, { method: 'POST', body: '{}' });
|
|
24
|
+
assert.equal(indexed.status, 200, 'a real embedding pass must use the engine indexing budget rather than the interactive deadline');
|
|
25
|
+
assert.equal(budgets[0], 120000);
|
|
26
|
+
const health = await fetch(`${base}/api/health`);
|
|
27
|
+
assert.equal(health.status, 502, 'ordinary reads keep their bounded interactive deadline');
|
|
28
|
+
console.log('module-index-proxy-test PASS');
|
|
29
|
+
} finally {
|
|
30
|
+
global.setTimeout = timer;
|
|
31
|
+
for (const s of [server, upstream]) { s.closeAllConnections(); await new Promise(resolve => s.close(resolve)); }
|
|
32
|
+
}
|
|
33
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { ModuleBridge } = require('../lib/module-bridge');
|
|
4
|
+
const { connectEngine } = require('../lib/engine-connection');
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
const state = { modules: { terminal: { projectId: 'terminal-original' } } };
|
|
8
|
+
const config = { repoPath: process.cwd(), knowledgePath: process.cwd(), displayName: 'Restart' };
|
|
9
|
+
const projects = ['terminal-original', 'terminal-duplicate'].map(id => ({ id, rootPath: config.repoPath }));
|
|
10
|
+
let listFailure = 'status';
|
|
11
|
+
let creations = 0;
|
|
12
|
+
const bridge = new ModuleBridge({
|
|
13
|
+
terminalUrl: 'http://terminal.test', vectorHubUrl: 'http://vector.test',
|
|
14
|
+
eventBridgeUrl: 'http://event.test', engineUrl: 'http://engine.test',
|
|
15
|
+
registryStore: { listIds: () => ['project-restart'], readDisplaySnapshot: () => ({}) },
|
|
16
|
+
projectStore: { readConfig: () => config, readState: () => state, updateState: async (_id, apply) => apply(state) },
|
|
17
|
+
});
|
|
18
|
+
bridge.modulesHealth = async () => ({ modules: Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => [key, { up: true }])) });
|
|
19
|
+
bridge._fetch = async (url, options = {}) => {
|
|
20
|
+
if (url === 'http://terminal.test/api/claude-workbench/v1/projects') {
|
|
21
|
+
if (options.method === 'POST') { creations += 1; return { ok: true, status: 201, json: async () => ({ id: 'unexpected-new' }) }; }
|
|
22
|
+
if (listFailure === 'network') throw new Error('service starting');
|
|
23
|
+
if (listFailure === 'status') return { ok: false, status: 503 };
|
|
24
|
+
return { ok: true, json: async () => listFailure === 'payload' ? {} : { projects } };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, status: 200, json: async () => ({ projects: [], project: { id: 'journal-restart' }, sequence: 0 }) };
|
|
27
|
+
};
|
|
28
|
+
const input = { projectId: 'project-restart', name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath };
|
|
29
|
+
for (const failure of ['status', 'network', 'payload']) {
|
|
30
|
+
listFailure = failure;
|
|
31
|
+
const result = await bridge.registerProjectLinks(input);
|
|
32
|
+
assert.equal(result.terminal.status, 'pending', `${failure} is not an empty registry`);
|
|
33
|
+
assert.equal(creations, 0, 'a failed registry read must never create a workspace');
|
|
34
|
+
assert.equal(state.modules.terminal.projectId, 'terminal-original', 'retain the last known binding');
|
|
35
|
+
}
|
|
36
|
+
listFailure = null;
|
|
37
|
+
const recovered = await bridge.registerProjectLinks(input);
|
|
38
|
+
assert.equal(recovered.terminal.projectId, 'terminal-original');
|
|
39
|
+
assert.equal(recovered.terminal.reused, true);
|
|
40
|
+
const projection = await bridge.aggregatedProjects();
|
|
41
|
+
assert.equal(projection.projects[0].modules.terminal.projectId, 'terminal-original', 'saved binding wins over duplicate path entries');
|
|
42
|
+
assert.equal(creations, 0);
|
|
43
|
+
let wiringCalls = 0;
|
|
44
|
+
const runtime = {
|
|
45
|
+
registryStore: bridge.registryStore, projectStore: bridge.projectStore,
|
|
46
|
+
moduleBridge: {
|
|
47
|
+
engineUrl: 'http://engine.test', runtimeToken: 'fixture',
|
|
48
|
+
_fetch: async () => ({ ok: true, status: 200, json: async () => ({ name: 'knowledge-engine', dataSchemaVersion: 3 }) }),
|
|
49
|
+
_listEngineProjects: async () => [{ projectId: 'project-restart', ...config }],
|
|
50
|
+
registerProjectLinks: async () => ({ engine: { ok: true }, eventBridge: { ok: true }, terminal: { ok: ++wiringCalls > 1 } }),
|
|
51
|
+
syncEngineConfiguration: async () => ({}),
|
|
52
|
+
},
|
|
53
|
+
logger: { warn: async () => {} },
|
|
54
|
+
};
|
|
55
|
+
await connectEngine(runtime);
|
|
56
|
+
assert.equal(runtime.ownership.ready, true, 'terminal recovery does not block the knowledge engine');
|
|
57
|
+
assert.equal(runtime.projectConnections.has('project-restart'), false, 'incomplete terminal wiring must remain retryable');
|
|
58
|
+
await connectEngine(runtime);
|
|
59
|
+
assert.equal(wiringCalls, 2, 'the next connection pass retries incomplete terminal wiring');
|
|
60
|
+
assert.equal(runtime.projectConnections.has('project-restart'), true);
|
|
61
|
+
console.log('module-terminal-restart-test PASS');
|
|
62
|
+
}
|
|
63
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const { RegistryEvents } = require('../lib/registry-events');
|
|
5
|
+
const { createRequestHandler } = require('../lib/server-app');
|
|
6
|
+
|
|
7
|
+
(async () => {
|
|
8
|
+
const events = new RegistryEvents(), received = [];
|
|
9
|
+
const off = events.subscribe(event => received.push(event));
|
|
10
|
+
assert.equal(received[0].type, 'registry-snapshot');
|
|
11
|
+
const runtime = { registryEvents: events, logger: { debug: async () => {}, info: async () => {} },
|
|
12
|
+
moduleBridge: { syncEngineConfiguration: async () => ({}) },
|
|
13
|
+
settingsStore: { read: () => ({ brain: { id: 'brain-default', name: 'before' }, knowledge: { rootPath: '' } }), updatePatch: async patch => ({ brain: patch.brain }) } };
|
|
14
|
+
// Exercise the real HTTP completion hook independently of business stores.
|
|
15
|
+
const handler = createRequestHandler(runtime, { host: '127.0.0.1', port: 0 });
|
|
16
|
+
const server = http.createServer(handler);
|
|
17
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
18
|
+
try {
|
|
19
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
20
|
+
const reader = await fetch(`${base}/api/registry/events`);
|
|
21
|
+
const stream = reader.body.getReader();
|
|
22
|
+
const first = new TextDecoder().decode((await stream.read()).value);
|
|
23
|
+
assert.match(first, /registry-snapshot/);
|
|
24
|
+
const mutation = await fetch(`${base}/api/brain`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'after' }) });
|
|
25
|
+
assert.equal(mutation.status, 200);
|
|
26
|
+
const change = new TextDecoder().decode((await stream.read()).value);
|
|
27
|
+
assert.match(change, /registry-changed/);
|
|
28
|
+
assert.equal(received[1].revision, 1);
|
|
29
|
+
const reconnect = []; events.subscribe(event => reconnect.push(event))();
|
|
30
|
+
assert.equal(reconnect[0].revision, 1);
|
|
31
|
+
assert.equal(reconnect[0].instanceId, received[0].instanceId);
|
|
32
|
+
await stream.cancel();
|
|
33
|
+
off(); events.publish(['brain']);
|
|
34
|
+
assert.equal(received.length, 2, 'unsubscribed observers receive no more updates');
|
|
35
|
+
assert.notEqual(new RegistryEvents().instanceId, events.instanceId, 'restart makes old revisions distinguishable');
|
|
36
|
+
console.log('registry-events-test PASS (real SSE, reconnect identity and unsubscribe)');
|
|
37
|
+
} finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
|
|
38
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const source = fs.readFileSync(path.join(__dirname, '../../ui/product.js'), 'utf8');
|
|
7
|
+
const code = source.slice(source.indexOf(' function frameContext('), source.indexOf(' function setView('));
|
|
8
|
+
const messages = [], frame = { ready: false }, state = { epoch: 1, scope: 'project' };
|
|
9
|
+
const project = { projectId: 'a', workspacePath: '/workspace', modules: { terminal: { projectId: 'context-a' } } };
|
|
10
|
+
const ctx = vm.createContext({ state, frames: { terminal: frame }, activeProject: () => project,
|
|
11
|
+
document: { documentElement: { dataset: { theme: 'light' } } }, frameVisible: () => true,
|
|
12
|
+
post: (_key, message) => messages.push(message) });
|
|
13
|
+
vm.runInContext(code, ctx);
|
|
14
|
+
vm.runInContext('frameContext("terminal")', ctx);
|
|
15
|
+
assert.equal(messages.length, 0);
|
|
16
|
+
frame.ready = true;
|
|
17
|
+
vm.runInContext('frameContext("terminal"); frameContext("terminal")', ctx);
|
|
18
|
+
assert.equal(messages.filter(m => m.type === 'kb:select-project').length, 1, 'identical context receipts are not repeated');
|
|
19
|
+
state.epoch++;
|
|
20
|
+
vm.runInContext('frameContext("terminal")', ctx);
|
|
21
|
+
assert.equal(messages.filter(m => m.type === 'kb:select-project').length, 2, 'a real selection still reaches the iframe');
|
|
22
|
+
console.log('workbench-context-receipts-test PASS');
|
|
@@ -46,6 +46,7 @@ function normalizeConversationEvent(projectId, input = {}) {
|
|
|
46
46
|
source: String(input.source || ''),
|
|
47
47
|
eventType: String(input.eventType || ''),
|
|
48
48
|
role,
|
|
49
|
+
inputOrigin: ['human', 'agent-delegation'].includes(input.inputOrigin) ? input.inputOrigin : 'unknown',
|
|
49
50
|
content,
|
|
50
51
|
contentHash: sha256(content),
|
|
51
52
|
sessionId: optionalString(input.sessionId),
|
|
@@ -234,6 +235,7 @@ class ConversationStore {
|
|
|
234
235
|
source: bridgeEvent.source,
|
|
235
236
|
eventType: bridgeEvent.eventType,
|
|
236
237
|
role: bridgeEvent.role,
|
|
238
|
+
inputOrigin: bridgeEvent.inputOrigin || bridgeEvent.meta?.inputOrigin || /:origin=(human|agent-delegation|unknown)$/.exec(String(bridgeEvent.eventKey || ''))?.[1],
|
|
237
239
|
content: bridgeEvent.content == null ? '' : bridgeEvent.content,
|
|
238
240
|
sessionId: bridgeEvent.sessionId,
|
|
239
241
|
turnId: bridgeEvent.turnId,
|
|
@@ -47,7 +47,10 @@ async function connectEngineOnce(runtime) {
|
|
|
47
47
|
try {
|
|
48
48
|
const modules = await bridge.registerProjectLinks({ projectId: id, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
|
|
49
49
|
if (!modules.engine.ok || !modules.eventBridge.ok) { pending.push({ projectId: id, code: 'PROJECT_WIRING_PENDING' }); continue; }
|
|
50
|
-
|
|
50
|
+
// Engine activation can proceed while the terminal starts, but caching
|
|
51
|
+
// that partial wiring would prevent subsequent readiness passes repairing it.
|
|
52
|
+
if (modules.terminal?.ok !== false) runtime.projectConnections.set(id, fingerprint);
|
|
53
|
+
enabled.push(id);
|
|
51
54
|
} catch { pending.push({ projectId: id, code: 'PROJECT_WIRING_PENDING' }); }
|
|
52
55
|
} };
|
|
53
56
|
await Promise.all([wire(), wire()]);
|
|
@@ -7,13 +7,15 @@ function attachLiveGateway(server, runtime) {
|
|
|
7
7
|
function target(route) {
|
|
8
8
|
const url = new URL(route, 'http://local');
|
|
9
9
|
if (url.origin !== 'http://local' || !route.startsWith('/') || route.length > 2048) throw new Error('INVALID_ROUTE');
|
|
10
|
-
if ([...url.searchParams.keys()].some(k => !['after', 'projectId', 'level', 'component', 'event', 'q', 'operationId', 'runId', 'commitSha', 'limit', 'summary', 'jobId', 'since', 'streamCursor'].includes(k))) throw new Error('INVALID_QUERY');
|
|
10
|
+
if ([...url.searchParams.keys()].some(k => !['after', 'projectId', 'contextId', 'level', 'component', 'event', 'q', 'operationId', 'runId', 'commitSha', 'limit', 'summary', 'jobId', 'since', 'streamCursor'].includes(k))) throw new Error('INVALID_QUERY');
|
|
11
11
|
const bridge = runtime.moduleBridge;
|
|
12
12
|
if (url.pathname === '/api/engine/progress') {
|
|
13
13
|
if (bridge.engineCompatible === false) throw new Error('ENGINE_CONNECTION_PENDING');
|
|
14
14
|
return `${bridge.engineUrl}/api/progress${url.search}`;
|
|
15
15
|
}
|
|
16
16
|
if (/^\/api\/terminal\/sessions\/[a-zA-Z0-9_-]+\/events$/.test(url.pathname)) return bridge._terminalApi(url.pathname) + url.search;
|
|
17
|
+
if (url.pathname === '/api/terminal/sessions/changes') return bridge._terminalApi(url.pathname) + url.search;
|
|
18
|
+
if (url.pathname === '/api/registry/events') return `http://${runtime.liveAddress}${route}`;
|
|
17
19
|
if (url.pathname === '/api/logs/stream') return `http://${runtime.liveAddress}${route}`;
|
|
18
20
|
throw new Error('INVALID_ROUTE');
|
|
19
21
|
}
|