@hadooppei/hwcode 1.0.9 → 1.0.11

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,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { Worker } from "node:worker_threads";
4
6
 
5
7
  import { Type } from "@earendil-works/pi-ai";
@@ -27,11 +29,17 @@ interface ProcessKnowledgeRuntime {
27
29
  modelAvailable?: boolean;
28
30
  activeReview?: ActiveReview;
29
31
  capabilityTimer?: NodeJS.Timeout;
32
+ workerRetryTimer?: NodeJS.Timeout;
33
+ workerRetryAttempt: number;
34
+ lastWorkerIssue?: { message: string; reportedAt: number };
30
35
  }
31
36
 
32
37
  const RUNTIME_SYMBOL = Symbol.for("hwcode.knowledge.runtime.v3");
33
38
  const processGlobals = globalThis as unknown as Record<PropertyKey, unknown>;
34
- const runtime = (processGlobals[RUNTIME_SYMBOL] ??= {}) as ProcessKnowledgeRuntime;
39
+ const runtime = (processGlobals[RUNTIME_SYMBOL] ??= { workerRetryAttempt: 0 }) as ProcessKnowledgeRuntime;
40
+ runtime.workerRetryAttempt ??= 0;
41
+
42
+ const WORKER_RETRY_DELAYS_MS = [1_000, 5_000, 30_000, 60_000] as const;
35
43
 
36
44
  function post(message: KnowledgeWorkerInput): void {
37
45
  runtime.worker?.postMessage(message);
@@ -54,22 +62,47 @@ function cancelActiveReview(): void {
54
62
  runtime.activeReview?.controller.abort();
55
63
  }
56
64
 
57
- async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review_request" }>): Promise<void> {
58
- const ctx = runtime.context;
59
- if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
60
- post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "no-configured-model" });
65
+ function reportWorkerIssue(error: unknown): void {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ const now = Date.now();
68
+ const prior = runtime.lastWorkerIssue;
69
+ if (prior?.message === message && now - prior.reportedAt < 60_000) return;
70
+ runtime.lastWorkerIssue = { message, reportedAt: now };
71
+ const rendered = `HWCode knowledge worker failed: ${message}`;
72
+ if (runtime.context?.hasUI) runtime.context.ui.notify(rendered, "error");
73
+ else console.error(rendered);
74
+ }
75
+
76
+ function scheduleWorkerRestart(): void {
77
+ if (!runtime.context || runtime.workerRetryTimer) return;
78
+ const index = Math.min(runtime.workerRetryAttempt, WORKER_RETRY_DELAYS_MS.length - 1);
79
+ const delay = WORKER_RETRY_DELAYS_MS[index];
80
+ runtime.workerRetryAttempt += 1;
81
+ runtime.workerRetryTimer = setTimeout(() => {
82
+ runtime.workerRetryTimer = undefined;
83
+ if (!runtime.context) return;
84
+ ensureWorker();
61
85
  updateCapability();
62
- return;
63
- }
64
- if (!ctx.isIdle() || ctx.hasPendingMessages()) {
65
- post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "model-executor-is-busy" });
66
- return;
67
- }
68
- const controller = new AbortController();
69
- runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
70
- const timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
71
- timeout.unref();
86
+ }, delay);
87
+ runtime.workerRetryTimer.unref();
88
+ }
89
+
90
+ function workerEntryUrl(): URL {
91
+ const compiled = new URL("../dist/lib/knowledge/review-worker.js", import.meta.url);
92
+ if (existsSync(fileURLToPath(compiled))) return compiled;
93
+ return new URL("../lib/knowledge/review-worker.ts", import.meta.url);
94
+ }
95
+
96
+ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review_request" }>): Promise<void> {
97
+ let timeout: NodeJS.Timeout | undefined;
72
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();
73
106
  const response = await ctx.modelRegistry.complete(
74
107
  ctx.model,
75
108
  {
@@ -91,6 +124,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
91
124
  type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
92
125
  error: error instanceof Error ? error.message : String(error),
93
126
  });
127
+ try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
94
128
  } finally {
95
129
  clearTimeout(timeout);
96
130
  if (runtime.activeReview?.requestId === message.requestId) runtime.activeReview = undefined;
@@ -98,29 +132,41 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
98
132
  }
99
133
 
100
134
  function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
135
+ if (message.type === "ready") {
136
+ runtime.workerRetryAttempt = 0;
137
+ runtime.lastWorkerIssue = undefined;
138
+ return;
139
+ }
101
140
  if (message.type === "review_request") { void runReview(message); return; }
102
141
  if (message.type === "review_cancel" && runtime.activeReview?.requestId === message.requestId) {
103
142
  runtime.activeReview.controller.abort();
143
+ return;
104
144
  }
145
+ if (message.type === "review_failed") reportWorkerIssue(message.error);
105
146
  }
106
147
 
107
- function ensureWorker(): Worker {
148
+ function ensureWorker(): Worker | undefined {
108
149
  if (runtime.worker) return runtime.worker;
109
- const worker = new Worker(new URL("../lib/knowledge/review-worker.ts", import.meta.url));
150
+ if (runtime.workerRetryTimer) return undefined;
151
+ const worker = new Worker(workerEntryUrl());
110
152
  worker.unref();
111
153
  runtime.worker = worker;
112
154
  worker.on("message", (message: KnowledgeWorkerOutput) => handleWorkerMessage(message));
113
- worker.on("error", () => {
155
+ worker.on("error", (error) => {
156
+ reportWorkerIssue(error);
114
157
  cancelActiveReview();
115
158
  if (runtime.worker === worker) {
116
159
  runtime.worker = undefined;
117
160
  runtime.modelAvailable = undefined;
161
+ scheduleWorkerRestart();
118
162
  }
119
163
  });
120
- worker.on("exit", () => {
164
+ worker.on("exit", (code) => {
121
165
  if (runtime.worker === worker) {
122
166
  runtime.worker = undefined;
123
167
  runtime.modelAvailable = undefined;
168
+ if (code !== 0) reportWorkerIssue(`worker exited with code ${code}`);
169
+ scheduleWorkerRestart();
124
170
  }
125
171
  });
126
172
  runtime.capabilityTimer ??= setInterval(updateCapability, KNOWLEDGE_RUNTIME_DEFAULTS.review.capabilityPollMs);
@@ -9,10 +9,12 @@ Persist only knowledge that is likely to be useful in future sessions:
9
9
  - verified engineering rules, successful procedures, or expensive failed approaches;
10
10
  - reusable architecture, testing, debugging, deployment, or operational knowledge.
11
11
  Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
12
+ 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.
12
13
  Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
13
14
  Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
14
15
  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}]}
16
+ Each summary must be one complete sentence of at most 160 characters. Keep each body under 3000 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered.
17
+ Return: {"candidates":[{"key":"stable semantic key","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
18
  Return {"candidates":[]} when nothing meets the threshold.`;
17
19
 
18
20
  export function buildKnowledgeExtractionPrompt(projectRoot: string, delta: string): string {
@@ -25,9 +27,58 @@ export function parseCandidateEnvelope(text: string): unknown[] {
25
27
  const start = unfenced.indexOf("{");
26
28
  const end = unfenced.lastIndexOf("}");
27
29
  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);
30
+ let strictError: unknown;
31
+ try {
32
+ const parsed = JSON.parse(unfenced.slice(start, end + 1)) as { candidates?: unknown };
33
+ if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
34
+ return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
35
+ } catch (error) {
36
+ strictError = error;
37
+ }
38
+
39
+ const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
40
+ const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
41
+ if (arrayStart >= 0) {
42
+ const candidates: unknown[] = [];
43
+ let inString = false;
44
+ let escaped = false;
45
+ for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
46
+ const character = unfenced[index];
47
+ if (inString) {
48
+ if (escaped) escaped = false;
49
+ else if (character === "\\") escaped = true;
50
+ else if (character === '"') inString = false;
51
+ continue;
52
+ }
53
+ if (character === '"') { inString = true; continue; }
54
+ if (character !== "{") continue;
55
+ const objectEnd = balancedObjectEnd(unfenced, index);
56
+ if (objectEnd < 0) break;
57
+ try { candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1))); } catch { /* Salvage other complete candidates. */ }
58
+ index = objectEnd;
59
+ }
60
+ if (candidates.length > 0) return candidates;
61
+ }
62
+ throw strictError;
63
+ }
64
+
65
+ function balancedObjectEnd(value: string, start: number): number {
66
+ let depth = 0;
67
+ let inString = false;
68
+ let escaped = false;
69
+ for (let index = start; index < value.length; index++) {
70
+ const character = value[index];
71
+ if (inString) {
72
+ if (escaped) escaped = false;
73
+ else if (character === "\\") escaped = true;
74
+ else if (character === '"') inString = false;
75
+ continue;
76
+ }
77
+ if (character === '"') { inString = true; continue; }
78
+ if (character === "{") depth++;
79
+ else if (character === "}" && --depth === 0) return index;
80
+ }
81
+ return -1;
31
82
  }
32
83
 
33
84
  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());
@@ -258,3 +358,5 @@ parentPort.on("message", (message: KnowledgeWorkerInput) => {
258
358
  stepDown("worker-stopped");
259
359
  process.exit(0);
260
360
  });
361
+
362
+ send({ type: "ready" });
@@ -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
+ }
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
8
8
 
9
9
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
10
10
  import { userRuntimePaths } from "../runtime/paths.ts";
11
- import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
11
+ import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
12
12
  import type {
13
13
  KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
14
14
  KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
@@ -132,6 +132,7 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
132
132
  const raw = value as Record<string, unknown>;
133
133
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
134
134
  || typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
135
+ if (raw.durability !== "stable") return undefined;
135
136
  if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
136
137
  const explicitUserDirective = raw.explicitUserDirective === true;
137
138
  const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
@@ -143,12 +144,12 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
143
144
  && (explicitUserDirective || raw.confidence >= 0.9);
144
145
  return {
145
146
  key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
146
- summary: compactKnowledgeText(raw.summary, STORAGE.maxSummaryChars),
147
+ summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
147
148
  keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
148
149
  scope, body, evidence, confidence: Math.min(1, raw.confidence),
149
150
  storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
150
151
  action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
151
- explicitUserDirective,
152
+ explicitUserDirective, durability: "stable",
152
153
  };
153
154
  }
154
155
 
@@ -1,6 +1,7 @@
1
1
  export type KnowledgeTrack = "rule" | "topic";
2
2
  export type KnowledgeScope = "global" | `project:${string}`;
3
3
  export type KnowledgeAction = "add" | "reinforce" | "revise";
4
+ export type KnowledgeDurability = "stable";
4
5
 
5
6
  export interface KnowledgeCandidate {
6
7
  key: string;
@@ -14,6 +15,7 @@ export interface KnowledgeCandidate {
14
15
  storageHint: KnowledgeTrack;
15
16
  action: KnowledgeAction;
16
17
  explicitUserDirective: boolean;
18
+ durability: KnowledgeDurability;
17
19
  }
18
20
 
19
21
  export interface KnowledgeCatalogEntry {
@@ -7,6 +7,7 @@ export type KnowledgeWorkerInput =
7
7
  | { type: "stop" };
8
8
 
9
9
  export type KnowledgeWorkerOutput =
10
+ | { type: "ready" }
10
11
  | { type: "leadership"; state: "leader" | "standby" | "ineligible"; leaderToken?: string }
11
12
  | { type: "review_request"; leaderToken: string; requestId: string; task: KnowledgeReviewTask }
12
13
  | { type: "review_cancel"; requestId: string; reason: string }
@@ -31,9 +31,9 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
31
31
  intervalMs: 60_000,
32
32
  idleMs: 60_000,
33
33
  capabilityPollMs: 5_000,
34
- modelTimeoutMs: 120_000,
35
- maxDeltaChars: 30_000,
36
- maxCandidates: 8,
34
+ modelTimeoutMs: 180_000,
35
+ maxDeltaChars: 20_000,
36
+ maxCandidates: 5,
37
37
  minimumConfidence: 0.72,
38
38
  }),
39
39
  storage: Object.freeze({
@@ -34,6 +34,7 @@ export interface UserRuntimePaths {
34
34
  knowledgeRuntime: string;
35
35
  knowledgeCoordinatorSocket: string;
36
36
  knowledgeLeader: string;
37
+ knowledgeStatus: string;
37
38
  knowledgeCommitLock: string;
38
39
  knowledgeElectionLock: string;
39
40
  }
@@ -68,6 +69,7 @@ export function userRuntimePaths(home = homedir()): UserRuntimePaths {
68
69
  // Unix-domain socket paths are short on purpose (macOS caps them at roughly 104 bytes).
69
70
  knowledgeCoordinatorSocket: join(root, "knowledge-v3.sock"),
70
71
  knowledgeLeader: join(knowledge, "runtime", "leader.json"),
72
+ knowledgeStatus: join(knowledge, "runtime", "status.json"),
71
73
  knowledgeCommitLock: join(knowledge, "runtime", "commit.lock"),
72
74
  knowledgeElectionLock: join(knowledge, "runtime", "election.lock"),
73
75
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "bin/hwcode.js",
11
11
  ".env.example",
12
12
  ".pi/APPEND_SYSTEM.md",
13
+ ".pi/dist",
13
14
  ".pi/settings.json",
14
15
  ".pi/extensions",
15
16
  ".pi/lib",
@@ -25,10 +26,12 @@
25
26
  "scripts": {
26
27
  "start": "node --env-file-if-exists=.env ./bin/hwcode.js",
27
28
  "pi": "node --env-file-if-exists=.env ./bin/hwcode.js",
29
+ "build:knowledge-worker": "node scripts/build-knowledge-worker.mjs",
28
30
  "test": "node scripts/run-tests.mjs",
31
+ "test:package": "node scripts/test-packed-worker.mjs",
29
32
  "typecheck": "tsc -p tsconfig.json",
30
33
  "test:workflows": "npm test",
31
- "prepack": "npm run typecheck && npm test && node scripts/audit-package.mjs"
34
+ "prepack": "npm run typecheck && npm test && npm run build:knowledge-worker && npm run test:package && node scripts/audit-package.mjs"
32
35
  },
33
36
  "engines": {
34
37
  "node": ">=22.19.0"