@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.
@@ -6,8 +6,8 @@ import { parentPort, workerData } from "node:worker_threads";
6
6
 
7
7
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
8
8
  import { userRuntimePaths } from "../runtime/paths.ts";
9
- import { parseCandidateEnvelope } from "./extractor.ts";
10
- import { type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
9
+ import { parseKnowledgeReviewEnvelope } from "./extractor.ts";
10
+ import { readKnowledgeReviewStatus, type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
11
11
  import { sanitizeKnowledgeText } from "./sanitize.ts";
12
12
  import { findNextReviewTask } from "./session-scanner.ts";
13
13
  import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.ts";
@@ -59,21 +59,44 @@ function publishReviewStatus(): void {
59
59
  }
60
60
 
61
61
  function boundedError(error: unknown): string {
62
- return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error)).slice(0, 1_000);
62
+ return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error))
63
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxFailureDetailChars);
64
+ }
65
+
66
+ function clearSupersededReviewState(task: KnowledgeReviewTask): void {
67
+ if (!reviewStatus) return;
68
+ for (const field of ["blockedReviews", "reviewFailures"] as const) {
69
+ const records = reviewStatus[field];
70
+ if (!records) continue;
71
+ for (const [reviewKey, record] of Object.entries(records)) {
72
+ if (record.sessionKey === task.sessionKey && reviewKey !== task.reviewKey) {
73
+ delete records[reviewKey];
74
+ reviewAttempts.delete(reviewKey);
75
+ }
76
+ }
77
+ if (Object.keys(records).length === 0) reviewStatus[field] = undefined;
78
+ }
63
79
  }
64
80
 
65
81
  function beginReview(task: KnowledgeReviewTask): void {
82
+ clearSupersededReviewState(task);
66
83
  activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
67
84
  reviewAttempts.set(task.reviewKey, activeAttempt);
68
85
  activeStartedAt = Date.now();
69
86
  clearTimeout(activeDeadlineTimer);
70
87
  activeDeadlineTimer = setTimeout(() => {
71
88
  if (activeTask?.requestId !== task.requestId) return;
72
- const error = "knowledge-review-result-timeout";
73
- finishReview(task, "failed", { error });
89
+ const error = [
90
+ `knowledge-review-result-timeout after ${KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000}ms`,
91
+ `reviewKey=${task.reviewKey}`,
92
+ `deltaChars=${task.delta.length}`,
93
+ `evidenceItems=${task.evidence?.length ?? 0}`,
94
+ `episodeItems=${task.episodes?.length ?? 0}`,
95
+ ].join("; ");
96
+ const terminal = finishReview(task, "failed", { error });
74
97
  activeTask = undefined;
75
98
  send({ type: "review_cancel", requestId: task.requestId, reason: error });
76
- send({ type: "review_failed", requestId: task.requestId, error });
99
+ send({ type: "review_failed", requestId: task.requestId, error, terminal });
77
100
  }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
78
101
  activeDeadlineTimer.unref();
79
102
  if (reviewStatus) {
@@ -93,24 +116,59 @@ function finishReview(
93
116
  task: KnowledgeReviewTask,
94
117
  outcome: "saved" | "failed",
95
118
  detail: { generationId?: string; result?: PersistKnowledgeResult; error?: unknown },
96
- ): void {
119
+ ): boolean {
97
120
  clearTimeout(activeDeadlineTimer);
98
121
  activeDeadlineTimer = undefined;
99
- if (!reviewStatus) return;
122
+ if (!reviewStatus) return false;
123
+ const error = detail.error === undefined ? undefined : boundedError(detail.error);
124
+ const exhausted = outcome === "failed" && activeAttempt >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxAttempts;
125
+ const completedAt = new Date().toISOString();
126
+ const durationMs = Math.max(0, Date.now() - activeStartedAt);
100
127
  reviewStatus.activeReview = undefined;
101
128
  reviewStatus.lastReview = {
102
129
  reviewKey: task.reviewKey,
103
130
  sessionKey: task.sessionKey,
104
131
  attempt: activeAttempt,
105
132
  outcome,
106
- completedAt: new Date().toISOString(),
107
- durationMs: Math.max(0, Date.now() - activeStartedAt),
133
+ completedAt,
134
+ durationMs,
108
135
  generationId: detail.generationId,
109
136
  result: detail.result,
110
- error: detail.error === undefined ? undefined : boundedError(detail.error),
137
+ error,
111
138
  };
112
- if (outcome === "saved") reviewAttempts.delete(task.reviewKey);
139
+ if (outcome === "saved") {
140
+ reviewAttempts.delete(task.reviewKey);
141
+ delete reviewStatus.reviewFailures?.[task.reviewKey];
142
+ clearSupersededReviewState(task);
143
+ } else {
144
+ const reviewFailures = reviewStatus.reviewFailures ?? {};
145
+ const prior = reviewFailures[task.reviewKey];
146
+ reviewFailures[task.reviewKey] = {
147
+ sessionKey: task.sessionKey,
148
+ sessionFileHash: task.sessionFileHash,
149
+ attempts: [...(prior?.attempts ?? []), {
150
+ attempt: activeAttempt,
151
+ failedAt: completedAt,
152
+ durationMs,
153
+ error: error ?? "unknown-review-failure",
154
+ }].slice(-KNOWLEDGE_RUNTIME_DEFAULTS.review.maxAttempts),
155
+ };
156
+ reviewStatus.reviewFailures = Object.fromEntries(Object.entries(reviewFailures).slice(-100));
157
+ }
158
+ if (exhausted) {
159
+ const blockedReviews = reviewStatus.blockedReviews ?? {};
160
+ blockedReviews[task.reviewKey] = {
161
+ reviewKey: task.reviewKey,
162
+ sessionKey: task.sessionKey,
163
+ sessionFileHash: task.sessionFileHash,
164
+ blockedAt: new Date().toISOString(),
165
+ attempts: activeAttempt,
166
+ error: error ?? "unknown-review-failure",
167
+ };
168
+ reviewStatus.blockedReviews = Object.fromEntries(Object.entries(blockedReviews).slice(-100));
169
+ }
113
170
  publishReviewStatus();
171
+ return exhausted;
114
172
  }
115
173
 
116
174
  function deferReview(task: KnowledgeReviewTask, reason: string): void {
@@ -155,7 +213,12 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)): void {
155
213
  }
156
214
 
157
215
  function stepDown(reason: string): void {
158
- if (activeTask) send({ type: "review_cancel", requestId: activeTask.requestId, reason });
216
+ if (activeTask) {
217
+ const attempts = reviewAttempts.get(activeTask.reviewKey) ?? 1;
218
+ if (attempts <= 1) reviewAttempts.delete(activeTask.reviewKey);
219
+ else reviewAttempts.set(activeTask.reviewKey, attempts - 1);
220
+ send({ type: "review_cancel", requestId: activeTask.requestId, reason });
221
+ }
159
222
  if (reviewStatus) {
160
223
  reviewStatus.state = "stepped-down";
161
224
  reviewStatus.stepDownReason = reason;
@@ -260,8 +323,16 @@ function becomeLeader(server: Server): void {
260
323
  scheduleElection();
261
324
  });
262
325
  writeLeaderMetadata(electedAt);
326
+ const priorStatus = readKnowledgeReviewStatus(home);
327
+ reviewAttempts.clear();
328
+ for (const [reviewKey, failure] of Object.entries(priorStatus?.reviewFailures ?? {})) {
329
+ const attempt = Math.max(0, ...failure.attempts.map((item) => item.attempt));
330
+ if (attempt > 0) reviewAttempts.set(reviewKey, attempt);
331
+ }
263
332
  reviewStatus = {
264
333
  version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
334
+ blockedReviews: priorStatus?.blockedReviews,
335
+ reviewFailures: priorStatus?.reviewFailures,
265
336
  };
266
337
  publishReviewStatus();
267
338
  send({ type: "leadership", state: "leader", leaderToken });
@@ -296,7 +367,8 @@ async function scanForReview(): Promise<void> {
296
367
  }
297
368
  try {
298
369
  const manifest = loadCurrentManifest(home);
299
- const task = findNextReviewTask(sessionsRoot, manifest.reviews);
370
+ const blockedReviewKeys = new Set(Object.keys(reviewStatus?.blockedReviews ?? {}));
371
+ const task = findNextReviewTask(sessionsRoot, manifest.reviews, Date.now(), blockedReviewKeys);
300
372
  if (!task) return;
301
373
  activeTask = task;
302
374
  beginReview(task);
@@ -315,13 +387,14 @@ async function scanForReview(): Promise<void> {
315
387
  }
316
388
  send({ type: "review_request", leaderToken, requestId: task.requestId, task });
317
389
  } catch (error) {
318
- if (activeTask) finishReview(activeTask, "failed", { error });
319
- else if (reviewStatus) {
390
+ const failedTask = activeTask;
391
+ const terminal = failedTask ? finishReview(failedTask, "failed", { error }) : true;
392
+ if (!failedTask && reviewStatus) {
320
393
  reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
321
394
  publishReviewStatus();
322
395
  }
323
396
  activeTask = undefined;
324
- send({ type: "review_failed", error: boundedError(error) });
397
+ send({ type: "review_failed", requestId: failedTask?.requestId, error: boundedError(error), terminal });
325
398
  }
326
399
  }
327
400
 
@@ -331,7 +404,8 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
331
404
  let committed = false;
332
405
  try {
333
406
  if (message.error || message.raw === undefined) throw new Error(message.error || "Knowledge review returned no content");
334
- const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
407
+ const envelope = parseKnowledgeReviewEnvelope(message.raw);
408
+ const result = commitKnowledgeReview(envelope.candidates, task, leaderToken, home, envelope.episodeDecisions);
335
409
  const generationId = loadCurrentManifest(home).generationId;
336
410
  finishReview(task, "saved", { generationId, result });
337
411
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
@@ -341,8 +415,8 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
341
415
  }
342
416
  committed = true;
343
417
  } catch (error) {
344
- finishReview(task, "failed", { error });
345
- send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
418
+ const terminal = finishReview(task, "failed", { error });
419
+ send({ type: "review_failed", requestId: task.requestId, error: boundedError(error), terminal });
346
420
  } finally {
347
421
  activeTask = undefined;
348
422
  if (committed) queueMicrotask(() => void scanForReview());
@@ -3,6 +3,16 @@ 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
+
9
+ export function containsInjectedSkill(value: string): boolean {
10
+ return INJECTED_SKILL_MARKER_RE.test(value);
11
+ }
12
+
13
+ export function stripInjectedSkillBlocks(value: string): string {
14
+ return value.replace(INJECTED_SKILL_BLOCK_RE, "");
15
+ }
6
16
 
7
17
  export function sanitizeKnowledgeText(value: string): string {
8
18
  return value
@@ -8,8 +8,9 @@ import {
8
8
 
9
9
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
10
10
  import { canonicalizeDirectory } from "../working-directory.ts";
11
+ import { buildKnowledgeEpisodes, buildKnowledgeEvidence } from "./evidence.ts";
11
12
  import { knowledgeDeltaDigest } from "./extractor.ts";
12
- import { sanitizeKnowledgeText } from "./sanitize.ts";
13
+ import { containsInjectedSkill, sanitizeKnowledgeText, stripInjectedSkillBlocks } from "./sanitize.ts";
13
14
  import { projectKnowledgeKey } from "./store.ts";
14
15
  import type { KnowledgeReviewCursor, KnowledgeReviewTask } from "./types.ts";
15
16
 
@@ -21,7 +22,6 @@ interface ReviewPiece {
21
22
  }
22
23
 
23
24
  const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
24
- const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
25
25
  const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
26
26
  const MAX_RECALL_QUERY_CHARS = 4_000;
27
27
  const MAX_LOADED_KNOWLEDGE_IDS = 8;
@@ -53,8 +53,13 @@ function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefine
53
53
  if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
54
54
  const message = entry.message as unknown as Record<string, unknown>;
55
55
  const role = typeof message.role === "string" ? message.role : "message";
56
- const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
56
+ const rawContent = textContent(message.content);
57
+ const injectedContext = role === "user" && containsInjectedSkill(rawContent);
58
+ const content = sanitizeKnowledgeText(injectedContext ? stripInjectedSkillBlocks(rawContent) : rawContent);
57
59
  if (!content) return undefined;
60
+ if (injectedContext) {
61
+ return { index, priority: 3, category: "assistant", text: `[injected_context]\n${content.slice(0, 4_000)}` };
62
+ }
58
63
  const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
59
64
  : role === "toolResult" ? 4 : 3;
60
65
  const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
@@ -62,8 +67,39 @@ function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefine
62
67
  return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
63
68
  }
64
69
 
65
- function buildDelta(entries: SessionEntry[]): string {
66
- const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
70
+ function entryId(entry: SessionEntry): string | undefined {
71
+ const value = (entry as unknown as Record<string, unknown>).id;
72
+ return typeof value === "string" ? value : undefined;
73
+ }
74
+
75
+ function knowledgeLookupResultEntryIds(entries: SessionEntry[]): Set<string> {
76
+ const lookupCalls = new Set<string>();
77
+ const resultEntries = new Set<string>();
78
+ for (const entry of entries) {
79
+ const message = messageRecord(entry);
80
+ const content = message?.content;
81
+ if (Array.isArray(content)) {
82
+ for (const raw of content) {
83
+ if (!raw || typeof raw !== "object") continue;
84
+ const item = raw as Record<string, unknown>;
85
+ if (item.type === "toolCall" && item.name === "hwcode_knowledge_lookup" && typeof item.id === "string") {
86
+ lookupCalls.add(item.id);
87
+ }
88
+ }
89
+ }
90
+ if (message?.role === "toolResult" && typeof message.toolCallId === "string" && lookupCalls.has(message.toolCallId)) {
91
+ const id = entryId(entry);
92
+ if (id) resultEntries.add(id);
93
+ }
94
+ }
95
+ return resultEntries;
96
+ }
97
+
98
+ function buildDelta(entries: SessionEntry[], excludedEntryIds: ReadonlySet<string> = new Set()): string {
99
+ const pieces = entries.map((entry, index) => {
100
+ const id = entryId(entry);
101
+ return id && excludedEntryIds.has(id) ? undefined : reviewPiece(entry, index);
102
+ }).filter((piece): piece is ReviewPiece => Boolean(piece));
67
103
  const selected: ReviewPiece[] = [];
68
104
  const categoryRemaining: Record<ReviewPiece["category"], number> = {
69
105
  user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
@@ -86,8 +122,11 @@ function buildDelta(entries: SessionEntry[]): string {
86
122
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
87
123
  }
88
124
 
89
- function buildValidationContext(entries: SessionEntry[]): string {
90
- const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
125
+ function buildValidationContext(entries: SessionEntry[], excludedEntryIds: ReadonlySet<string> = new Set()): string {
126
+ const pieces = entries.map((entry, index) => {
127
+ const id = entryId(entry);
128
+ return id && excludedEntryIds.has(id) ? undefined : reviewPiece(entry, index);
129
+ }).filter((piece): piece is ReviewPiece => Boolean(piece));
91
130
  const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
92
131
  const evidence = pieces.filter((piece) => (
93
132
  (piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
@@ -182,7 +221,9 @@ function buildRecallQuery(entries: SessionEntry[], ids: string[]): string {
182
221
  for (const entry of entries.slice().reverse()) {
183
222
  const message = messageRecord(entry);
184
223
  if (!message || message.role !== "user") continue;
185
- const text = compactRecallValue(textContent(message.content).replace(INJECTED_SKILL_RE, ""), 800);
224
+ const raw = textContent(message.content);
225
+ if (containsInjectedSkill(raw)) continue;
226
+ const text = compactRecallValue(raw, 800);
186
227
  if (text) lines.push(`User request: ${text}`);
187
228
  if (lines.filter((line) => line.startsWith("User request:")).length >= 2) break;
188
229
  }
@@ -240,13 +281,23 @@ export function readReviewTask(
240
281
  : -1;
241
282
  const pending = branch.slice(cursorIndex + 1);
242
283
  if (pending.length === 0) return undefined;
243
- const delta = buildDelta(pending);
284
+ const pendingEntryIds = new Set(pending.map((entry) => entry.id));
285
+ const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
286
+ const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
287
+ const episodes = buildKnowledgeEpisodes(evidence);
288
+ const lookupResultEntryIds = knowledgeLookupResultEntryIds(branch);
289
+ const delta = buildDelta(pending, lookupResultEntryIds);
244
290
  const loadedIds = loadedKnowledgeIds(pending);
245
291
  const recallQuery = buildRecallQuery(pending, loadedIds);
246
- const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
292
+ const context = cursorIndex >= 0
293
+ ? buildValidationContext(branch.slice(0, cursorIndex + 1), lookupResultEntryIds)
294
+ : "";
247
295
  const firstEntryId = pending[0].id;
248
296
  const lastEntryId = pending[pending.length - 1].id;
249
- const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
297
+ const deltaDigest = knowledgeDeltaDigest([
298
+ delta || `${firstEntryId}\0${lastEntryId}`, recallQuery, loadedIds.join("\0"),
299
+ JSON.stringify(evidence), JSON.stringify(episodes),
300
+ ].join("\0"));
250
301
  const sessionFileHash = hash(resolve(sessionFile), 16);
251
302
  const sessionKey = hash(header.id, 24);
252
303
  const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
@@ -255,6 +306,7 @@ export function readReviewTask(
255
306
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
256
307
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
257
308
  firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
309
+ evidence, episodes,
258
310
  deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
259
311
  };
260
312
  }
@@ -263,6 +315,7 @@ export function findNextReviewTask(
263
315
  sessionsRoot: string,
264
316
  reviews: Record<string, KnowledgeReviewCursor>,
265
317
  now = Date.now(),
318
+ excludedReviewKeys: ReadonlySet<string> = new Set(),
266
319
  ): KnowledgeReviewTask | undefined {
267
320
  const files = discoverSessionFiles(sessionsRoot).map((path) => {
268
321
  try { return { path, mtimeMs: statSync(path).mtimeMs }; } catch { return undefined; }
@@ -274,7 +327,7 @@ export function findNextReviewTask(
274
327
  let task = readReviewTask(file.path, cursor, now);
275
328
  const stableCursor = task ? reviews[task.sessionKey] : undefined;
276
329
  if (task && stableCursor && stableCursor !== cursor) task = readReviewTask(file.path, stableCursor, now);
277
- if (task) return task;
330
+ if (task && !excludedReviewKeys.has(task.reviewKey)) return task;
278
331
  }
279
332
  return undefined;
280
333
  }