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