@hadooppei/hwcode 1.0.16 → 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.
@@ -5,8 +5,8 @@ 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";
9
- import { writeKnowledgeReviewStatus } from "./review-status.js";
8
+ import { parseKnowledgeReviewEnvelope } from "./extractor.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) {
@@ -354,7 +430,8 @@ function handleReviewResult(message) {
354
430
  try {
355
431
  if (message.error || message.raw === undefined)
356
432
  throw new Error(message.error || "Knowledge review returned no content");
357
- const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
433
+ const envelope = parseKnowledgeReviewEnvelope(message.raw);
434
+ const result = commitKnowledgeReview(envelope.candidates, task, leaderToken, home, envelope.episodeDecisions);
358
435
  const generationId = loadCurrentManifest(home).generationId;
359
436
  finishReview(task, "saved", { generationId, result });
360
437
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
@@ -365,8 +442,8 @@ function handleReviewResult(message) {
365
442
  committed = true;
366
443
  }
367
444
  catch (error) {
368
- finishReview(task, "failed", { error });
369
- 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 });
370
447
  }
371
448
  finally {
372
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>")
@@ -4,11 +4,11 @@ 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
6
  import { canonicalizeDirectory } from "../working-directory.js";
7
+ import { buildKnowledgeEpisodes, buildKnowledgeEvidence } from "./evidence.js";
7
8
  import { knowledgeDeltaDigest } from "./extractor.js";
8
- import { sanitizeKnowledgeText } from "./sanitize.js";
9
+ import { containsInjectedSkill, sanitizeKnowledgeText, stripInjectedSkillBlocks } from "./sanitize.js";
9
10
  import { projectKnowledgeKey } from "./store.js";
10
11
  const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
11
- const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
12
12
  const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
13
13
  const MAX_RECALL_QUERY_CHARS = 4_000;
14
14
  const MAX_LOADED_KNOWLEDGE_IDS = 8;
@@ -41,17 +41,53 @@ function reviewPiece(entry, index) {
41
41
  return undefined;
42
42
  const message = entry.message;
43
43
  const role = typeof message.role === "string" ? message.role : "message";
44
- 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);
45
47
  if (!content)
46
48
  return undefined;
49
+ if (injectedContext) {
50
+ return { index, priority: 3, category: "assistant", text: `[injected_context]\n${content.slice(0, 4_000)}` };
51
+ }
47
52
  const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
48
53
  : role === "toolResult" ? 4 : 3;
49
54
  const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
50
55
  const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
51
56
  return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
52
57
  }
53
- function buildDelta(entries) {
54
- 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));
55
91
  const selected = [];
56
92
  const categoryRemaining = {
57
93
  user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
@@ -78,8 +114,11 @@ function buildDelta(entries) {
78
114
  return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
79
115
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
80
116
  }
81
- function buildValidationContext(entries) {
82
- 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));
83
122
  const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
84
123
  const evidence = pieces.filter((piece) => ((piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
85
124
  && FAILURE_EVIDENCE_RE.test(piece.text)));
@@ -183,7 +222,10 @@ function buildRecallQuery(entries, ids) {
183
222
  const message = messageRecord(entry);
184
223
  if (!message || message.role !== "user")
185
224
  continue;
186
- 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);
187
229
  if (text)
188
230
  lines.push(`User request: ${text}`);
189
231
  if (lines.filter((line) => line.startsWith("User request:")).length >= 2)
@@ -268,13 +310,23 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
268
310
  const pending = branch.slice(cursorIndex + 1);
269
311
  if (pending.length === 0)
270
312
  return undefined;
271
- const delta = buildDelta(pending);
313
+ const pendingEntryIds = new Set(pending.map((entry) => entry.id));
314
+ const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
315
+ const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
316
+ const episodes = buildKnowledgeEpisodes(evidence);
317
+ const lookupResultEntryIds = knowledgeLookupResultEntryIds(branch);
318
+ const delta = buildDelta(pending, lookupResultEntryIds);
272
319
  const loadedIds = loadedKnowledgeIds(pending);
273
320
  const recallQuery = buildRecallQuery(pending, loadedIds);
274
- const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
321
+ const context = cursorIndex >= 0
322
+ ? buildValidationContext(branch.slice(0, cursorIndex + 1), lookupResultEntryIds)
323
+ : "";
275
324
  const firstEntryId = pending[0].id;
276
325
  const lastEntryId = pending[pending.length - 1].id;
277
- const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
326
+ const deltaDigest = knowledgeDeltaDigest([
327
+ delta || `${firstEntryId}\0${lastEntryId}`, recallQuery, loadedIds.join("\0"),
328
+ JSON.stringify(evidence), JSON.stringify(episodes),
329
+ ].join("\0"));
278
330
  const sessionFileHash = hash(resolve(sessionFile), 16);
279
331
  const sessionKey = hash(header.id, 24);
280
332
  const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
@@ -283,10 +335,11 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
283
335
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
284
336
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
285
337
  firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
338
+ evidence, episodes,
286
339
  deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
287
340
  };
288
341
  }
289
- export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
342
+ export function findNextReviewTask(sessionsRoot, reviews, now = Date.now(), excludedReviewKeys = new Set()) {
290
343
  const files = discoverSessionFiles(sessionsRoot).map((path) => {
291
344
  try {
292
345
  return { path, mtimeMs: statSync(path).mtimeMs };
@@ -303,7 +356,7 @@ export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
303
356
  const stableCursor = task ? reviews[task.sessionKey] : undefined;
304
357
  if (task && stableCursor && stableCursor !== cursor)
305
358
  task = readReviewTask(file.path, stableCursor, now);
306
- if (task)
359
+ if (task && !excludedReviewKeys.has(task.reviewKey))
307
360
  return task;
308
361
  }
309
362
  return undefined;