@hadooppei/hwcode 1.0.8 → 1.0.10

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,212 +1,199 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { Worker } from "node:worker_threads";
3
6
 
4
7
  import { Type } from "@earendil-works/pi-ai";
5
8
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
9
 
7
10
  import {
8
- buildKnowledgeExtractionPrompt, knowledgeDeltaDigest, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
11
+ buildKnowledgeExtractionPrompt, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
9
12
  } from "../lib/knowledge/extractor.ts";
10
13
  import { matchKnowledge } from "../lib/knowledge/matcher.ts";
11
- import {
12
- loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey,
13
- } from "../lib/knowledge/store.ts";
14
- import type { KnowledgeReviewState, KnowledgeSnapshot } from "../lib/knowledge/types.ts";
14
+ import { loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey } from "../lib/knowledge/store.ts";
15
15
  import type { KnowledgeWorkerInput, KnowledgeWorkerOutput } from "../lib/knowledge/worker-protocol.ts";
16
16
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../lib/runtime/defaults.ts";
17
17
  import { getWorkingDirectory } from "../lib/working-directory.ts";
18
18
 
19
- export const KNOWLEDGE_REVIEW_STATE_TYPE = "hwcode-knowledge-review-state";
20
-
21
- interface PendingReview {
19
+ interface ActiveReview {
22
20
  requestId: string;
23
- lastEntryId: string;
24
- deltaDigest: string;
21
+ leaderToken: string;
22
+ controller: AbortController;
25
23
  }
26
24
 
27
- function textContent(content: unknown): string {
28
- if (typeof content === "string") return content;
29
- if (!Array.isArray(content)) return "";
30
- return content.map((item) => {
31
- if (!item || typeof item !== "object") return "";
32
- const value = item as Record<string, unknown>;
33
- return typeof value.text === "string" ? value.text : "";
34
- }).filter(Boolean).join("\n");
25
+ interface ProcessKnowledgeRuntime {
26
+ worker?: Worker;
27
+ context?: ExtensionContext;
28
+ sessionsRoot?: string;
29
+ modelAvailable?: boolean;
30
+ activeReview?: ActiveReview;
31
+ capabilityTimer?: NodeJS.Timeout;
32
+ workerRetryTimer?: NodeJS.Timeout;
33
+ workerRetryAttempt: number;
34
+ lastWorkerIssue?: { message: string; reportedAt: number };
35
35
  }
36
36
 
37
- function entryMessageText(entry: unknown): string {
38
- if (!entry || typeof entry !== "object") return "";
39
- const data = entry as Record<string, unknown>;
40
- if (data.type === "compaction" || data.type === "branch_summary") {
41
- return typeof data.summary === "string" ? `[summary]\n${data.summary}` : "";
42
- }
43
- if (data.type !== "message" || !data.message || typeof data.message !== "object") return "";
44
- const message = data.message as Record<string, unknown>;
45
- const role = typeof message.role === "string" ? message.role : "message";
46
- const content = textContent(message.content);
47
- return content ? `[${role}]\n${content}` : "";
48
- }
37
+ const RUNTIME_SYMBOL = Symbol.for("hwcode.knowledge.runtime.v3");
38
+ const processGlobals = globalThis as unknown as Record<PropertyKey, unknown>;
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;
49
43
 
50
- function entryId(entry: unknown): string | undefined {
51
- if (!entry || typeof entry !== "object") return undefined;
52
- const id = (entry as Record<string, unknown>).id;
53
- return typeof id === "string" ? id : undefined;
44
+ function post(message: KnowledgeWorkerInput): void {
45
+ runtime.worker?.postMessage(message);
54
46
  }
55
47
 
56
- function reviewableEntries(ctx: ExtensionContext): readonly unknown[] {
57
- return ctx.sessionManager.getBranch().filter((entry) => Boolean(entryMessageText(entry)));
48
+ function modelAvailable(ctx: ExtensionContext | undefined): boolean {
49
+ return Boolean(ctx?.model && ctx.modelRegistry.hasConfiguredAuth(ctx.model));
58
50
  }
59
51
 
60
- function lastReviewableEntryId(ctx: ExtensionContext): string | undefined {
61
- const entries = reviewableEntries(ctx);
62
- return entries.length > 0 ? entryId(entries[entries.length - 1]) : undefined;
52
+ function updateCapability(): void {
53
+ if (!runtime.worker && runtime.context) ensureWorker();
54
+ if (!runtime.worker || !runtime.sessionsRoot) return;
55
+ const available = modelAvailable(runtime.context);
56
+ if (runtime.modelAvailable === available) return;
57
+ runtime.modelAvailable = available;
58
+ post({ type: "configure", modelAvailable: available, sessionsRoot: runtime.sessionsRoot });
63
59
  }
64
60
 
65
- function buildConversationDelta(ctx: ExtensionContext, lastReviewedEntryId?: string): { text: string; lastEntryId?: string } {
66
- const entries = reviewableEntries(ctx);
67
- const lastIndex = lastReviewedEntryId ? entries.findIndex((entry) => entryId(entry) === lastReviewedEntryId) : -1;
68
- const pending = entries.slice(lastIndex + 1);
69
- const lastEntryId = pending.length > 0 ? entryId(pending[pending.length - 1]) : undefined;
70
- const parts: string[] = [];
71
- let chars = 0;
72
- for (const entry of pending.slice().reverse()) {
73
- const part = entryMessageText(entry);
74
- if (!part) continue;
75
- const remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars - chars;
76
- if (remaining <= 0) break;
77
- const clipped = part.slice(0, remaining);
78
- parts.unshift(clipped);
79
- chars += clipped.length;
80
- if (clipped.length < part.length) break;
81
- }
82
- return { text: parts.join("\n\n"), lastEntryId };
61
+ function cancelActiveReview(): void {
62
+ runtime.activeReview?.controller.abort();
83
63
  }
84
64
 
85
- function loadReviewState(ctx: ExtensionContext): KnowledgeReviewState {
86
- for (const entry of ctx.sessionManager.getBranch().slice().reverse()) {
87
- if (entry.type !== "custom" || entry.customType !== KNOWLEDGE_REVIEW_STATE_TYPE) continue;
88
- const data = entry.data as Partial<KnowledgeReviewState> | undefined;
89
- if (data?.version === 1) return data as KnowledgeReviewState;
90
- break;
91
- }
92
- return { version: 1 };
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);
93
74
  }
94
75
 
95
- export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
96
- let worker: Worker | undefined;
97
- let activeContext: ExtensionContext | undefined;
98
- let activeProjectRoot = "";
99
- let activeProjectKey = "";
100
- let snapshot: KnowledgeSnapshot = { rulesPrompt: "", memoryPrompt: "", catalog: { version: 2, updatedAt: "", items: [] } };
101
- let reviewState: KnowledgeReviewState = { version: 1 };
102
- let pendingReview: PendingReview | undefined;
103
- let reviewController: AbortController | undefined;
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();
85
+ updateCapability();
86
+ }, delay);
87
+ runtime.workerRetryTimer.unref();
88
+ }
104
89
 
105
- const post = (message: KnowledgeWorkerInput) => worker?.postMessage(message);
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
+ }
106
95
 
107
- function cancelReview(reason: string): void {
108
- reviewController?.abort();
109
- reviewController = undefined;
110
- if (pendingReview) {
111
- post({ type: "review_result", requestId: pendingReview.requestId, error: reason });
112
- pendingReview = undefined;
113
- }
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();
111
+ try {
112
+ const response = await ctx.modelRegistry.complete(
113
+ ctx.model,
114
+ {
115
+ systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
116
+ messages: [{
117
+ role: "user",
118
+ content: [{ type: "text", text: buildKnowledgeExtractionPrompt(message.task.projectRoot, message.task.delta) }],
119
+ timestamp: Date.now(),
120
+ }],
121
+ },
122
+ { signal: controller.signal, reasoningEffort: "low", cacheRetention: "none", sessionId: randomUUID() },
123
+ );
124
+ if (controller.signal.aborted) throw new Error("knowledge-review-aborted-or-timed-out");
125
+ const raw = response.content.filter((item): item is { type: "text"; text: string } => item.type === "text")
126
+ .map((item) => item.text).join("\n");
127
+ post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
128
+ } catch (error) {
129
+ post({
130
+ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
131
+ error: error instanceof Error ? error.message : String(error),
132
+ });
133
+ } finally {
134
+ clearTimeout(timeout);
135
+ if (runtime.activeReview?.requestId === message.requestId) runtime.activeReview = undefined;
114
136
  }
137
+ }
115
138
 
116
- async function runReview(requestId: string): Promise<void> {
117
- const ctx = activeContext;
118
- if (!ctx || !ctx.isIdle() || ctx.hasPendingMessages()) {
119
- post({ type: "review_result", requestId, error: "session-is-busy" });
120
- return;
121
- }
122
- const delta = buildConversationDelta(ctx, reviewState.lastReviewedEntryId);
123
- if (!delta.text || !delta.lastEntryId) {
124
- post({ type: "review_result", requestId, raw: '{"candidates":[]}' });
125
- return;
126
- }
127
- pendingReview = { requestId, lastEntryId: delta.lastEntryId, deltaDigest: knowledgeDeltaDigest(delta.text) };
128
- if (!ctx.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
129
- post({ type: "review_result", requestId, error: "no-configured-model" });
130
- pendingReview = undefined;
131
- return;
132
- }
133
- const controller = new AbortController();
134
- reviewController = controller;
135
- try {
136
- const response = await ctx.modelRegistry.complete(
137
- ctx.model,
138
- {
139
- systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
140
- messages: [{
141
- role: "user",
142
- content: [{ type: "text", text: buildKnowledgeExtractionPrompt(activeProjectRoot, delta.text) }],
143
- timestamp: Date.now(),
144
- }],
145
- },
146
- { signal: controller.signal, reasoningEffort: "low", cacheRetention: "none", sessionId: randomUUID() },
147
- );
148
- if (controller.signal.aborted) return;
149
- const raw = response.content.filter((item): item is { type: "text"; text: string } => item.type === "text")
150
- .map((item) => item.text).join("\n");
151
- post({ type: "review_result", requestId, raw });
152
- } catch (error) {
153
- if (!controller.signal.aborted) {
154
- post({ type: "review_result", requestId, error: error instanceof Error ? error.message : String(error) });
155
- pendingReview = undefined;
156
- }
157
- } finally {
158
- if (reviewController === controller) reviewController = undefined;
159
- }
139
+ function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
140
+ if (message.type === "ready") {
141
+ runtime.workerRetryAttempt = 0;
142
+ runtime.lastWorkerIssue = undefined;
143
+ return;
144
+ }
145
+ if (message.type === "review_request") { void runReview(message); return; }
146
+ if (message.type === "review_cancel" && runtime.activeReview?.requestId === message.requestId) {
147
+ runtime.activeReview.controller.abort();
148
+ return;
160
149
  }
150
+ if (message.type === "review_failed") reportWorkerIssue(message.error);
151
+ }
161
152
 
162
- function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
163
- if (message.type === "review_due") {
164
- void runReview(message.requestId);
165
- return;
166
- }
167
- if (message.type === "review_saved" && pendingReview?.requestId === message.requestId) {
168
- const result = message.result.saved + message.result.updated > 0 ? "saved" : "noop";
169
- reviewState = {
170
- version: 1,
171
- lastReviewedEntryId: pendingReview.lastEntryId,
172
- lastReviewedAt: new Date().toISOString(),
173
- lastDeltaDigest: pendingReview.deltaDigest,
174
- lastResult: result,
175
- };
176
- pi.appendEntry<KnowledgeReviewState>(KNOWLEDGE_REVIEW_STATE_TYPE, reviewState);
177
- snapshot = loadKnowledgeSnapshot(activeProjectKey);
178
- pendingReview = undefined;
179
- return;
153
+ function ensureWorker(): Worker | undefined {
154
+ if (runtime.worker) return runtime.worker;
155
+ if (runtime.workerRetryTimer) return undefined;
156
+ const worker = new Worker(workerEntryUrl());
157
+ worker.unref();
158
+ runtime.worker = worker;
159
+ worker.on("message", (message: KnowledgeWorkerOutput) => handleWorkerMessage(message));
160
+ worker.on("error", (error) => {
161
+ reportWorkerIssue(error);
162
+ cancelActiveReview();
163
+ if (runtime.worker === worker) {
164
+ runtime.worker = undefined;
165
+ runtime.modelAvailable = undefined;
166
+ scheduleWorkerRestart();
180
167
  }
181
- if (message.type === "review_failed" && pendingReview?.requestId === message.requestId) {
182
- pendingReview = undefined;
168
+ });
169
+ worker.on("exit", (code) => {
170
+ if (runtime.worker === worker) {
171
+ runtime.worker = undefined;
172
+ runtime.modelAvailable = undefined;
173
+ if (code !== 0) reportWorkerIssue(`worker exited with code ${code}`);
174
+ scheduleWorkerRestart();
183
175
  }
184
- }
176
+ });
177
+ runtime.capabilityTimer ??= setInterval(updateCapability, KNOWLEDGE_RUNTIME_DEFAULTS.review.capabilityPollMs);
178
+ runtime.capabilityTimer.unref();
179
+ return worker;
180
+ }
185
181
 
186
- function startWorker(ctx: ExtensionContext): void {
187
- cancelReview("session-replaced");
188
- if (worker) {
189
- post({ type: "stop" });
190
- void worker.terminate();
191
- }
192
- activeContext = ctx;
193
- activeProjectRoot = getWorkingDirectory(ctx.sessionManager);
194
- activeProjectKey = projectKnowledgeKey(activeProjectRoot);
195
- reviewState = loadReviewState(ctx);
196
- snapshot = loadKnowledgeSnapshot(activeProjectKey);
197
- const startedWorker = new Worker(new URL("../lib/knowledge/review-worker.ts", import.meta.url));
198
- worker = startedWorker;
199
- startedWorker.on("message", (message: KnowledgeWorkerOutput) => handleWorkerMessage(message));
200
- startedWorker.on("error", () => { if (worker === startedWorker) worker = undefined; });
201
- const latest = lastReviewableEntryId(ctx);
202
- post({
203
- type: "configure",
204
- sessionId: ctx.sessionManager.getSessionId(),
205
- projectKey: activeProjectKey,
206
- dirty: Boolean(latest && latest !== reviewState.lastReviewedEntryId),
207
- });
208
- }
182
+ function configureForContext(ctx: ExtensionContext): void {
183
+ runtime.context = ctx;
184
+ runtime.sessionsRoot = dirname(ctx.sessionManager.getSessionDir());
185
+ ensureWorker();
186
+ const available = modelAvailable(ctx);
187
+ runtime.modelAvailable = available;
188
+ post({ type: "configure", modelAvailable: available, sessionsRoot: runtime.sessionsRoot });
189
+ }
209
190
 
191
+ function currentProject(ctx: ExtensionContext): { root: string; key: string } {
192
+ const root = getWorkingDirectory(ctx.sessionManager);
193
+ return { root, key: projectKnowledgeKey(root) };
194
+ }
195
+
196
+ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
210
197
  pi.registerTool(defineTool({
211
198
  name: "hwcode_knowledge_lookup",
212
199
  label: "HWCode Knowledge Lookup",
@@ -218,14 +205,18 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
218
205
  }),
219
206
  executionMode: "sequential",
220
207
  async execute(_id, params) {
208
+ const ctx = runtime.context;
209
+ if (!ctx) return { content: [{ type: "text", text: "Knowledge context is not available." }], isError: true, details: {} };
210
+ const { key } = currentProject(ctx);
221
211
  if (params.id) {
222
- const found = loadKnowledgeById(params.id, activeProjectKey);
212
+ const found = loadKnowledgeById(params.id, key);
223
213
  if (!found) return { content: [{ type: "text", text: `No applicable knowledge found with ID: ${params.id}` }], isError: true, details: {} };
224
214
  return { content: [{ type: "text", text: found.content }], details: {} };
225
215
  }
226
216
  if (params.query) {
217
+ const snapshot = loadKnowledgeSnapshot(key);
227
218
  const matches = matchKnowledge(params.query, snapshot.catalog)
228
- .filter((item) => item.scope === "global" || item.scope === `project:${activeProjectKey}`);
219
+ .filter((item) => item.scope === "global" || item.scope === `project:${key}`);
229
220
  if (matches.length === 0) return { content: [{ type: "text", text: `No relevant knowledge found for: ${params.query}` }], details: {} };
230
221
  return {
231
222
  content: [{ type: "text", text: matches.map((item) => `- [${item.id}] ${item.title}: ${item.summary}`).join("\n") }],
@@ -236,10 +227,18 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
236
227
  },
237
228
  }));
238
229
 
239
- pi.on("session_start", (_event, ctx) => startWorker(ctx));
230
+ pi.on("session_start", (_event, ctx) => configureForContext(ctx));
231
+
232
+ pi.on("model_select", (_event, ctx) => {
233
+ runtime.context = ctx;
234
+ updateCapability();
235
+ });
240
236
 
241
237
  pi.on("before_agent_start", (event, ctx) => {
242
- activeContext = ctx;
238
+ runtime.context = ctx;
239
+ updateCapability();
240
+ const { key } = currentProject(ctx);
241
+ const snapshot = loadKnowledgeSnapshot(key);
243
242
  const sections = [
244
243
  snapshot.rulesPrompt ? `<hwcode_rules>\n${snapshot.rulesPrompt}\n</hwcode_rules>` : "",
245
244
  snapshot.memoryPrompt ? `<hwcode_knowledge_index>\n${snapshot.memoryPrompt}\n</hwcode_knowledge_index>` : "",
@@ -249,31 +248,28 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
249
248
  });
250
249
 
251
250
  pi.on("input", (_event, ctx) => {
252
- activeContext = ctx;
253
- cancelReview("new-user-input");
254
- post({ type: "activity", state: "busy", dirty: true });
251
+ runtime.context = ctx;
252
+ cancelActiveReview();
253
+ updateCapability();
255
254
  return undefined;
256
255
  });
257
256
 
258
257
  pi.on("agent_start", (_event, ctx) => {
259
- activeContext = ctx;
260
- cancelReview("agent-started");
261
- post({ type: "activity", state: "busy", dirty: true });
258
+ runtime.context = ctx;
259
+ cancelActiveReview();
262
260
  });
263
261
 
264
262
  pi.on("agent_settled", (_event, ctx) => {
265
- activeContext = ctx;
266
- const latest = lastReviewableEntryId(ctx);
267
- post({ type: "activity", state: "settled", dirty: Boolean(latest && latest !== reviewState.lastReviewedEntryId) });
263
+ runtime.context = ctx;
264
+ updateCapability();
265
+ post({ type: "scan_now" });
268
266
  });
269
267
 
270
- pi.on("session_shutdown", async () => {
271
- cancelReview("session-shutdown");
272
- if (worker) {
273
- post({ type: "stop" });
274
- await worker.terminate();
268
+ pi.on("session_shutdown", (event) => {
269
+ cancelActiveReview();
270
+ if (event.reason === "quit") {
271
+ runtime.context = undefined;
272
+ updateCapability();
275
273
  }
276
- worker = undefined;
277
- activeContext = undefined;
278
274
  });
279
275
  }