@hadooppei/hwcode 1.0.8 → 1.0.9

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