@hadooppei/hwcode 1.0.19 → 1.0.22

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;
@@ -81,8 +81,11 @@ function boundedExcerpt(value, limit = 900) {
81
81
  if (clean.length <= limit)
82
82
  return clean;
83
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)];
84
+ const priority = (line) => REVERSAL_RE.test(line) ? 0
85
+ : FAILURE_RE.test(line) && VERIFICATION_RE.test(line) ? 1
86
+ : FAILURE_RE.test(line) ? 2 : 3;
87
+ const significant = lines.filter((line) => (REVERSAL_RE.test(line) || FAILURE_RE.test(line) || VERIFICATION_RE.test(line))).sort((left, right) => priority(left) - priority(right));
88
+ const selected = [...lines.slice(0, 2), ...significant.slice(0, 7), ...lines.slice(-2)];
86
89
  return [...new Set(selected)].join("\n").slice(0, limit);
87
90
  }
88
91
  function compactArguments(args) {
@@ -152,10 +155,8 @@ function entryIdOf(entry, index) {
152
155
  function evidenceId(entryId, suffix) {
153
156
  return `ev-${digest(`${entryId}\0${suffix}`)}`;
154
157
  }
155
- function outcomeFor(message, text) {
156
- if (message.isError === true || FAILURE_RE.test(text))
157
- return "failure";
158
- return "success";
158
+ function outcomeFor(message) {
159
+ return message.isError === true ? "failure" : "success";
159
160
  }
160
161
  function userKind(text) {
161
162
  return CORRECTION_RE.test(text) ? "user-correction" : "user-directive";
@@ -189,6 +190,10 @@ export function buildKnowledgeEvidence(entries) {
189
190
  const role = typeof message.role === "string" ? message.role : "";
190
191
  const text = textContent(content).trim();
191
192
  if (role === "user" && text) {
193
+ // Skill bodies and workflow activation envelopes are injected as user-role messages by
194
+ // the host. They are execution context, not an explicit human directive to persist.
195
+ if (containsInjectedSkill(text))
196
+ return;
192
197
  evidence.push({
193
198
  id: evidenceId(entryId, "user"), entryId, timestamp, kind: userKind(text),
194
199
  subject: "User requirement or correction", outcome: "unknown", excerpt: boundedExcerpt(text, 700),
@@ -198,11 +203,15 @@ export function buildKnowledgeEvidence(entries) {
198
203
  if (role === "toolResult" && text) {
199
204
  const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
200
205
  const call = calls.get(toolCallId);
201
- const outcome = outcomeFor(message, text);
206
+ // Recalled knowledge is comparison context, not fresh execution evidence. Treating a
207
+ // lookup result as verification would let knowledge reinforce itself merely by loading it.
208
+ if (call?.name === "hwcode_knowledge_lookup")
209
+ return;
210
+ const outcome = outcomeFor(message);
202
211
  const callText = call ? `[call] ${call.name}: ${compactArguments(call.args)}\n` : "";
203
212
  evidence.push({
204
213
  id: evidenceId(entryId, toolCallId || "tool"), entryId, timestamp,
205
- kind: outcome === "failure" ? "tool-failure" : VERIFICATION_RE.test(text) ? "verification" : "tool-success",
214
+ kind: outcome === "failure" ? "tool-failure" : "tool-success",
206
215
  subject: evidenceSubject(call, text), operation: operationName(call), outcome,
207
216
  excerpt: boundedExcerpt(`${callText}[result] ${text}`),
208
217
  });
@@ -211,7 +220,7 @@ export function buildKnowledgeEvidence(entries) {
211
220
  if (role === "assistant" && text && (FAILURE_RE.test(text) || VERIFICATION_RE.test(text) || REVERSAL_RE.test(text))) {
212
221
  evidence.push({
213
222
  id: evidenceId(entryId, "assistant"), entryId, timestamp,
214
- kind: VERIFICATION_RE.test(text) ? "verification" : "assistant-claim",
223
+ kind: "assistant-claim",
215
224
  subject: "Assistant diagnosis or conclusion", outcome: "unknown", excerpt: boundedExcerpt(text, 800),
216
225
  });
217
226
  }
@@ -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:
@@ -13,9 +14,11 @@ Treat failed, unsupported, invalid, or corrected operations as negative evidence
13
14
  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
15
  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
16
  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.
17
+ Prefer the most specific existing topic that already covers a claim over a broad conventions or SOP topic.
16
18
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
17
19
  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
20
  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.
21
+ Keep each candidate atomic: put separable claim sets in separate candidates, omit any claim without direct cited evidence, and prioritize the highest-value supported candidates. One unsupported claim rejects its entire candidate.
19
22
  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.
20
23
  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.
21
24
  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"}]}
@@ -37,117 +40,54 @@ export function buildKnowledgeExtractionPrompt(projectRoot, delta, context = "",
37
40
  ].join("\n");
38
41
  }
39
42
  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
- }
43
+ const parsed = parseEnvelopeObject(text);
44
+ if (!Array.isArray(parsed.candidates))
45
+ throw new Error("Knowledge reviewer response is missing candidates[]");
46
+ const validDecisions = new Set(["add", "reinforce", "extend", "correct", "retire", "skip"]);
47
+ const decisions = Array.isArray(parsed.episodeDecisions)
48
+ ? parsed.episodeDecisions.filter((item) => {
49
+ if (!item || typeof item !== "object")
50
+ return false;
51
+ const value = item;
52
+ return typeof value.episodeId === "string" && validDecisions.has(String(value.decision))
53
+ && typeof value.reason === "string";
54
+ })
55
+ : [];
56
+ return {
57
+ episodeDecisions: decisions,
58
+ candidates: parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates),
59
+ };
62
60
  }
63
61
  function parseEnvelopeObject(text) {
64
62
  const trimmed = text.trim();
65
63
  const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
66
64
  const start = unfenced.indexOf("{");
67
65
  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;
66
+ if (start < 0 || end <= start) {
67
+ throw new Error([
68
+ "Knowledge reviewer did not return a complete JSON object",
69
+ `responseChars=${unfenced.length}`,
70
+ `responseExcerpt=${JSON.stringify(compactKnowledgeText(unfenced, 1_200))}`,
71
+ ].join("; "));
72
+ }
73
+ const payload = unfenced.slice(start, end + 1);
80
74
  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);
75
+ return JSON.parse(payload);
85
76
  }
86
77
  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;
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ const offsetMatch = /position\s+(\d+)/iu.exec(message);
80
+ const offset = offsetMatch ? Number.parseInt(offsetMatch[1], 10) : -1;
81
+ const excerptStart = offset >= 0 ? Math.max(0, offset - 600) : 0;
82
+ const excerptEnd = offset >= 0 ? Math.min(payload.length, offset + 600) : Math.min(payload.length, 1_200);
83
+ const excerpt = compactKnowledgeText(payload.slice(excerptStart, excerptEnd), 1_200);
84
+ throw new Error([
85
+ `Knowledge reviewer returned invalid JSON: ${message}`,
86
+ `responseChars=${payload.length}`,
87
+ offset >= 0 ? `errorOffset=${offset}` : undefined,
88
+ `responseExcerpt=${JSON.stringify(excerpt)}`,
89
+ ].filter(Boolean).join("; "));
149
90
  }
150
- return -1;
151
91
  }
152
92
  export function knowledgeDeltaDigest(delta) {
153
93
  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;
@@ -30,14 +30,18 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
30
30
  intervalMs: 60_000,
31
31
  idleMs: 60_000,
32
32
  capabilityPollMs: 5_000,
33
+ requestTimeoutMs: 45_000,
33
34
  modelTimeoutMs: 180_000,
35
+ maxOutputTokens: 4_096,
36
+ maxAttempts: 2,
37
+ maxFailureDetailChars: 4_000,
34
38
  maxDeltaChars: 12_000,
35
39
  maxPriorContextChars: 4_000,
36
40
  maxNarrativeChars: 6_000,
37
41
  maxEvidenceItems: 24,
38
42
  maxEvidenceChars: 12_000,
39
43
  mandatoryEpisodeScore: 45,
40
- maxExistingContextItems: 5,
44
+ maxExistingContextItems: 8,
41
45
  maxExistingBodyItems: 2,
42
46
  maxExistingBodyChars: 600,
43
47
  maxCandidates: 3,