@hadooppei/hwcode 1.0.10 → 1.0.12

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.
@@ -8,7 +8,7 @@ import { Type } from "@earendil-works/pi-ai";
8
8
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  import {
11
- buildKnowledgeExtractionPrompt, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
11
+ buildKnowledgeExtractionPrompt, type ExistingKnowledgeContext, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
12
12
  } from "../lib/knowledge/extractor.ts";
13
13
  import { matchKnowledge } from "../lib/knowledge/matcher.ts";
14
14
  import { loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey } from "../lib/knowledge/store.ts";
@@ -94,28 +94,48 @@ function workerEntryUrl(): URL {
94
94
  }
95
95
 
96
96
  async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review_request" }>): Promise<void> {
97
- const ctx = runtime.context;
98
- if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
99
- post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "no-configured-model" });
100
- updateCapability();
101
- return;
102
- }
103
- if (!ctx.isIdle() || ctx.hasPendingMessages()) {
104
- post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "model-executor-is-busy" });
105
- return;
106
- }
107
- const controller = new AbortController();
108
- runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
109
- const timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
110
- timeout.unref();
97
+ let timeout: NodeJS.Timeout | undefined;
111
98
  try {
99
+ const ctx = runtime.context;
100
+ if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
101
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) throw new Error("model-executor-is-busy");
102
+ const controller = new AbortController();
103
+ runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
104
+ timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
105
+ timeout.unref();
106
+ const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
107
+ const related: ExistingKnowledgeContext[] = matchKnowledge(
108
+ message.task.delta,
109
+ snapshot.catalog,
110
+ KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
111
+ ).map((entry, index) => {
112
+ const reference: ExistingKnowledgeContext = {
113
+ id: entry.id,
114
+ title: entry.title,
115
+ summary: entry.summary,
116
+ keywords: entry.keywords,
117
+ track: entry.track,
118
+ };
119
+ if (index >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyItems) return reference;
120
+ const content = loadKnowledgeById(entry.id, message.task.projectKey)?.content
121
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyChars);
122
+ return content ? { ...reference, content } : reference;
123
+ });
112
124
  const response = await ctx.modelRegistry.complete(
113
125
  ctx.model,
114
126
  {
115
127
  systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
116
128
  messages: [{
117
129
  role: "user",
118
- content: [{ type: "text", text: buildKnowledgeExtractionPrompt(message.task.projectRoot, message.task.delta) }],
130
+ content: [{
131
+ type: "text",
132
+ text: buildKnowledgeExtractionPrompt(
133
+ message.task.projectRoot,
134
+ message.task.delta,
135
+ message.task.context,
136
+ related,
137
+ ),
138
+ }],
119
139
  timestamp: Date.now(),
120
140
  }],
121
141
  },
@@ -130,6 +150,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
130
150
  type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
131
151
  error: error instanceof Error ? error.message : String(error),
132
152
  });
153
+ try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
133
154
  } finally {
134
155
  clearTimeout(timeout);
135
156
  if (runtime.activeReview?.requestId === message.requestId) runtime.activeReview = undefined;
@@ -2,6 +2,15 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
4
 
5
+ export interface ExistingKnowledgeContext {
6
+ id: string;
7
+ title: string;
8
+ summary: string;
9
+ keywords: string[];
10
+ track: "rule" | "topic";
11
+ content?: string;
12
+ }
13
+
5
14
  export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
6
15
  Review only the supplied conversation delta. Return strict JSON and no markdown.
7
16
  Persist only knowledge that is likely to be useful in future sessions:
@@ -9,14 +18,32 @@ Persist only knowledge that is likely to be useful in future sessions:
9
18
  - verified engineering rules, successful procedures, or expensive failed approaches;
10
19
  - reusable architecture, testing, debugging, deployment, or operational knowledge.
11
20
  Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
12
- Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
13
- Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
21
+ Do not persist mutable inventory such as current cloud resources, names, availability, status, timestamps, or account snapshots. Preserve the discovery method or selection rule instead. If useful procedure is mixed with volatile facts, remove the volatile facts.
22
+ Treat failed, unsupported, invalid, or corrected operations as negative evidence. Never present an operation as valid when the supplied evidence says it failed; use only a verified alternative or record an explicit warning. Never invent commands or parameters absent from successful evidence.
23
+ 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.
24
+ Relevant existing knowledge may be supplied. If a candidate has the same intent as an existing item, do not add a translated, renamed, or reformatted duplicate. Set targetId to that exact existing ID and action to "reinforce" when the existing content remains correct, or "revise" only when the delta explicitly proves a correction. Use targetId null only for genuinely new knowledge.
14
25
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
15
- Return: {"candidates":[{"key":"stable semantic key","title":"...","summary":"...","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false}]}
26
+ 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.
27
+ 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.
28
+ Return: {"candidates":[{"key":"stable semantic key","targetId":"existing-id or null","title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false,"durability":"stable"}]}
16
29
  Return {"candidates":[]} when nothing meets the threshold.`;
17
30
 
18
- export function buildKnowledgeExtractionPrompt(projectRoot: string, delta: string): string {
19
- return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
31
+ export function buildKnowledgeExtractionPrompt(
32
+ projectRoot: string,
33
+ delta: string,
34
+ context = "",
35
+ existing: ExistingKnowledgeContext[] = [],
36
+ ): string {
37
+ const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
38
+ return [
39
+ `Project root: ${projectRoot}`,
40
+ "",
41
+ "<relevant_existing_knowledge>", related, "</relevant_existing_knowledge>",
42
+ "",
43
+ "<prior_validation_context>", context, "</prior_validation_context>",
44
+ "",
45
+ "<conversation_delta>", delta, "</conversation_delta>",
46
+ ].join("\n");
20
47
  }
21
48
 
22
49
  export function parseCandidateEnvelope(text: string): unknown[] {
@@ -25,9 +52,58 @@ export function parseCandidateEnvelope(text: string): unknown[] {
25
52
  const start = unfenced.indexOf("{");
26
53
  const end = unfenced.lastIndexOf("}");
27
54
  if (start < 0 || end <= start) throw new Error("Knowledge reviewer did not return a JSON object");
28
- const parsed = JSON.parse(unfenced.slice(start, end + 1)) as { candidates?: unknown };
29
- if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
30
- return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
55
+ let strictError: unknown;
56
+ try {
57
+ const parsed = JSON.parse(unfenced.slice(start, end + 1)) as { candidates?: unknown };
58
+ if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
59
+ return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
60
+ } catch (error) {
61
+ strictError = error;
62
+ }
63
+
64
+ const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
65
+ const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
66
+ if (arrayStart >= 0) {
67
+ const candidates: unknown[] = [];
68
+ let inString = false;
69
+ let escaped = false;
70
+ for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
71
+ const character = unfenced[index];
72
+ if (inString) {
73
+ if (escaped) escaped = false;
74
+ else if (character === "\\") escaped = true;
75
+ else if (character === '"') inString = false;
76
+ continue;
77
+ }
78
+ if (character === '"') { inString = true; continue; }
79
+ if (character !== "{") continue;
80
+ const objectEnd = balancedObjectEnd(unfenced, index);
81
+ if (objectEnd < 0) break;
82
+ try { candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1))); } catch { /* Salvage other complete candidates. */ }
83
+ index = objectEnd;
84
+ }
85
+ if (candidates.length > 0) return candidates;
86
+ }
87
+ throw strictError;
88
+ }
89
+
90
+ function balancedObjectEnd(value: string, start: number): number {
91
+ let depth = 0;
92
+ let inString = false;
93
+ let escaped = false;
94
+ for (let index = start; index < value.length; index++) {
95
+ const character = value[index];
96
+ if (inString) {
97
+ if (escaped) escaped = false;
98
+ else if (character === "\\") escaped = true;
99
+ else if (character === '"') inString = false;
100
+ continue;
101
+ }
102
+ if (character === '"') { inString = true; continue; }
103
+ if (character === "{") depth++;
104
+ else if (character === "}" && --depth === 0) return index;
105
+ }
106
+ return -1;
31
107
  }
32
108
 
33
109
  export function knowledgeDeltaDigest(delta: string): string {
@@ -0,0 +1,55 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ import { userRuntimePaths } from "../runtime/paths.ts";
5
+ import type { PersistKnowledgeResult } from "./types.ts";
6
+
7
+ export interface KnowledgeReviewStatus {
8
+ version: 1;
9
+ protocolVersion: 3;
10
+ state: "leader" | "stepped-down";
11
+ pid: number;
12
+ leaderToken: string;
13
+ electedAt: string;
14
+ updatedAt: string;
15
+ stepDownReason?: string;
16
+ lastScanAt?: string;
17
+ lastScanError?: { at: string; error: string };
18
+ activeReview?: {
19
+ reviewKey: string;
20
+ sessionKey: string;
21
+ sessionFileHash: string;
22
+ projectKey: string;
23
+ attempt: number;
24
+ startedAt: string;
25
+ };
26
+ lastReview?: {
27
+ reviewKey: string;
28
+ sessionKey: string;
29
+ attempt: number;
30
+ outcome: "saved" | "failed";
31
+ completedAt: string;
32
+ durationMs: number;
33
+ generationId?: string;
34
+ result?: PersistKnowledgeResult;
35
+ error?: string;
36
+ };
37
+ }
38
+
39
+ export function writeKnowledgeReviewStatus(status: KnowledgeReviewStatus, home: string): void {
40
+ const path = userRuntimePaths(home).knowledgeStatus;
41
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
42
+ const temporary = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
44
+ renameSync(temporary, path);
45
+ chmodSync(path, 0o600);
46
+ }
47
+
48
+ export function readKnowledgeReviewStatus(home: string): KnowledgeReviewStatus | undefined {
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(userRuntimePaths(home).knowledgeStatus, "utf8")) as KnowledgeReviewStatus;
51
+ return parsed.version === 1 && parsed.protocolVersion === 3 ? parsed : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
@@ -7,9 +7,11 @@ import { parentPort, workerData } from "node:worker_threads";
7
7
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
8
8
  import { userRuntimePaths } from "../runtime/paths.ts";
9
9
  import { parseCandidateEnvelope } from "./extractor.ts";
10
+ import { type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
11
+ import { sanitizeKnowledgeText } from "./sanitize.ts";
10
12
  import { findNextReviewTask } from "./session-scanner.ts";
11
13
  import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.ts";
12
- import type { KnowledgeReviewTask } from "./types.ts";
14
+ import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
13
15
  import type { CoordinatorBroadcast, KnowledgeWorkerInput, KnowledgeWorkerOutput } from "./worker-protocol.ts";
14
16
 
15
17
  if (!parentPort) throw new Error("Knowledge review worker requires a parent port");
@@ -25,6 +27,11 @@ let leaderToken = "";
25
27
  let standbySocket: Socket | undefined;
26
28
  let electionPending = false;
27
29
  let activeTask: KnowledgeReviewTask | undefined;
30
+ let activeAttempt = 0;
31
+ let activeStartedAt = 0;
32
+ let activeDeadlineTimer: NodeJS.Timeout | undefined;
33
+ let reviewStatus: KnowledgeReviewStatus | undefined;
34
+ const reviewAttempts = new Map<string, number>();
28
35
 
29
36
  function send(message: KnowledgeWorkerOutput): void {
30
37
  parentPort!.postMessage(message);
@@ -36,13 +43,76 @@ function coordinatorPath(): string {
36
43
  : paths.knowledgeCoordinatorSocket;
37
44
  }
38
45
 
39
- function writeLeaderMetadata(): void {
46
+ function writeLeaderMetadata(electedAt: string): void {
40
47
  writeFileSync(paths.knowledgeLeader, `${JSON.stringify({
41
- protocolVersion: 3, leaderToken, pid: process.pid, electedAt: new Date().toISOString(),
48
+ protocolVersion: 3, leaderToken, pid: process.pid, electedAt,
42
49
  }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
43
50
  chmodSync(paths.knowledgeLeader, 0o600);
44
51
  }
45
52
 
53
+ function publishReviewStatus(): void {
54
+ if (!reviewStatus) return;
55
+ reviewStatus.updatedAt = new Date().toISOString();
56
+ try { writeKnowledgeReviewStatus(reviewStatus, home); } catch (error) {
57
+ send({ type: "review_failed", error: `Unable to write knowledge status: ${error instanceof Error ? error.message : String(error)}` });
58
+ }
59
+ }
60
+
61
+ function boundedError(error: unknown): string {
62
+ return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error)).slice(0, 1_000);
63
+ }
64
+
65
+ function beginReview(task: KnowledgeReviewTask): void {
66
+ activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
67
+ reviewAttempts.set(task.reviewKey, activeAttempt);
68
+ activeStartedAt = Date.now();
69
+ clearTimeout(activeDeadlineTimer);
70
+ activeDeadlineTimer = setTimeout(() => {
71
+ if (activeTask?.requestId !== task.requestId) return;
72
+ const error = "knowledge-review-result-timeout";
73
+ finishReview(task, "failed", { error });
74
+ activeTask = undefined;
75
+ send({ type: "review_cancel", requestId: task.requestId, reason: error });
76
+ send({ type: "review_failed", requestId: task.requestId, error });
77
+ }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
78
+ activeDeadlineTimer.unref();
79
+ if (reviewStatus) {
80
+ reviewStatus.activeReview = {
81
+ reviewKey: task.reviewKey,
82
+ sessionKey: task.sessionKey,
83
+ sessionFileHash: task.sessionFileHash,
84
+ projectKey: task.projectKey,
85
+ attempt: activeAttempt,
86
+ startedAt: new Date(activeStartedAt).toISOString(),
87
+ };
88
+ publishReviewStatus();
89
+ }
90
+ }
91
+
92
+ function finishReview(
93
+ task: KnowledgeReviewTask,
94
+ outcome: "saved" | "failed",
95
+ detail: { generationId?: string; result?: PersistKnowledgeResult; error?: unknown },
96
+ ): void {
97
+ clearTimeout(activeDeadlineTimer);
98
+ activeDeadlineTimer = undefined;
99
+ if (!reviewStatus) return;
100
+ reviewStatus.activeReview = undefined;
101
+ reviewStatus.lastReview = {
102
+ reviewKey: task.reviewKey,
103
+ sessionKey: task.sessionKey,
104
+ attempt: activeAttempt,
105
+ outcome,
106
+ completedAt: new Date().toISOString(),
107
+ durationMs: Math.max(0, Date.now() - activeStartedAt),
108
+ generationId: detail.generationId,
109
+ result: detail.result,
110
+ error: detail.error === undefined ? undefined : boundedError(detail.error),
111
+ };
112
+ if (outcome === "saved") reviewAttempts.delete(task.reviewKey);
113
+ publishReviewStatus();
114
+ }
115
+
46
116
  function removeLeaderMetadata(token: string): void {
47
117
  try {
48
118
  const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8")) as { leaderToken?: string };
@@ -69,7 +139,17 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)): void {
69
139
 
70
140
  function stepDown(reason: string): void {
71
141
  if (activeTask) send({ type: "review_cancel", requestId: activeTask.requestId, reason });
142
+ if (reviewStatus) {
143
+ reviewStatus.state = "stepped-down";
144
+ reviewStatus.stepDownReason = reason;
145
+ reviewStatus.activeReview = undefined;
146
+ publishReviewStatus();
147
+ }
72
148
  activeTask = undefined;
149
+ clearTimeout(activeDeadlineTimer);
150
+ activeDeadlineTimer = undefined;
151
+ activeAttempt = 0;
152
+ activeStartedAt = 0;
73
153
  const token = leaderToken;
74
154
  leaderToken = "";
75
155
  for (const socket of standbySockets) socket.destroy();
@@ -83,6 +163,7 @@ function stepDown(reason: string): void {
83
163
  }
84
164
  }
85
165
  if (token) removeLeaderMetadata(token);
166
+ reviewStatus = undefined;
86
167
  }
87
168
 
88
169
  function becomeStandby(socket: Socket): void {
@@ -147,6 +228,7 @@ function connectToLeader(probe = 0): void {
147
228
  function becomeLeader(server: Server): void {
148
229
  leaderServer = server;
149
230
  leaderToken = randomUUID();
231
+ const electedAt = new Date().toISOString();
150
232
  server.unref();
151
233
  server.on("connection", (socket) => {
152
234
  standbySockets.add(socket);
@@ -160,7 +242,11 @@ function becomeLeader(server: Server): void {
160
242
  stepDown("coordinator-server-failed");
161
243
  scheduleElection();
162
244
  });
163
- writeLeaderMetadata();
245
+ writeLeaderMetadata(electedAt);
246
+ reviewStatus = {
247
+ version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
248
+ };
249
+ publishReviewStatus();
164
250
  send({ type: "leadership", state: "leader", leaderToken });
165
251
  void scanForReview();
166
252
  }
@@ -186,14 +272,21 @@ function attemptElection(): void {
186
272
 
187
273
  async function scanForReview(): Promise<void> {
188
274
  if (!leaderServer || !leaderToken || activeTask || !sessionsRoot || !eligible) return;
275
+ if (reviewStatus) {
276
+ reviewStatus.lastScanAt = new Date().toISOString();
277
+ reviewStatus.lastScanError = undefined;
278
+ publishReviewStatus();
279
+ }
189
280
  try {
190
281
  const manifest = loadCurrentManifest(home);
191
282
  const task = findNextReviewTask(sessionsRoot, manifest.reviews);
192
283
  if (!task) return;
193
284
  activeTask = task;
285
+ beginReview(task);
194
286
  if (!task.delta) {
195
287
  const result = commitKnowledgeReview([], task, leaderToken, home);
196
288
  const generationId = loadCurrentManifest(home).generationId;
289
+ finishReview(task, "saved", { generationId, result });
197
290
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
198
291
  if (generationId) {
199
292
  send({ type: "generation_changed", generationId });
@@ -205,8 +298,13 @@ async function scanForReview(): Promise<void> {
205
298
  }
206
299
  send({ type: "review_request", leaderToken, requestId: task.requestId, task });
207
300
  } catch (error) {
301
+ if (activeTask) finishReview(activeTask, "failed", { error });
302
+ else if (reviewStatus) {
303
+ reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
304
+ publishReviewStatus();
305
+ }
208
306
  activeTask = undefined;
209
- send({ type: "review_failed", error: error instanceof Error ? error.message : String(error) });
307
+ send({ type: "review_failed", error: boundedError(error) });
210
308
  }
211
309
  }
212
310
 
@@ -218,6 +316,7 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
218
316
  if (message.error || message.raw === undefined) throw new Error(message.error || "Knowledge review returned no content");
219
317
  const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
220
318
  const generationId = loadCurrentManifest(home).generationId;
319
+ finishReview(task, "saved", { generationId, result });
221
320
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
222
321
  if (generationId) {
223
322
  send({ type: "generation_changed", generationId });
@@ -225,7 +324,8 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
225
324
  }
226
325
  committed = true;
227
326
  } catch (error) {
228
- send({ type: "review_failed", requestId: task.requestId, error: error instanceof Error ? error.message : String(error) });
327
+ finishReview(task, "failed", { error });
328
+ send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
229
329
  } finally {
230
330
  activeTask = undefined;
231
331
  if (committed) queueMicrotask(() => void scanForReview());
@@ -24,3 +24,23 @@ export function compactKnowledgeText(value: string, maxChars: number): string {
24
24
  .trim()
25
25
  .slice(0, maxChars);
26
26
  }
27
+
28
+ export function compactKnowledgeSummary(value: string, maxChars: number): string {
29
+ const normalized = sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim();
30
+ if (normalized.length <= maxChars || maxChars <= 0) return normalized.slice(0, Math.max(0, maxChars));
31
+ if (maxChars === 1) return "…";
32
+ const limit = maxChars - 1;
33
+ const prefix = normalized.slice(0, limit);
34
+ const minimumBoundary = Math.floor(limit * 0.6);
35
+ const sentenceEnd = Math.max(
36
+ prefix.lastIndexOf("。"), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"),
37
+ prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"),
38
+ );
39
+ if (sentenceEnd >= minimumBoundary) return prefix.slice(0, sentenceEnd + 1).trimEnd();
40
+ const next = normalized.charAt(limit);
41
+ if (/[A-Za-z0-9]/u.test(prefix.at(-1) ?? "") && /[A-Za-z0-9]/u.test(next)) {
42
+ const wordBoundary = Math.max(prefix.lastIndexOf(" "), prefix.lastIndexOf("-"), prefix.lastIndexOf("/"));
43
+ if (wordBoundary >= minimumBoundary) return `${prefix.slice(0, wordBoundary).trimEnd()}…`;
44
+ }
45
+ return `${prefix.trimEnd()}…`;
46
+ }
@@ -19,6 +19,9 @@ interface ReviewPiece {
19
19
  text: string;
20
20
  }
21
21
 
22
+ const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
23
+ const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
24
+
22
25
  function hash(value: string, length = 64): string {
23
26
  return createHash("sha256").update(value).digest("hex").slice(0, length);
24
27
  }
@@ -35,20 +38,21 @@ function textContent(content: unknown): string {
35
38
 
36
39
  function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefined {
37
40
  if (entry.type === "compaction" || entry.type === "branch_summary") {
38
- return { index, priority: 1, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
41
+ return { index, priority: 2, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
39
42
  }
40
43
  if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
41
44
  return {
42
- index, priority: 0, category: "workflow",
45
+ index, priority: 1, category: "workflow",
43
46
  text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
44
47
  };
45
48
  }
46
49
  if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
47
50
  const message = entry.message as unknown as Record<string, unknown>;
48
51
  const role = typeof message.role === "string" ? message.role : "message";
49
- const content = sanitizeKnowledgeText(textContent(message.content));
52
+ const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
50
53
  if (!content) return undefined;
51
- const priority = role === "user" ? 0 : role === "toolResult" ? 3 : 2;
54
+ const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
55
+ : role === "toolResult" ? 4 : 3;
52
56
  const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
53
57
  const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
54
58
  return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
@@ -61,14 +65,43 @@ function buildDelta(entries: SessionEntry[]): string {
61
65
  user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
62
66
  };
63
67
  let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
64
- for (const piece of pieces.slice().sort((left, right) => left.priority - right.priority || left.index - right.index)) {
68
+ for (const piece of pieces.slice().sort((left, right) => {
69
+ if (left.priority !== right.priority) return left.priority - right.priority;
70
+ if (left.category === right.category && left.category !== "user") return right.index - left.index;
71
+ return left.index - right.index;
72
+ })) {
65
73
  if (remaining <= 0) break;
66
- const text = piece.text.slice(0, Math.min(remaining, categoryRemaining[piece.category]));
74
+ const separatorCharacters = selected.length > 0 ? 2 : 0;
75
+ if (remaining <= separatorCharacters) break;
76
+ const text = piece.text.slice(0, Math.min(remaining - separatorCharacters, categoryRemaining[piece.category]));
67
77
  if (text) selected.push({ ...piece, text });
68
- remaining -= text.length;
78
+ remaining -= text.length + separatorCharacters;
69
79
  categoryRemaining[piece.category] -= text.length;
70
80
  }
71
- return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n");
81
+ return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
82
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
83
+ }
84
+
85
+ function buildValidationContext(entries: SessionEntry[]): string {
86
+ const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
87
+ const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
88
+ const evidence = pieces.filter((piece) => (
89
+ (piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
90
+ && FAILURE_EVIDENCE_RE.test(piece.text)
91
+ ));
92
+ if (latestWorkflow && !evidence.includes(latestWorkflow)) evidence.push(latestWorkflow);
93
+ let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars;
94
+ const selected: ReviewPiece[] = [];
95
+ for (const piece of evidence.slice().sort((left, right) => right.index - left.index)) {
96
+ if (remaining <= 0) break;
97
+ const separatorCharacters = selected.length > 0 ? 2 : 0;
98
+ if (remaining <= separatorCharacters) break;
99
+ const text = piece.text.slice(0, remaining - separatorCharacters);
100
+ if (text) selected.push({ ...piece, text });
101
+ remaining -= text.length + separatorCharacters;
102
+ }
103
+ return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
104
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
72
105
  }
73
106
 
74
107
  function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
@@ -120,6 +153,7 @@ export function readReviewTask(
120
153
  const pending = branch.slice(cursorIndex + 1);
121
154
  if (pending.length === 0) return undefined;
122
155
  const delta = buildDelta(pending);
156
+ const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
123
157
  const firstEntryId = pending[0].id;
124
158
  const lastEntryId = pending[pending.length - 1].id;
125
159
  const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
@@ -130,7 +164,7 @@ export function readReviewTask(
130
164
  return {
131
165
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
132
166
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
133
- firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
167
+ firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
134
168
  };
135
169
  }
136
170