@hadooppei/hwcode 1.0.19 → 1.0.20

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.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { sanitizeKnowledgeText } from "./sanitize.js";
2
+ import { containsInjectedSkill, sanitizeKnowledgeText } from "./sanitize.js";
3
3
  const FAILURE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|timeout|timed out|unreachable|servfail|refused)\b|not supported|失败|错误|不支持|超时|无法|不可达)/iu;
4
4
  const VERIFICATION_RE = /(?:\b(?:verified|verification|active|running|succeeded|success|http\s*=?\s*200|downloaded newer image)\b|验证通过|运行成功|成功创建|已恢复)/iu;
5
5
  const CORRECTION_RE = /(?:\b(?:must|should|always|never|correction|most valuable)\b|必须|应该|不应该|纠正|最有价值|不要|务必)/iu;
@@ -189,6 +189,10 @@ export function buildKnowledgeEvidence(entries) {
189
189
  const role = typeof message.role === "string" ? message.role : "";
190
190
  const text = textContent(content).trim();
191
191
  if (role === "user" && text) {
192
+ // Skill bodies and workflow activation envelopes are injected as user-role messages by
193
+ // the host. They are execution context, not an explicit human directive to persist.
194
+ if (containsInjectedSkill(text))
195
+ return;
192
196
  evidence.push({
193
197
  id: evidenceId(entryId, "user"), entryId, timestamp, kind: userKind(text),
194
198
  subject: "User requirement or correction", outcome: "unknown", excerpt: boundedExcerpt(text, 700),
@@ -198,6 +202,10 @@ export function buildKnowledgeEvidence(entries) {
198
202
  if (role === "toolResult" && text) {
199
203
  const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
200
204
  const call = calls.get(toolCallId);
205
+ // Recalled knowledge is comparison context, not fresh execution evidence. Treating a
206
+ // lookup result as verification would let knowledge reinforce itself merely by loading it.
207
+ if (call?.name === "hwcode_knowledge_lookup")
208
+ return;
201
209
  const outcome = outcomeFor(message, text);
202
210
  const callText = call ? `[call] ${call.name}: ${compactArguments(call.args)}\n` : "";
203
211
  evidence.push({
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
3
+ import { compactKnowledgeText } from "./sanitize.js";
3
4
  export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
4
5
  Review the supplied structured evidence and conversation delta. Return strict JSON and no markdown.
5
6
  Persist only knowledge that is likely to be useful in future sessions:
@@ -37,117 +38,54 @@ export function buildKnowledgeExtractionPrompt(projectRoot, delta, context = "",
37
38
  ].join("\n");
38
39
  }
39
40
  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
- }
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
+ };
62
58
  }
63
59
  function parseEnvelopeObject(text) {
64
60
  const trimmed = text.trim();
65
61
  const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
66
62
  const start = unfenced.indexOf("{");
67
63
  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
- }
72
- export function parseCandidateEnvelope(text) {
73
- const trimmed = text.trim();
74
- const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
75
- const start = unfenced.indexOf("{");
76
- const end = unfenced.lastIndexOf("}");
77
- if (start < 0 || end <= start)
78
- throw new Error("Knowledge reviewer did not return a JSON object");
79
- let strictError;
64
+ if (start < 0 || end <= start) {
65
+ throw new Error([
66
+ "Knowledge reviewer did not return a complete JSON object",
67
+ `responseChars=${unfenced.length}`,
68
+ `responseExcerpt=${JSON.stringify(compactKnowledgeText(unfenced, 1_200))}`,
69
+ ].join("; "));
70
+ }
71
+ const payload = unfenced.slice(start, end + 1);
80
72
  try {
81
- const parsed = JSON.parse(unfenced.slice(start, end + 1));
82
- if (!Array.isArray(parsed.candidates))
83
- throw new Error("Knowledge reviewer response is missing candidates[]");
84
- return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
73
+ return JSON.parse(payload);
85
74
  }
86
75
  catch (error) {
87
- strictError = error;
88
- }
89
- const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
90
- const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
91
- if (arrayStart >= 0) {
92
- const candidates = [];
93
- let inString = false;
94
- let escaped = false;
95
- for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
96
- const character = unfenced[index];
97
- if (inString) {
98
- if (escaped)
99
- escaped = false;
100
- else if (character === "\\")
101
- escaped = true;
102
- else if (character === '"')
103
- inString = false;
104
- continue;
105
- }
106
- if (character === '"') {
107
- inString = true;
108
- continue;
109
- }
110
- if (character !== "{")
111
- continue;
112
- const objectEnd = balancedObjectEnd(unfenced, index);
113
- if (objectEnd < 0)
114
- break;
115
- try {
116
- candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1)));
117
- }
118
- catch { /* Salvage other complete candidates. */ }
119
- index = objectEnd;
120
- }
121
- if (candidates.length > 0)
122
- return candidates;
123
- }
124
- throw strictError;
125
- }
126
- function balancedObjectEnd(value, start) {
127
- let depth = 0;
128
- let inString = false;
129
- let escaped = false;
130
- for (let index = start; index < value.length; index++) {
131
- const character = value[index];
132
- if (inString) {
133
- if (escaped)
134
- escaped = false;
135
- else if (character === "\\")
136
- escaped = true;
137
- else if (character === '"')
138
- inString = false;
139
- continue;
140
- }
141
- if (character === '"') {
142
- inString = true;
143
- continue;
144
- }
145
- if (character === "{")
146
- depth++;
147
- else if (character === "}" && --depth === 0)
148
- return index;
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ const offsetMatch = /position\s+(\d+)/iu.exec(message);
78
+ const offset = offsetMatch ? Number.parseInt(offsetMatch[1], 10) : -1;
79
+ const excerptStart = offset >= 0 ? Math.max(0, offset - 600) : 0;
80
+ const excerptEnd = offset >= 0 ? Math.min(payload.length, offset + 600) : Math.min(payload.length, 1_200);
81
+ const excerpt = compactKnowledgeText(payload.slice(excerptStart, excerptEnd), 1_200);
82
+ throw new Error([
83
+ `Knowledge reviewer returned invalid JSON: ${message}`,
84
+ `responseChars=${payload.length}`,
85
+ offset >= 0 ? `errorOffset=${offset}` : undefined,
86
+ `responseExcerpt=${JSON.stringify(excerpt)}`,
87
+ ].filter(Boolean).join("; "));
149
88
  }
150
- return -1;
151
89
  }
152
90
  export function knowledgeDeltaDigest(delta) {
153
91
  return createHash("sha256").update(delta).digest("hex");
@@ -6,7 +6,7 @@ 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
8
  import { parseKnowledgeReviewEnvelope } from "./extractor.js";
9
- import { writeKnowledgeReviewStatus } from "./review-status.js";
9
+ import { readKnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.js";
10
10
  import { sanitizeKnowledgeText } from "./sanitize.js";
11
11
  import { findNextReviewTask } from "./session-scanner.js";
12
12
  import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.js";
@@ -54,9 +54,28 @@ function publishReviewStatus() {
54
54
  }
55
55
  }
56
56
  function boundedError(error) {
57
- return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error)).slice(0, 1_000);
57
+ return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error))
58
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxFailureDetailChars);
59
+ }
60
+ function clearSupersededReviewState(task) {
61
+ if (!reviewStatus)
62
+ return;
63
+ for (const field of ["blockedReviews", "reviewFailures"]) {
64
+ const records = reviewStatus[field];
65
+ if (!records)
66
+ continue;
67
+ for (const [reviewKey, record] of Object.entries(records)) {
68
+ if (record.sessionKey === task.sessionKey && reviewKey !== task.reviewKey) {
69
+ delete records[reviewKey];
70
+ reviewAttempts.delete(reviewKey);
71
+ }
72
+ }
73
+ if (Object.keys(records).length === 0)
74
+ reviewStatus[field] = undefined;
75
+ }
58
76
  }
59
77
  function beginReview(task) {
78
+ clearSupersededReviewState(task);
60
79
  activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
61
80
  reviewAttempts.set(task.reviewKey, activeAttempt);
62
81
  activeStartedAt = Date.now();
@@ -64,11 +83,17 @@ function beginReview(task) {
64
83
  activeDeadlineTimer = setTimeout(() => {
65
84
  if (activeTask?.requestId !== task.requestId)
66
85
  return;
67
- const error = "knowledge-review-result-timeout";
68
- finishReview(task, "failed", { error });
86
+ const error = [
87
+ `knowledge-review-result-timeout after ${KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000}ms`,
88
+ `reviewKey=${task.reviewKey}`,
89
+ `deltaChars=${task.delta.length}`,
90
+ `evidenceItems=${task.evidence?.length ?? 0}`,
91
+ `episodeItems=${task.episodes?.length ?? 0}`,
92
+ ].join("; ");
93
+ const terminal = finishReview(task, "failed", { error });
69
94
  activeTask = undefined;
70
95
  send({ type: "review_cancel", requestId: task.requestId, reason: error });
71
- send({ type: "review_failed", requestId: task.requestId, error });
96
+ send({ type: "review_failed", requestId: task.requestId, error, terminal });
72
97
  }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
73
98
  activeDeadlineTimer.unref();
74
99
  if (reviewStatus) {
@@ -87,22 +112,57 @@ function finishReview(task, outcome, detail) {
87
112
  clearTimeout(activeDeadlineTimer);
88
113
  activeDeadlineTimer = undefined;
89
114
  if (!reviewStatus)
90
- return;
115
+ return false;
116
+ const error = detail.error === undefined ? undefined : boundedError(detail.error);
117
+ const exhausted = outcome === "failed" && activeAttempt >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxAttempts;
118
+ const completedAt = new Date().toISOString();
119
+ const durationMs = Math.max(0, Date.now() - activeStartedAt);
91
120
  reviewStatus.activeReview = undefined;
92
121
  reviewStatus.lastReview = {
93
122
  reviewKey: task.reviewKey,
94
123
  sessionKey: task.sessionKey,
95
124
  attempt: activeAttempt,
96
125
  outcome,
97
- completedAt: new Date().toISOString(),
98
- durationMs: Math.max(0, Date.now() - activeStartedAt),
126
+ completedAt,
127
+ durationMs,
99
128
  generationId: detail.generationId,
100
129
  result: detail.result,
101
- error: detail.error === undefined ? undefined : boundedError(detail.error),
130
+ error,
102
131
  };
103
- if (outcome === "saved")
132
+ if (outcome === "saved") {
104
133
  reviewAttempts.delete(task.reviewKey);
134
+ delete reviewStatus.reviewFailures?.[task.reviewKey];
135
+ clearSupersededReviewState(task);
136
+ }
137
+ else {
138
+ const reviewFailures = reviewStatus.reviewFailures ?? {};
139
+ const prior = reviewFailures[task.reviewKey];
140
+ reviewFailures[task.reviewKey] = {
141
+ sessionKey: task.sessionKey,
142
+ sessionFileHash: task.sessionFileHash,
143
+ attempts: [...(prior?.attempts ?? []), {
144
+ attempt: activeAttempt,
145
+ failedAt: completedAt,
146
+ durationMs,
147
+ error: error ?? "unknown-review-failure",
148
+ }].slice(-KNOWLEDGE_RUNTIME_DEFAULTS.review.maxAttempts),
149
+ };
150
+ reviewStatus.reviewFailures = Object.fromEntries(Object.entries(reviewFailures).slice(-100));
151
+ }
152
+ if (exhausted) {
153
+ const blockedReviews = reviewStatus.blockedReviews ?? {};
154
+ blockedReviews[task.reviewKey] = {
155
+ reviewKey: task.reviewKey,
156
+ sessionKey: task.sessionKey,
157
+ sessionFileHash: task.sessionFileHash,
158
+ blockedAt: new Date().toISOString(),
159
+ attempts: activeAttempt,
160
+ error: error ?? "unknown-review-failure",
161
+ };
162
+ reviewStatus.blockedReviews = Object.fromEntries(Object.entries(blockedReviews).slice(-100));
163
+ }
105
164
  publishReviewStatus();
165
+ return exhausted;
106
166
  }
107
167
  function deferReview(task, reason) {
108
168
  clearTimeout(activeDeadlineTimer);
@@ -149,8 +209,14 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)) {
149
209
  timer.unref();
150
210
  }
151
211
  function stepDown(reason) {
152
- if (activeTask)
212
+ if (activeTask) {
213
+ const attempts = reviewAttempts.get(activeTask.reviewKey) ?? 1;
214
+ if (attempts <= 1)
215
+ reviewAttempts.delete(activeTask.reviewKey);
216
+ else
217
+ reviewAttempts.set(activeTask.reviewKey, attempts - 1);
153
218
  send({ type: "review_cancel", requestId: activeTask.requestId, reason });
219
+ }
154
220
  if (reviewStatus) {
155
221
  reviewStatus.state = "stepped-down";
156
222
  reviewStatus.stepDownReason = reason;
@@ -277,8 +343,17 @@ function becomeLeader(server) {
277
343
  scheduleElection();
278
344
  });
279
345
  writeLeaderMetadata(electedAt);
346
+ const priorStatus = readKnowledgeReviewStatus(home);
347
+ reviewAttempts.clear();
348
+ for (const [reviewKey, failure] of Object.entries(priorStatus?.reviewFailures ?? {})) {
349
+ const attempt = Math.max(0, ...failure.attempts.map((item) => item.attempt));
350
+ if (attempt > 0)
351
+ reviewAttempts.set(reviewKey, attempt);
352
+ }
280
353
  reviewStatus = {
281
354
  version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
355
+ blockedReviews: priorStatus?.blockedReviews,
356
+ reviewFailures: priorStatus?.reviewFailures,
282
357
  };
283
358
  publishReviewStatus();
284
359
  send({ type: "leadership", state: "leader", leaderToken });
@@ -315,7 +390,8 @@ async function scanForReview() {
315
390
  }
316
391
  try {
317
392
  const manifest = loadCurrentManifest(home);
318
- const task = findNextReviewTask(sessionsRoot, manifest.reviews);
393
+ const blockedReviewKeys = new Set(Object.keys(reviewStatus?.blockedReviews ?? {}));
394
+ const task = findNextReviewTask(sessionsRoot, manifest.reviews, Date.now(), blockedReviewKeys);
319
395
  if (!task)
320
396
  return;
321
397
  activeTask = task;
@@ -336,14 +412,14 @@ async function scanForReview() {
336
412
  send({ type: "review_request", leaderToken, requestId: task.requestId, task });
337
413
  }
338
414
  catch (error) {
339
- if (activeTask)
340
- finishReview(activeTask, "failed", { error });
341
- else if (reviewStatus) {
415
+ const failedTask = activeTask;
416
+ const terminal = failedTask ? finishReview(failedTask, "failed", { error }) : true;
417
+ if (!failedTask && reviewStatus) {
342
418
  reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
343
419
  publishReviewStatus();
344
420
  }
345
421
  activeTask = undefined;
346
- send({ type: "review_failed", error: boundedError(error) });
422
+ send({ type: "review_failed", requestId: failedTask?.requestId, error: boundedError(error), terminal });
347
423
  }
348
424
  }
349
425
  function handleReviewResult(message) {
@@ -366,8 +442,8 @@ function handleReviewResult(message) {
366
442
  committed = true;
367
443
  }
368
444
  catch (error) {
369
- finishReview(task, "failed", { error });
370
- send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
445
+ const terminal = finishReview(task, "failed", { error });
446
+ send({ type: "review_failed", requestId: task.requestId, error: boundedError(error), terminal });
371
447
  }
372
448
  finally {
373
449
  activeTask = undefined;
@@ -3,6 +3,14 @@ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/giu;
3
3
  const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
4
4
  const SECRET_ASSIGNMENT_RE = /\b(access[_-]?key|secret(?:[_-]?(?:access|key))?|password|passwd|token|credential|client[_-]?secret)\b(\s*[:=]\s*)[^\s,;]+/giu;
5
5
  const URL_CREDENTIAL_RE = /(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu;
6
+ const INJECTED_SKILL_BLOCK_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
7
+ const INJECTED_SKILL_MARKER_RE = /<skill\b[^>]*>/iu;
8
+ export function containsInjectedSkill(value) {
9
+ return INJECTED_SKILL_MARKER_RE.test(value);
10
+ }
11
+ export function stripInjectedSkillBlocks(value) {
12
+ return value.replace(INJECTED_SKILL_BLOCK_RE, "");
13
+ }
6
14
  export function sanitizeKnowledgeText(value) {
7
15
  return value
8
16
  .replace(PRIVATE_KEY_RE, "<redacted-private-key>")
@@ -6,10 +6,9 @@ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
6
6
  import { canonicalizeDirectory } from "../working-directory.js";
7
7
  import { buildKnowledgeEpisodes, buildKnowledgeEvidence } from "./evidence.js";
8
8
  import { knowledgeDeltaDigest } from "./extractor.js";
9
- import { sanitizeKnowledgeText } from "./sanitize.js";
9
+ import { containsInjectedSkill, sanitizeKnowledgeText, stripInjectedSkillBlocks } from "./sanitize.js";
10
10
  import { projectKnowledgeKey } from "./store.js";
11
11
  const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
12
- const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
13
12
  const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
14
13
  const MAX_RECALL_QUERY_CHARS = 4_000;
15
14
  const MAX_LOADED_KNOWLEDGE_IDS = 8;
@@ -42,17 +41,53 @@ function reviewPiece(entry, index) {
42
41
  return undefined;
43
42
  const message = entry.message;
44
43
  const role = typeof message.role === "string" ? message.role : "message";
45
- const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
44
+ const rawContent = textContent(message.content);
45
+ const injectedContext = role === "user" && containsInjectedSkill(rawContent);
46
+ const content = sanitizeKnowledgeText(injectedContext ? stripInjectedSkillBlocks(rawContent) : rawContent);
46
47
  if (!content)
47
48
  return undefined;
49
+ if (injectedContext) {
50
+ return { index, priority: 3, category: "assistant", text: `[injected_context]\n${content.slice(0, 4_000)}` };
51
+ }
48
52
  const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
49
53
  : role === "toolResult" ? 4 : 3;
50
54
  const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
51
55
  const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
52
56
  return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
53
57
  }
54
- function buildDelta(entries) {
55
- const pieces = entries.map(reviewPiece).filter((piece) => Boolean(piece));
58
+ function entryId(entry) {
59
+ const value = entry.id;
60
+ return typeof value === "string" ? value : undefined;
61
+ }
62
+ function knowledgeLookupResultEntryIds(entries) {
63
+ const lookupCalls = new Set();
64
+ const resultEntries = new Set();
65
+ for (const entry of entries) {
66
+ const message = messageRecord(entry);
67
+ const content = message?.content;
68
+ if (Array.isArray(content)) {
69
+ for (const raw of content) {
70
+ if (!raw || typeof raw !== "object")
71
+ continue;
72
+ const item = raw;
73
+ if (item.type === "toolCall" && item.name === "hwcode_knowledge_lookup" && typeof item.id === "string") {
74
+ lookupCalls.add(item.id);
75
+ }
76
+ }
77
+ }
78
+ if (message?.role === "toolResult" && typeof message.toolCallId === "string" && lookupCalls.has(message.toolCallId)) {
79
+ const id = entryId(entry);
80
+ if (id)
81
+ resultEntries.add(id);
82
+ }
83
+ }
84
+ return resultEntries;
85
+ }
86
+ function buildDelta(entries, excludedEntryIds = new Set()) {
87
+ const pieces = entries.map((entry, index) => {
88
+ const id = entryId(entry);
89
+ return id && excludedEntryIds.has(id) ? undefined : reviewPiece(entry, index);
90
+ }).filter((piece) => Boolean(piece));
56
91
  const selected = [];
57
92
  const categoryRemaining = {
58
93
  user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
@@ -79,8 +114,11 @@ function buildDelta(entries) {
79
114
  return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
80
115
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
81
116
  }
82
- function buildValidationContext(entries) {
83
- const pieces = entries.map(reviewPiece).filter((piece) => Boolean(piece));
117
+ function buildValidationContext(entries, excludedEntryIds = new Set()) {
118
+ const pieces = entries.map((entry, index) => {
119
+ const id = entryId(entry);
120
+ return id && excludedEntryIds.has(id) ? undefined : reviewPiece(entry, index);
121
+ }).filter((piece) => Boolean(piece));
84
122
  const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
85
123
  const evidence = pieces.filter((piece) => ((piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
86
124
  && FAILURE_EVIDENCE_RE.test(piece.text)));
@@ -184,7 +222,10 @@ function buildRecallQuery(entries, ids) {
184
222
  const message = messageRecord(entry);
185
223
  if (!message || message.role !== "user")
186
224
  continue;
187
- const text = compactRecallValue(textContent(message.content).replace(INJECTED_SKILL_RE, ""), 800);
225
+ const raw = textContent(message.content);
226
+ if (containsInjectedSkill(raw))
227
+ continue;
228
+ const text = compactRecallValue(raw, 800);
188
229
  if (text)
189
230
  lines.push(`User request: ${text}`);
190
231
  if (lines.filter((line) => line.startsWith("User request:")).length >= 2)
@@ -273,10 +314,13 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
273
314
  const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
274
315
  const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
275
316
  const episodes = buildKnowledgeEpisodes(evidence);
276
- const delta = buildDelta(pending);
317
+ const lookupResultEntryIds = knowledgeLookupResultEntryIds(branch);
318
+ const delta = buildDelta(pending, lookupResultEntryIds);
277
319
  const loadedIds = loadedKnowledgeIds(pending);
278
320
  const recallQuery = buildRecallQuery(pending, loadedIds);
279
- const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
321
+ const context = cursorIndex >= 0
322
+ ? buildValidationContext(branch.slice(0, cursorIndex + 1), lookupResultEntryIds)
323
+ : "";
280
324
  const firstEntryId = pending[0].id;
281
325
  const lastEntryId = pending[pending.length - 1].id;
282
326
  const deltaDigest = knowledgeDeltaDigest([
@@ -295,7 +339,7 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
295
339
  deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
296
340
  };
297
341
  }
298
- export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
342
+ export function findNextReviewTask(sessionsRoot, reviews, now = Date.now(), excludedReviewKeys = new Set()) {
299
343
  const files = discoverSessionFiles(sessionsRoot).map((path) => {
300
344
  try {
301
345
  return { path, mtimeMs: statSync(path).mtimeMs };
@@ -312,7 +356,7 @@ export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
312
356
  const stableCursor = task ? reviews[task.sessionKey] : undefined;
313
357
  if (task && stableCursor && stableCursor !== cursor)
314
358
  task = readReviewTask(file.path, stableCursor, now);
315
- if (task)
359
+ if (task && !excludedReviewKeys.has(task.reviewKey))
316
360
  return task;
317
361
  }
318
362
  return undefined;
@@ -31,6 +31,8 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
31
31
  idleMs: 60_000,
32
32
  capabilityPollMs: 5_000,
33
33
  modelTimeoutMs: 180_000,
34
+ maxAttempts: 2,
35
+ maxFailureDetailChars: 4_000,
34
36
  maxDeltaChars: 12_000,
35
37
  maxPriorContextChars: 4_000,
36
38
  maxNarrativeChars: 6_000,
@@ -22,6 +22,7 @@ interface ActiveReview {
22
22
  leaderToken: string;
23
23
  controller: AbortController;
24
24
  abortReason?: string;
25
+ diagnostic?: string;
25
26
  }
26
27
 
27
28
  interface ProcessKnowledgeRuntime {
@@ -117,7 +118,7 @@ function reportWorkerIssue(error: unknown): void {
117
118
  const prior = runtime.lastWorkerIssue;
118
119
  if (prior?.message === message && now - prior.reportedAt < 60_000) return;
119
120
  runtime.lastWorkerIssue = { message, reportedAt: now };
120
- const rendered = `HWCode knowledge worker failed: ${message}`;
121
+ const rendered = `HWCode knowledge worker failed: ${message.slice(0, 600)}`;
121
122
  if (runtime.context?.hasUI) runtime.context.ui.notify(rendered, "error");
122
123
  else console.error(rendered);
123
124
  }
@@ -162,7 +163,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
162
163
  runtime.activeReview = review;
163
164
  timeout = setTimeout(() => {
164
165
  if (controller.signal.aborted) return;
165
- review!.abortReason = "knowledge-review-model-timeout";
166
+ review!.abortReason = `knowledge-review-model-timeout after ${KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs}ms${review!.diagnostic ? `; ${review!.diagnostic}` : ""}`;
166
167
  controller.abort();
167
168
  }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
168
169
  timeout.unref();
@@ -210,6 +211,23 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
210
211
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyChars);
211
212
  return content ? { ...reference, content } : reference;
212
213
  });
214
+ const prompt = buildKnowledgeExtractionPrompt(
215
+ message.task.projectRoot,
216
+ message.task.delta.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxNarrativeChars),
217
+ message.task.context,
218
+ related,
219
+ promptContext.evidence,
220
+ promptContext.episodes,
221
+ );
222
+ review.diagnostic = [
223
+ `reviewKey=${message.task.reviewKey}`,
224
+ `model=${ctx.model.provider}/${ctx.model.id}`,
225
+ `promptChars=${prompt.length}`,
226
+ `deltaChars=${message.task.delta.length}`,
227
+ `evidenceItems=${promptContext.evidence.length}`,
228
+ `episodeItems=${promptContext.episodes.length}`,
229
+ `existingItems=${related.length}`,
230
+ ].join("; ");
213
231
  const response = await ctx.modelRegistry.complete(
214
232
  ctx.model,
215
233
  {
@@ -218,14 +236,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
218
236
  role: "user",
219
237
  content: [{
220
238
  type: "text",
221
- text: buildKnowledgeExtractionPrompt(
222
- message.task.projectRoot,
223
- message.task.delta.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxNarrativeChars),
224
- message.task.context,
225
- related,
226
- promptContext.evidence,
227
- promptContext.episodes,
228
- ),
239
+ text: prompt,
229
240
  }],
230
241
  timestamp: Date.now(),
231
242
  }],
@@ -237,10 +248,14 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
237
248
  .map((item) => item.text).join("\n");
238
249
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
239
250
  } catch (error) {
240
- const reason = review?.controller.signal.aborted
251
+ const baseReason = review?.controller.signal.aborted
241
252
  ? review.abortReason ?? "knowledge-review-interrupted"
242
253
  : error instanceof Error ? error.message : String(error);
243
- if (reason === "model-executor-is-busy" || isExpectedReviewInterruption(reason)) {
254
+ const expectedInterruption = baseReason === "model-executor-is-busy" || isExpectedReviewInterruption(baseReason);
255
+ const reason = review?.diagnostic && !expectedInterruption && !baseReason.includes("reviewKey=")
256
+ ? `${baseReason}; ${review.diagnostic}`
257
+ : baseReason;
258
+ if (expectedInterruption) {
244
259
  post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
245
260
  } else {
246
261
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
@@ -263,7 +278,7 @@ function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
263
278
  cancelActiveReview(message.reason);
264
279
  return;
265
280
  }
266
- if (message.type === "review_failed") reportWorkerIssue(message.error);
281
+ if (message.type === "review_failed" && message.terminal !== false) reportWorkerIssue(message.error);
267
282
  }
268
283
 
269
284
  function ensureWorker(): Worker | undefined {