@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.
- package/.pi/dist/lib/knowledge/extractor.js +102 -0
- package/.pi/dist/lib/knowledge/review-status.js +20 -0
- package/.pi/dist/lib/knowledge/review-worker.js +392 -0
- package/.pi/dist/lib/knowledge/sanitize.js +44 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +165 -0
- package/.pi/dist/lib/knowledge/store.js +372 -0
- package/.pi/dist/lib/knowledge/types.js +1 -0
- package/.pi/dist/lib/knowledge/worker-protocol.js +1 -0
- package/.pi/dist/lib/runtime/defaults.js +50 -0
- package/.pi/dist/lib/runtime/paths.js +56 -0
- package/.pi/extensions/knowledge.ts +65 -19
- package/.pi/lib/knowledge/extractor.ts +55 -4
- package/.pi/lib/knowledge/review-status.ts +55 -0
- package/.pi/lib/knowledge/review-worker.ts +108 -6
- package/.pi/lib/knowledge/sanitize.ts +20 -0
- package/.pi/lib/knowledge/store.ts +4 -3
- package/.pi/lib/knowledge/types.ts +2 -0
- package/.pi/lib/knowledge/worker-protocol.ts +1 -0
- package/.pi/lib/runtime/defaults.ts +3 -3
- package/.pi/lib/runtime/paths.ts +2 -0
- package/package.json +5 -2
|
@@ -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
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
35
|
-
maxDeltaChars:
|
|
36
|
-
maxCandidates:
|
|
34
|
+
modelTimeoutMs: 180_000,
|
|
35
|
+
maxDeltaChars: 20_000,
|
|
36
|
+
maxCandidates: 5,
|
|
37
37
|
minimumConfidence: 0.72,
|
|
38
38
|
}),
|
|
39
39
|
storage: Object.freeze({
|
package/.pi/lib/runtime/paths.ts
CHANGED
|
@@ -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.
|
|
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"
|