@hadooppei/hwcode 1.0.15 → 1.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.
@@ -0,0 +1,308 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { sanitizeKnowledgeText } from "./sanitize.ts";
6
+ import type { KnowledgeEpisode, KnowledgeEvidence, KnowledgeEvidenceKind } from "./types.ts";
7
+
8
+ const FAILURE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|timeout|timed out|unreachable|servfail|refused)\b|not supported|失败|错误|不支持|超时|无法|不可达)/iu;
9
+ const VERIFICATION_RE = /(?:\b(?:verified|verification|active|running|succeeded|success|http\s*=?\s*200|downloaded newer image)\b|验证通过|运行成功|成功创建|已恢复)/iu;
10
+ const CORRECTION_RE = /(?:\b(?:must|should|always|never|correction|most valuable)\b|必须|应该|不应该|纠正|最有价值|不要|务必)/iu;
11
+ const REVERSAL_RE = /(?:\b(?:actually|instead|not the|turns out|root cause)\b|实为|并非|不是.*而是|误判|转机|根因|重大进展)/iu;
12
+
13
+ interface ToolCallRecord {
14
+ id: string;
15
+ name: string;
16
+ args: Record<string, unknown>;
17
+ }
18
+
19
+ interface EpisodeDefinition {
20
+ id: string;
21
+ title: string;
22
+ patterns: RegExp[];
23
+ }
24
+
25
+ const EPISODE_DEFINITIONS: EpisodeDefinition[] = [
26
+ {
27
+ id: "container-image-runtime",
28
+ title: "Container image retrieval and runtime verification",
29
+ patterns: [/docker(?:\.io)?/iu, /registry/iu, /imagepull/iu, /image pull/iu, /镜像拉取/u, /hello-world/iu, /nginx/iu, /container/iu, /容器/u, /daemon\.json/iu],
30
+ },
31
+ {
32
+ id: "cloud-network-egress",
33
+ title: "Cloud network, DNS, and outbound reachability",
34
+ patterns: [/\bdns\b/iu, /servfail/iu, /egress/iu, /outbound/iu, /route table/iu, /公网出口/u, /出站/u, /解析/u, /\bnat\b/iu, /security group/iu],
35
+ },
36
+ {
37
+ id: "cloud-compute-deployment",
38
+ title: "Cloud compute provisioning and deployment verification",
39
+ patterns: [/\becs\b/iu, /createservers/iu, /showserver/iu, /virtual machine/iu, /虚拟机/u, /云主机/u],
40
+ },
41
+ {
42
+ id: "cli-api-contract",
43
+ title: "CLI operation, version, and parameter contracts",
44
+ patterns: [/hcloud/iu, /koocli/iu, /\/v[23]\b/iu, /operation .*not supported/iu, /parameter/iu, /参数/u, /listsecuritygroups/iu, /listroutetables/iu],
45
+ },
46
+ {
47
+ id: "package-installation",
48
+ title: "Package installation and repository recovery",
49
+ patterns: [/apt-get/iu, /apt update/iu, /package/iu, /dpkg/iu, /404\s+not found/iu, /软件包/u, /镜像源/u],
50
+ },
51
+ {
52
+ id: "testing-verification",
53
+ title: "Testing and runtime verification",
54
+ patterns: [/test/iu, /typecheck/iu, /smoke/iu, /http\s*=?\s*200/iu, /验证/u, /测试/u],
55
+ },
56
+ {
57
+ id: "software-debugging",
58
+ title: "Software debugging and verified repair",
59
+ patterns: [/\b(?:bug|debug|exception|stack trace|compile|build failed|regression)\b/iu, /报错/u, /异常/u, /编译/u, /构建失败/u, /修复/u, /回归/u],
60
+ },
61
+ {
62
+ id: "architecture-refactoring",
63
+ title: "Architecture, API, and refactoring decisions",
64
+ patterns: [/\b(?:architecture|refactor|schema|interface|design decision|migration)\b/iu, /架构/u, /重构/u, /接口设计/u, /数据结构/u, /迁移/u],
65
+ },
66
+ {
67
+ id: "deployment-operations",
68
+ title: "Deployment and operational procedures",
69
+ patterns: [/\b(?:deploy|release|rollback|systemd|kubernetes|terraform|service health)\b/iu, /部署/u, /发布/u, /回滚/u, /运维/u, /健康检查/u],
70
+ },
71
+ {
72
+ id: "general-reusable-work",
73
+ title: "General reusable workflow or user guidance",
74
+ patterns: [/[\s\S]+/u],
75
+ },
76
+ ];
77
+
78
+ function digest(value: string, length = 12): string {
79
+ return createHash("sha256").update(value).digest("hex").slice(0, length);
80
+ }
81
+
82
+ function messageRecord(entry: SessionEntry): Record<string, unknown> | undefined {
83
+ return entry.type === "message" && entry.message && typeof entry.message === "object"
84
+ ? entry.message as unknown as Record<string, unknown>
85
+ : undefined;
86
+ }
87
+
88
+ function textContent(content: unknown): string {
89
+ if (typeof content === "string") return content;
90
+ if (!Array.isArray(content)) return "";
91
+ return content.map((item) => {
92
+ if (!item || typeof item !== "object") return "";
93
+ const value = item as Record<string, unknown>;
94
+ return typeof value.text === "string" ? value.text : "";
95
+ }).filter(Boolean).join("\n");
96
+ }
97
+
98
+ function boundedExcerpt(value: string, limit = 900): string {
99
+ const clean = sanitizeKnowledgeText(value).replace(/\n{3,}/gu, "\n\n").trim();
100
+ if (clean.length <= limit) return clean;
101
+ const lines = clean.split("\n");
102
+ const significant = lines.filter((line) => FAILURE_RE.test(line) || VERIFICATION_RE.test(line));
103
+ const selected = [...lines.slice(0, 3), ...significant.slice(0, 5), ...lines.slice(-3)];
104
+ return [...new Set(selected)].join("\n").slice(0, limit);
105
+ }
106
+
107
+ function compactArguments(args: Record<string, unknown>): string {
108
+ const parts: string[] = [];
109
+ for (const key of ["approach", "intent", "operation", "command"]) {
110
+ const value = args[key];
111
+ if (typeof value === "string" && value.trim()) parts.push(`${key}=${value}`);
112
+ }
113
+ if (Array.isArray(args.args)) {
114
+ const values = args.args.filter((item): item is string => typeof item === "string").slice(0, 6);
115
+ if (values.length > 0) parts.push(`args=${values.join(" ")}`);
116
+ }
117
+ if (parts.length === 0) parts.push(JSON.stringify(args));
118
+ return boundedExcerpt(parts.join("; "), 700);
119
+ }
120
+
121
+ function operationName(call: ToolCallRecord | undefined): string | undefined {
122
+ if (!call) return undefined;
123
+ const command = typeof call.args.command === "string" ? call.args.command : "";
124
+ const args = Array.isArray(call.args.args)
125
+ ? call.args.args.filter((item): item is string => typeof item === "string")
126
+ : [];
127
+ if (command && args.length >= 2) return `${command} ${args[0]} ${args[1]}`;
128
+ if (call.name === "bash" && command) {
129
+ if (/docker\s+(?:pull|run|ps)|daemon\.json/iu.test(command)) return "bash docker registry/runtime";
130
+ if (/(?:nslookup|\bdig\b|getent hosts).*docker|registry-1\.docker\.io/iu.test(command)) return "bash docker registry DNS diagnostic";
131
+ if (/apt-get/iu.test(command)) return "bash apt package operation";
132
+ }
133
+ if (command) return command.split(/\s+/u).slice(0, 3).join(" ");
134
+ return call.name;
135
+ }
136
+
137
+ function evidenceSubject(call: ToolCallRecord | undefined, result: string): string {
138
+ if (call) {
139
+ const intent = call.args.intent;
140
+ if (typeof intent === "string" && intent.trim()) return boundedExcerpt(intent, 220);
141
+ const approach = call.args.approach;
142
+ if (typeof approach === "string" && approach.trim()) return approach.slice(0, 120);
143
+ const operation = operationName(call);
144
+ if (operation?.includes("docker registry DNS")) return "Diagnose Docker registry DNS resolution and direct reachability";
145
+ if (operation?.includes("docker registry/runtime")) return "Configure or verify Docker image retrieval and container runtime";
146
+ if (operation?.includes("apt package")) return "Install Docker packages and recover package repository failures";
147
+ return operation ?? call.name;
148
+ }
149
+ return boundedExcerpt(result.split("\n").find(Boolean) ?? "tool result", 220);
150
+ }
151
+
152
+ function timestampOf(entry: SessionEntry): string {
153
+ const value = (entry as unknown as Record<string, unknown>).timestamp;
154
+ return typeof value === "string" ? value : "";
155
+ }
156
+
157
+ function entryIdOf(entry: SessionEntry, index: number): string {
158
+ const value = (entry as unknown as Record<string, unknown>).id;
159
+ return typeof value === "string" && value ? value : `entry-${index}`;
160
+ }
161
+
162
+ function evidenceId(entryId: string, suffix: string): string {
163
+ return `ev-${digest(`${entryId}\0${suffix}`)}`;
164
+ }
165
+
166
+ function outcomeFor(message: Record<string, unknown>, text: string): "success" | "failure" {
167
+ if (message.isError === true || FAILURE_RE.test(text)) return "failure";
168
+ return "success";
169
+ }
170
+
171
+ function userKind(text: string): KnowledgeEvidenceKind {
172
+ return CORRECTION_RE.test(text) ? "user-correction" : "user-directive";
173
+ }
174
+
175
+ export function buildKnowledgeEvidence(entries: SessionEntry[]): KnowledgeEvidence[] {
176
+ const calls = new Map<string, ToolCallRecord>();
177
+ const evidence: KnowledgeEvidence[] = [];
178
+
179
+ entries.forEach((entry, index) => {
180
+ const message = messageRecord(entry);
181
+ if (!message) return;
182
+ const entryId = entryIdOf(entry, index);
183
+ const timestamp = timestampOf(entry);
184
+ const content = message.content;
185
+ if (Array.isArray(content)) {
186
+ for (const raw of content) {
187
+ if (!raw || typeof raw !== "object") continue;
188
+ const item = raw as Record<string, unknown>;
189
+ if (item.type !== "toolCall" || typeof item.id !== "string" || typeof item.name !== "string") continue;
190
+ calls.set(item.id, {
191
+ id: item.id,
192
+ name: item.name,
193
+ args: item.arguments && typeof item.arguments === "object"
194
+ ? item.arguments as Record<string, unknown>
195
+ : {},
196
+ });
197
+ }
198
+ }
199
+
200
+ const role = typeof message.role === "string" ? message.role : "";
201
+ const text = textContent(content).trim();
202
+ if (role === "user" && text) {
203
+ evidence.push({
204
+ id: evidenceId(entryId, "user"), entryId, timestamp, kind: userKind(text),
205
+ subject: "User requirement or correction", outcome: "unknown", excerpt: boundedExcerpt(text, 700),
206
+ });
207
+ return;
208
+ }
209
+ if (role === "toolResult" && text) {
210
+ const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
211
+ const call = calls.get(toolCallId);
212
+ const outcome = outcomeFor(message, text);
213
+ const callText = call ? `[call] ${call.name}: ${compactArguments(call.args)}\n` : "";
214
+ evidence.push({
215
+ id: evidenceId(entryId, toolCallId || "tool"), entryId, timestamp,
216
+ kind: outcome === "failure" ? "tool-failure" : VERIFICATION_RE.test(text) ? "verification" : "tool-success",
217
+ subject: evidenceSubject(call, text), operation: operationName(call), outcome,
218
+ excerpt: boundedExcerpt(`${callText}[result] ${text}`),
219
+ });
220
+ return;
221
+ }
222
+ if (role === "assistant" && text && (FAILURE_RE.test(text) || VERIFICATION_RE.test(text) || REVERSAL_RE.test(text))) {
223
+ evidence.push({
224
+ id: evidenceId(entryId, "assistant"), entryId, timestamp,
225
+ kind: VERIFICATION_RE.test(text) ? "verification" : "assistant-claim",
226
+ subject: "Assistant diagnosis or conclusion", outcome: "unknown", excerpt: boundedExcerpt(text, 800),
227
+ });
228
+ }
229
+ });
230
+ const userEvidence = evidence.filter((item) => item.kind === "user-correction" || item.kind === "user-directive").slice(-12);
231
+ const recentEvidence = evidence.filter((item) => item.kind !== "user-correction" && item.kind !== "user-directive").slice(-68);
232
+ return [...userEvidence, ...recentEvidence].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
233
+ }
234
+
235
+ function definitionScore(evidence: KnowledgeEvidence, definition: EpisodeDefinition): number {
236
+ const value = `${evidence.subject}\n${evidence.operation ?? ""}\n${evidence.excerpt}`;
237
+ return definition.patterns.filter((pattern) => pattern.test(value)).length;
238
+ }
239
+
240
+ function timeValue(value: string): number {
241
+ const parsed = Date.parse(value);
242
+ return Number.isFinite(parsed) ? parsed : 0;
243
+ }
244
+
245
+ function episodeScore(items: KnowledgeEvidence[]): Omit<KnowledgeEpisode, "id" | "title" | "query" | "evidenceIds"> {
246
+ const failures = items.filter((item) => item.outcome === "failure").length;
247
+ const successes = items.filter((item) => item.outcome === "success" || item.kind === "verification").length;
248
+ const reversals = items.filter((item) => REVERSAL_RE.test(item.excerpt)).length;
249
+ const userSignal = items.some((item) => item.kind === "user-correction");
250
+ const times = items.map((item) => timeValue(item.timestamp)).filter((value) => value > 0).sort((a, b) => a - b);
251
+ const elapsedMs = times.length > 1 ? times[times.length - 1]! - times[0]! : 0;
252
+ const score = Math.min(100,
253
+ (userSignal ? 25 : items.some((item) => item.kind === "user-directive") ? 8 : 0)
254
+ + Math.min(20, failures * 5)
255
+ + Math.min(20, successes * 3 + (failures > 0 && successes > 0 ? 5 : 0))
256
+ + Math.min(15, reversals * 5)
257
+ + Math.min(10, Math.floor(elapsedMs / 120_000))
258
+ + (items.length >= 3 ? 10 : 4));
259
+ return {
260
+ score, failureCount: failures, successCount: successes,
261
+ hypothesisReversalCount: reversals, elapsedMs, userSignal,
262
+ };
263
+ }
264
+
265
+ export function buildKnowledgeEpisodes(evidence: KnowledgeEvidence[]): KnowledgeEpisode[] {
266
+ const buckets = new Map(EPISODE_DEFINITIONS.map((definition) => [definition.id, [] as KnowledgeEvidence[]]));
267
+ for (const item of evidence) {
268
+ const value = `${item.subject}\n${item.operation ?? ""}\n${item.excerpt}`;
269
+ if (/(?:docker(?:\.io)?|registry-1\.docker|daemon\.json|imagepullbackoff|镜像拉取)/iu.test(value)) {
270
+ buckets.get("container-image-runtime")!.push(item);
271
+ continue;
272
+ }
273
+ const ranked = EPISODE_DEFINITIONS.map((definition) => ({ definition, score: definitionScore(item, definition) }))
274
+ .filter(({ score }) => score > 0)
275
+ .sort((left, right) => right.score - left.score);
276
+ const primary = ranked[0]?.definition;
277
+ if (primary) buckets.get(primary.id)!.push(item);
278
+ }
279
+ const episodes: KnowledgeEpisode[] = [];
280
+ for (const definition of EPISODE_DEFINITIONS) {
281
+ const items = buckets.get(definition.id) ?? [];
282
+ if (items.length === 0) continue;
283
+ const metrics = episodeScore(items);
284
+ const queryItems = items.map((item, index) => ({
285
+ item, index,
286
+ priority: item.kind === "user-correction" ? 0 : REVERSAL_RE.test(item.excerpt) ? 1
287
+ : item.outcome === "failure" ? 2 : item.kind === "verification" ? 3 : 4,
288
+ })).sort((left, right) => left.priority - right.priority || right.index - left.index).slice(0, 10)
289
+ .map(({ item }) => item);
290
+ const queryParts = queryItems.flatMap((item) => [
291
+ item.subject, item.operation ?? "", boundedExcerpt(item.excerpt, 180),
292
+ ])
293
+ .map((item) => item.replace(/\s+/gu, " ").trim()).filter(Boolean);
294
+ episodes.push({
295
+ id: `ep-${definition.id}-${digest(items.map((item) => item.id).join("\0"), 8)}`,
296
+ title: definition.title,
297
+ query: [...new Set(queryParts)].join("; ").slice(0, 1_200),
298
+ evidenceIds: items.map((item) => item.id),
299
+ ...metrics,
300
+ });
301
+ }
302
+ const hasSpecificEpisode = episodes.some((episode) => !episode.id.includes("-general-reusable-work-"));
303
+ const retained = hasSpecificEpisode
304
+ ? episodes.filter((episode) => !episode.id.includes("-general-reusable-work-") || episode.userSignal)
305
+ : episodes;
306
+ return retained.sort((left, right) => right.score - left.score || right.evidenceIds.length - left.evidenceIds.length)
307
+ .slice(0, 6);
308
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
3
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
+ import type { KnowledgeEpisode, KnowledgeEpisodeDecision, KnowledgeEvidence } from "./types.ts";
4
5
 
5
6
  export interface ExistingKnowledgeContext {
6
7
  id: string;
@@ -8,11 +9,18 @@ export interface ExistingKnowledgeContext {
8
9
  summary: string;
9
10
  keywords: string[];
10
11
  track: "rule" | "topic";
12
+ identityKey?: string;
13
+ claimKeys?: string[];
11
14
  content?: string;
12
15
  }
13
16
 
17
+ export interface KnowledgeReviewEnvelope {
18
+ episodeDecisions: KnowledgeEpisodeDecision[];
19
+ candidates: unknown[];
20
+ }
21
+
14
22
  export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
15
- Review only the supplied conversation delta. Return strict JSON and no markdown.
23
+ Review the supplied structured evidence and conversation delta. Return strict JSON and no markdown.
16
24
  Persist only knowledge that is likely to be useful in future sessions:
17
25
  - explicit user corrections or stable preferences;
18
26
  - verified engineering rules, successful procedures, or expensive failed approaches;
@@ -22,19 +30,23 @@ Do not persist mutable inventory such as current cloud resources, names, availab
22
30
  Do not persist hypotheses, suspected causes, or statements marked as possible, pending validation, or unconfirmed. Omit them until direct evidence verifies them.
23
31
  Treat failed, unsupported, invalid, or corrected operations as negative evidence. Never present an operation as valid when the supplied evidence says it failed; use only a verified alternative or record an explicit warning. Never invent commands or parameters absent from successful evidence.
24
32
  Use storageHint "rule" only for one short imperative instruction of at most 240 characters. A rule must not contain incident narration, example resource names, IDs, timestamps, or evidence details. Use "topic" for multi-step SOPs and detailed experience.
25
- Relevant existing knowledge may be supplied. If a candidate has the same intent as an existing item, do not add a translated, renamed, or reformatted duplicate. Set targetId to that exact existing ID and action to "reinforce" when the existing content remains correct, or "revise" only when the delta explicitly proves a correction. Use targetId null only for genuinely new knowledge.
33
+ Rank knowledge by future time saved, repeated failures, hypothesis reversals, user corrections, verified resolution, reuse, and novelty. Do not favor the final task result merely because it completed the objective. Every supplied episode scoring 45 or higher must have an episodeDecisions entry, even when skipped.
34
+ Relevant existing knowledge may be supplied. Cheap recall only proposes related items; it does not prove duplication. Compare execution environment, symptom, and root cause. Use action "reinforce" only when the exact target already covers every claim and there is no new content. Use "extend" for new compatible claims, "correct" when direct evidence disproves old content, "retire" when the whole target is invalid, and "add" for a distinct environment or root cause. Never turn lexical similarity into reinforcement.
26
35
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
27
36
  The prior validation context is only for checking facts and contradictions; do not persist it again unless the new delta independently reinforces it. Prefer accumulated workflow successfulSteps and failedApproaches over assistant narration.
37
+ Every technical claim must have a stable claim key and cite evidenceIds from the supplied evidence ledger. Tool success, tool failure, and verification evidence outrank assistant narration. Do not support a service-wide or universal claim with one operation-specific failure. Evidence for an unrelated parameter error cannot support an API-version claim.
28
38
  Each summary must be one complete sentence of at most 160 characters. Keep each topic body under 2200 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered. Return at most 3 candidates.
29
39
  The body must not repeat the title as a Markdown H1. The storage renderer supplies the H1. Replace any necessary example resource value with an obvious placeholder instead of a real project, namespace, image, network, or resource name.
30
- Return: {"candidates":[{"key":"stable semantic key","targetId":"existing-id or null","title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false,"durability":"stable"}]}
31
- Return {"candidates":[]} when nothing meets the threshold.`;
40
+ Return: {"episodeDecisions":[{"episodeId":"ep-id","decision":"add|reinforce|extend|correct|retire|skip","targetId":"existing-id or omitted","reason":"..."}],"candidates":[{"key":"stable semantic key","identityKey":"platform/runtime/problem/root-cause","targetId":"existing-id or null","episodeIds":["ep-id"],"title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body or only the new section for extend","claims":[{"key":"stable claim key","text":"one precise claim","evidenceIds":["ev-id"]}],"evidenceIds":["ev-id"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|extend|correct|retire","explicitUserDirective":false,"durability":"stable"}]}
41
+ Return {"episodeDecisions":[],"candidates":[]} only when there are no mandatory episodes and nothing meets the threshold.`;
32
42
 
33
43
  export function buildKnowledgeExtractionPrompt(
34
44
  projectRoot: string,
35
45
  delta: string,
36
46
  context = "",
37
47
  existing: ExistingKnowledgeContext[] = [],
48
+ evidence: KnowledgeEvidence[] = [],
49
+ episodes: KnowledgeEpisode[] = [],
38
50
  ): string {
39
51
  const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
40
52
  return [
@@ -44,10 +56,45 @@ export function buildKnowledgeExtractionPrompt(
44
56
  "",
45
57
  "<prior_validation_context>", context, "</prior_validation_context>",
46
58
  "",
59
+ "<problem_episodes>", JSON.stringify(episodes), "</problem_episodes>",
60
+ "",
61
+ "<evidence_ledger>", JSON.stringify(evidence), "</evidence_ledger>",
62
+ "",
47
63
  "<conversation_delta>", delta, "</conversation_delta>",
48
64
  ].join("\n");
49
65
  }
50
66
 
67
+ export function parseKnowledgeReviewEnvelope(text: string): KnowledgeReviewEnvelope {
68
+ try {
69
+ const parsed = parseEnvelopeObject(text);
70
+ if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
71
+ const validDecisions = new Set(["add", "reinforce", "extend", "correct", "retire", "skip"]);
72
+ const decisions = Array.isArray(parsed.episodeDecisions)
73
+ ? parsed.episodeDecisions.filter((item): item is KnowledgeEpisodeDecision => {
74
+ if (!item || typeof item !== "object") return false;
75
+ const value = item as Record<string, unknown>;
76
+ return typeof value.episodeId === "string" && validDecisions.has(String(value.decision))
77
+ && typeof value.reason === "string";
78
+ })
79
+ : [];
80
+ return {
81
+ episodeDecisions: decisions,
82
+ candidates: parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates),
83
+ };
84
+ } catch {
85
+ return { episodeDecisions: [], candidates: parseCandidateEnvelope(text) };
86
+ }
87
+ }
88
+
89
+ function parseEnvelopeObject(text: string): Record<string, unknown> {
90
+ const trimmed = text.trim();
91
+ const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
92
+ const start = unfenced.indexOf("{");
93
+ const end = unfenced.lastIndexOf("}");
94
+ if (start < 0 || end <= start) throw new Error("Knowledge reviewer did not return a JSON object");
95
+ return JSON.parse(unfenced.slice(start, end + 1)) as Record<string, unknown>;
96
+ }
97
+
51
98
  export function parseCandidateEnvelope(text: string): unknown[] {
52
99
  const trimmed = text.trim();
53
100
  const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
@@ -6,7 +6,7 @@ import { parentPort, workerData } from "node:worker_threads";
6
6
 
7
7
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
8
8
  import { userRuntimePaths } from "../runtime/paths.ts";
9
- import { parseCandidateEnvelope } from "./extractor.ts";
9
+ import { parseKnowledgeReviewEnvelope } from "./extractor.ts";
10
10
  import { type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
11
11
  import { sanitizeKnowledgeText } from "./sanitize.ts";
12
12
  import { findNextReviewTask } from "./session-scanner.ts";
@@ -331,7 +331,8 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
331
331
  let committed = false;
332
332
  try {
333
333
  if (message.error || message.raw === undefined) throw new Error(message.error || "Knowledge review returned no content");
334
- const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
334
+ const envelope = parseKnowledgeReviewEnvelope(message.raw);
335
+ const result = commitKnowledgeReview(envelope.candidates, task, leaderToken, home, envelope.episodeDecisions);
335
336
  const generationId = loadCurrentManifest(home).generationId;
336
337
  finishReview(task, "saved", { generationId, result });
337
338
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
@@ -1,13 +1,14 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
- import { resolve, sep } from "node:path";
3
+ import { resolve } from "node:path";
4
4
 
5
5
  import {
6
6
  buildContextEntries, parseSessionEntries, type SessionEntry, type SessionHeader,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
10
- import { findLatestWorkflowRoot } from "../working-directory.ts";
10
+ import { canonicalizeDirectory } from "../working-directory.ts";
11
+ import { buildKnowledgeEpisodes, buildKnowledgeEvidence } from "./evidence.ts";
11
12
  import { knowledgeDeltaDigest } from "./extractor.ts";
12
13
  import { sanitizeKnowledgeText } from "./sanitize.ts";
13
14
  import { projectKnowledgeKey } from "./store.ts";
@@ -198,24 +199,8 @@ function buildRecallQuery(entries: SessionEntry[], ids: string[]): string {
198
199
  return lines.join("\n").slice(0, MAX_RECALL_QUERY_CHARS);
199
200
  }
200
201
 
201
- function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
202
- const sessionRoot = resolve(header.cwd);
203
- const workflowRootValue = findLatestWorkflowRoot(branch);
204
- const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
205
- let workingDirectory = sessionRoot;
206
- for (const entry of branch.slice().reverse()) {
207
- if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
208
- || !entry.data || typeof entry.data !== "object") continue;
209
- const cwd = (entry.data as Record<string, unknown>).cwd;
210
- if (typeof cwd !== "string" || !cwd) continue;
211
- workingDirectory = resolve(cwd);
212
- break;
213
- }
214
- if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
215
- return workflowRoot;
216
- }
217
- if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`)) return sessionRoot;
218
- return workingDirectory;
202
+ function projectRootForSession(header: SessionHeader): string {
203
+ try { return canonicalizeDirectory(header.cwd); } catch { return resolve(header.cwd); }
219
204
  }
220
205
 
221
206
  export function discoverSessionFiles(root: string): string[] {
@@ -256,21 +241,29 @@ export function readReviewTask(
256
241
  : -1;
257
242
  const pending = branch.slice(cursorIndex + 1);
258
243
  if (pending.length === 0) return undefined;
244
+ const pendingEntryIds = new Set(pending.map((entry) => entry.id));
245
+ const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
246
+ const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
247
+ const episodes = buildKnowledgeEpisodes(evidence);
259
248
  const delta = buildDelta(pending);
260
249
  const loadedIds = loadedKnowledgeIds(pending);
261
250
  const recallQuery = buildRecallQuery(pending, loadedIds);
262
251
  const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
263
252
  const firstEntryId = pending[0].id;
264
253
  const lastEntryId = pending[pending.length - 1].id;
265
- const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
254
+ const deltaDigest = knowledgeDeltaDigest([
255
+ delta || `${firstEntryId}\0${lastEntryId}`, recallQuery, loadedIds.join("\0"),
256
+ JSON.stringify(evidence), JSON.stringify(episodes),
257
+ ].join("\0"));
266
258
  const sessionFileHash = hash(resolve(sessionFile), 16);
267
259
  const sessionKey = hash(header.id, 24);
268
260
  const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
269
- const projectRoot = projectRootForSession(header, branch);
261
+ const projectRoot = projectRootForSession(header);
270
262
  return {
271
263
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
272
264
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
273
265
  firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
266
+ evidence, episodes,
274
267
  deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
275
268
  };
276
269
  }