@hadooppei/hwcode 1.0.10 → 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 +75 -5
- package/.pi/dist/lib/knowledge/review-status.js +20 -0
- package/.pi/dist/lib/knowledge/review-worker.js +105 -5
- package/.pi/dist/lib/knowledge/sanitize.js +20 -0
- package/.pi/dist/lib/knowledge/store.js +5 -3
- package/.pi/dist/lib/runtime/defaults.js +3 -3
- package/.pi/dist/lib/runtime/paths.js +1 -0
- package/.pi/extensions/knowledge.ts +9 -14
- package/.pi/lib/knowledge/extractor.ts +55 -4
- package/.pi/lib/knowledge/review-status.ts +55 -0
- package/.pi/lib/knowledge/review-worker.ts +106 -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/runtime/defaults.ts +3 -3
- package/.pi/lib/runtime/paths.ts +2 -0
- package/package.json +1 -1
|
@@ -7,10 +7,12 @@ Persist only knowledge that is likely to be useful in future sessions:
|
|
|
7
7
|
- verified engineering rules, successful procedures, or expensive failed approaches;
|
|
8
8
|
- reusable architecture, testing, debugging, deployment, or operational knowledge.
|
|
9
9
|
Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
|
|
10
|
+
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.
|
|
10
11
|
Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
|
|
11
12
|
Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
|
|
12
13
|
Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
|
|
13
|
-
|
|
14
|
+
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.
|
|
15
|
+
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"}]}
|
|
14
16
|
Return {"candidates":[]} when nothing meets the threshold.`;
|
|
15
17
|
export function buildKnowledgeExtractionPrompt(projectRoot, delta) {
|
|
16
18
|
return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
|
|
@@ -22,10 +24,78 @@ export function parseCandidateEnvelope(text) {
|
|
|
22
24
|
const end = unfenced.lastIndexOf("}");
|
|
23
25
|
if (start < 0 || end <= start)
|
|
24
26
|
throw new Error("Knowledge reviewer did not return a JSON object");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
let strictError;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(unfenced.slice(start, end + 1));
|
|
30
|
+
if (!Array.isArray(parsed.candidates))
|
|
31
|
+
throw new Error("Knowledge reviewer response is missing candidates[]");
|
|
32
|
+
return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
strictError = error;
|
|
36
|
+
}
|
|
37
|
+
const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
|
|
38
|
+
const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
|
|
39
|
+
if (arrayStart >= 0) {
|
|
40
|
+
const candidates = [];
|
|
41
|
+
let inString = false;
|
|
42
|
+
let escaped = false;
|
|
43
|
+
for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
|
|
44
|
+
const character = unfenced[index];
|
|
45
|
+
if (inString) {
|
|
46
|
+
if (escaped)
|
|
47
|
+
escaped = false;
|
|
48
|
+
else if (character === "\\")
|
|
49
|
+
escaped = true;
|
|
50
|
+
else if (character === '"')
|
|
51
|
+
inString = false;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (character === '"') {
|
|
55
|
+
inString = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (character !== "{")
|
|
59
|
+
continue;
|
|
60
|
+
const objectEnd = balancedObjectEnd(unfenced, index);
|
|
61
|
+
if (objectEnd < 0)
|
|
62
|
+
break;
|
|
63
|
+
try {
|
|
64
|
+
candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1)));
|
|
65
|
+
}
|
|
66
|
+
catch { /* Salvage other complete candidates. */ }
|
|
67
|
+
index = objectEnd;
|
|
68
|
+
}
|
|
69
|
+
if (candidates.length > 0)
|
|
70
|
+
return candidates;
|
|
71
|
+
}
|
|
72
|
+
throw strictError;
|
|
73
|
+
}
|
|
74
|
+
function balancedObjectEnd(value, start) {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let inString = false;
|
|
77
|
+
let escaped = false;
|
|
78
|
+
for (let index = start; index < value.length; index++) {
|
|
79
|
+
const character = value[index];
|
|
80
|
+
if (inString) {
|
|
81
|
+
if (escaped)
|
|
82
|
+
escaped = false;
|
|
83
|
+
else if (character === "\\")
|
|
84
|
+
escaped = true;
|
|
85
|
+
else if (character === '"')
|
|
86
|
+
inString = false;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (character === '"') {
|
|
90
|
+
inString = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (character === "{")
|
|
94
|
+
depth++;
|
|
95
|
+
else if (character === "}" && --depth === 0)
|
|
96
|
+
return index;
|
|
97
|
+
}
|
|
98
|
+
return -1;
|
|
29
99
|
}
|
|
30
100
|
export function knowledgeDeltaDigest(delta) {
|
|
31
101
|
return createHash("sha256").update(delta).digest("hex");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { userRuntimePaths } from "../runtime/paths.js";
|
|
4
|
+
export function writeKnowledgeReviewStatus(status, home) {
|
|
5
|
+
const path = userRuntimePaths(home).knowledgeStatus;
|
|
6
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
7
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
8
|
+
writeFileSync(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
9
|
+
renameSync(temporary, path);
|
|
10
|
+
chmodSync(path, 0o600);
|
|
11
|
+
}
|
|
12
|
+
export function readKnowledgeReviewStatus(home) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(readFileSync(userRuntimePaths(home).knowledgeStatus, "utf8"));
|
|
15
|
+
return parsed.version === 1 && parsed.protocolVersion === 3 ? parsed : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -6,6 +6,8 @@ import { parentPort, workerData } from "node:worker_threads";
|
|
|
6
6
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
7
7
|
import { userRuntimePaths } from "../runtime/paths.js";
|
|
8
8
|
import { parseCandidateEnvelope } from "./extractor.js";
|
|
9
|
+
import { writeKnowledgeReviewStatus } from "./review-status.js";
|
|
10
|
+
import { sanitizeKnowledgeText } from "./sanitize.js";
|
|
9
11
|
import { findNextReviewTask } from "./session-scanner.js";
|
|
10
12
|
import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.js";
|
|
11
13
|
if (!parentPort)
|
|
@@ -21,6 +23,11 @@ let leaderToken = "";
|
|
|
21
23
|
let standbySocket;
|
|
22
24
|
let electionPending = false;
|
|
23
25
|
let activeTask;
|
|
26
|
+
let activeAttempt = 0;
|
|
27
|
+
let activeStartedAt = 0;
|
|
28
|
+
let activeDeadlineTimer;
|
|
29
|
+
let reviewStatus;
|
|
30
|
+
const reviewAttempts = new Map();
|
|
24
31
|
function send(message) {
|
|
25
32
|
parentPort.postMessage(message);
|
|
26
33
|
}
|
|
@@ -29,12 +36,74 @@ function coordinatorPath() {
|
|
|
29
36
|
? `\\\\.\\pipe\\hwcode-knowledge-v3-${Buffer.from(home).toString("hex").slice(0, 16)}`
|
|
30
37
|
: paths.knowledgeCoordinatorSocket;
|
|
31
38
|
}
|
|
32
|
-
function writeLeaderMetadata() {
|
|
39
|
+
function writeLeaderMetadata(electedAt) {
|
|
33
40
|
writeFileSync(paths.knowledgeLeader, `${JSON.stringify({
|
|
34
|
-
protocolVersion: 3, leaderToken, pid: process.pid, electedAt
|
|
41
|
+
protocolVersion: 3, leaderToken, pid: process.pid, electedAt,
|
|
35
42
|
}, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
36
43
|
chmodSync(paths.knowledgeLeader, 0o600);
|
|
37
44
|
}
|
|
45
|
+
function publishReviewStatus() {
|
|
46
|
+
if (!reviewStatus)
|
|
47
|
+
return;
|
|
48
|
+
reviewStatus.updatedAt = new Date().toISOString();
|
|
49
|
+
try {
|
|
50
|
+
writeKnowledgeReviewStatus(reviewStatus, home);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
send({ type: "review_failed", error: `Unable to write knowledge status: ${error instanceof Error ? error.message : String(error)}` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function boundedError(error) {
|
|
57
|
+
return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error)).slice(0, 1_000);
|
|
58
|
+
}
|
|
59
|
+
function beginReview(task) {
|
|
60
|
+
activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
|
|
61
|
+
reviewAttempts.set(task.reviewKey, activeAttempt);
|
|
62
|
+
activeStartedAt = Date.now();
|
|
63
|
+
clearTimeout(activeDeadlineTimer);
|
|
64
|
+
activeDeadlineTimer = setTimeout(() => {
|
|
65
|
+
if (activeTask?.requestId !== task.requestId)
|
|
66
|
+
return;
|
|
67
|
+
const error = "knowledge-review-result-timeout";
|
|
68
|
+
finishReview(task, "failed", { error });
|
|
69
|
+
activeTask = undefined;
|
|
70
|
+
send({ type: "review_cancel", requestId: task.requestId, reason: error });
|
|
71
|
+
send({ type: "review_failed", requestId: task.requestId, error });
|
|
72
|
+
}, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
|
|
73
|
+
activeDeadlineTimer.unref();
|
|
74
|
+
if (reviewStatus) {
|
|
75
|
+
reviewStatus.activeReview = {
|
|
76
|
+
reviewKey: task.reviewKey,
|
|
77
|
+
sessionKey: task.sessionKey,
|
|
78
|
+
sessionFileHash: task.sessionFileHash,
|
|
79
|
+
projectKey: task.projectKey,
|
|
80
|
+
attempt: activeAttempt,
|
|
81
|
+
startedAt: new Date(activeStartedAt).toISOString(),
|
|
82
|
+
};
|
|
83
|
+
publishReviewStatus();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function finishReview(task, outcome, detail) {
|
|
87
|
+
clearTimeout(activeDeadlineTimer);
|
|
88
|
+
activeDeadlineTimer = undefined;
|
|
89
|
+
if (!reviewStatus)
|
|
90
|
+
return;
|
|
91
|
+
reviewStatus.activeReview = undefined;
|
|
92
|
+
reviewStatus.lastReview = {
|
|
93
|
+
reviewKey: task.reviewKey,
|
|
94
|
+
sessionKey: task.sessionKey,
|
|
95
|
+
attempt: activeAttempt,
|
|
96
|
+
outcome,
|
|
97
|
+
completedAt: new Date().toISOString(),
|
|
98
|
+
durationMs: Math.max(0, Date.now() - activeStartedAt),
|
|
99
|
+
generationId: detail.generationId,
|
|
100
|
+
result: detail.result,
|
|
101
|
+
error: detail.error === undefined ? undefined : boundedError(detail.error),
|
|
102
|
+
};
|
|
103
|
+
if (outcome === "saved")
|
|
104
|
+
reviewAttempts.delete(task.reviewKey);
|
|
105
|
+
publishReviewStatus();
|
|
106
|
+
}
|
|
38
107
|
function removeLeaderMetadata(token) {
|
|
39
108
|
try {
|
|
40
109
|
const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8"));
|
|
@@ -63,7 +132,17 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)) {
|
|
|
63
132
|
function stepDown(reason) {
|
|
64
133
|
if (activeTask)
|
|
65
134
|
send({ type: "review_cancel", requestId: activeTask.requestId, reason });
|
|
135
|
+
if (reviewStatus) {
|
|
136
|
+
reviewStatus.state = "stepped-down";
|
|
137
|
+
reviewStatus.stepDownReason = reason;
|
|
138
|
+
reviewStatus.activeReview = undefined;
|
|
139
|
+
publishReviewStatus();
|
|
140
|
+
}
|
|
66
141
|
activeTask = undefined;
|
|
142
|
+
clearTimeout(activeDeadlineTimer);
|
|
143
|
+
activeDeadlineTimer = undefined;
|
|
144
|
+
activeAttempt = 0;
|
|
145
|
+
activeStartedAt = 0;
|
|
67
146
|
const token = leaderToken;
|
|
68
147
|
leaderToken = "";
|
|
69
148
|
for (const socket of standbySockets)
|
|
@@ -85,6 +164,7 @@ function stepDown(reason) {
|
|
|
85
164
|
}
|
|
86
165
|
if (token)
|
|
87
166
|
removeLeaderMetadata(token);
|
|
167
|
+
reviewStatus = undefined;
|
|
88
168
|
}
|
|
89
169
|
function becomeStandby(socket) {
|
|
90
170
|
standbySocket = socket;
|
|
@@ -162,6 +242,7 @@ function connectToLeader(probe = 0) {
|
|
|
162
242
|
function becomeLeader(server) {
|
|
163
243
|
leaderServer = server;
|
|
164
244
|
leaderToken = randomUUID();
|
|
245
|
+
const electedAt = new Date().toISOString();
|
|
165
246
|
server.unref();
|
|
166
247
|
server.on("connection", (socket) => {
|
|
167
248
|
standbySockets.add(socket);
|
|
@@ -176,7 +257,11 @@ function becomeLeader(server) {
|
|
|
176
257
|
stepDown("coordinator-server-failed");
|
|
177
258
|
scheduleElection();
|
|
178
259
|
});
|
|
179
|
-
writeLeaderMetadata();
|
|
260
|
+
writeLeaderMetadata(electedAt);
|
|
261
|
+
reviewStatus = {
|
|
262
|
+
version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
|
|
263
|
+
};
|
|
264
|
+
publishReviewStatus();
|
|
180
265
|
send({ type: "leadership", state: "leader", leaderToken });
|
|
181
266
|
void scanForReview();
|
|
182
267
|
}
|
|
@@ -204,15 +289,22 @@ function attemptElection() {
|
|
|
204
289
|
async function scanForReview() {
|
|
205
290
|
if (!leaderServer || !leaderToken || activeTask || !sessionsRoot || !eligible)
|
|
206
291
|
return;
|
|
292
|
+
if (reviewStatus) {
|
|
293
|
+
reviewStatus.lastScanAt = new Date().toISOString();
|
|
294
|
+
reviewStatus.lastScanError = undefined;
|
|
295
|
+
publishReviewStatus();
|
|
296
|
+
}
|
|
207
297
|
try {
|
|
208
298
|
const manifest = loadCurrentManifest(home);
|
|
209
299
|
const task = findNextReviewTask(sessionsRoot, manifest.reviews);
|
|
210
300
|
if (!task)
|
|
211
301
|
return;
|
|
212
302
|
activeTask = task;
|
|
303
|
+
beginReview(task);
|
|
213
304
|
if (!task.delta) {
|
|
214
305
|
const result = commitKnowledgeReview([], task, leaderToken, home);
|
|
215
306
|
const generationId = loadCurrentManifest(home).generationId;
|
|
307
|
+
finishReview(task, "saved", { generationId, result });
|
|
216
308
|
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
217
309
|
if (generationId) {
|
|
218
310
|
send({ type: "generation_changed", generationId });
|
|
@@ -225,8 +317,14 @@ async function scanForReview() {
|
|
|
225
317
|
send({ type: "review_request", leaderToken, requestId: task.requestId, task });
|
|
226
318
|
}
|
|
227
319
|
catch (error) {
|
|
320
|
+
if (activeTask)
|
|
321
|
+
finishReview(activeTask, "failed", { error });
|
|
322
|
+
else if (reviewStatus) {
|
|
323
|
+
reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
|
|
324
|
+
publishReviewStatus();
|
|
325
|
+
}
|
|
228
326
|
activeTask = undefined;
|
|
229
|
-
send({ type: "review_failed", error:
|
|
327
|
+
send({ type: "review_failed", error: boundedError(error) });
|
|
230
328
|
}
|
|
231
329
|
}
|
|
232
330
|
function handleReviewResult(message) {
|
|
@@ -239,6 +337,7 @@ function handleReviewResult(message) {
|
|
|
239
337
|
throw new Error(message.error || "Knowledge review returned no content");
|
|
240
338
|
const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
|
|
241
339
|
const generationId = loadCurrentManifest(home).generationId;
|
|
340
|
+
finishReview(task, "saved", { generationId, result });
|
|
242
341
|
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
243
342
|
if (generationId) {
|
|
244
343
|
send({ type: "generation_changed", generationId });
|
|
@@ -247,7 +346,8 @@ function handleReviewResult(message) {
|
|
|
247
346
|
committed = true;
|
|
248
347
|
}
|
|
249
348
|
catch (error) {
|
|
250
|
-
|
|
349
|
+
finishReview(task, "failed", { error });
|
|
350
|
+
send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
|
|
251
351
|
}
|
|
252
352
|
finally {
|
|
253
353
|
activeTask = undefined;
|
|
@@ -22,3 +22,23 @@ export function compactKnowledgeText(value, maxChars) {
|
|
|
22
22
|
.trim()
|
|
23
23
|
.slice(0, maxChars);
|
|
24
24
|
}
|
|
25
|
+
export function compactKnowledgeSummary(value, maxChars) {
|
|
26
|
+
const normalized = sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim();
|
|
27
|
+
if (normalized.length <= maxChars || maxChars <= 0)
|
|
28
|
+
return normalized.slice(0, Math.max(0, maxChars));
|
|
29
|
+
if (maxChars === 1)
|
|
30
|
+
return "…";
|
|
31
|
+
const limit = maxChars - 1;
|
|
32
|
+
const prefix = normalized.slice(0, limit);
|
|
33
|
+
const minimumBoundary = Math.floor(limit * 0.6);
|
|
34
|
+
const sentenceEnd = Math.max(prefix.lastIndexOf("。"), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"), prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"));
|
|
35
|
+
if (sentenceEnd >= minimumBoundary)
|
|
36
|
+
return prefix.slice(0, sentenceEnd + 1).trimEnd();
|
|
37
|
+
const next = normalized.charAt(limit);
|
|
38
|
+
if (/[A-Za-z0-9]/u.test(prefix.at(-1) ?? "") && /[A-Za-z0-9]/u.test(next)) {
|
|
39
|
+
const wordBoundary = Math.max(prefix.lastIndexOf(" "), prefix.lastIndexOf("-"), prefix.lastIndexOf("/"));
|
|
40
|
+
if (wordBoundary >= minimumBoundary)
|
|
41
|
+
return `${prefix.slice(0, wordBoundary).trimEnd()}…`;
|
|
42
|
+
}
|
|
43
|
+
return `${prefix.trimEnd()}…`;
|
|
44
|
+
}
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
5
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
6
6
|
import { userRuntimePaths } from "../runtime/paths.js";
|
|
7
|
-
import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.js";
|
|
7
|
+
import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.js";
|
|
8
8
|
const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
|
|
9
9
|
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
|
|
10
10
|
const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
|
|
@@ -117,6 +117,8 @@ function normalizeCandidate(value, projectKey) {
|
|
|
117
117
|
if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
|
|
118
118
|
|| typeof raw.body !== "string" || typeof raw.confidence !== "number")
|
|
119
119
|
return undefined;
|
|
120
|
+
if (raw.durability !== "stable")
|
|
121
|
+
return undefined;
|
|
120
122
|
if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence)
|
|
121
123
|
return undefined;
|
|
122
124
|
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
@@ -130,12 +132,12 @@ function normalizeCandidate(value, projectKey) {
|
|
|
130
132
|
&& (explicitUserDirective || raw.confidence >= 0.9);
|
|
131
133
|
return {
|
|
132
134
|
key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
|
|
133
|
-
summary:
|
|
135
|
+
summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
|
|
134
136
|
keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
|
|
135
137
|
scope, body, evidence, confidence: Math.min(1, raw.confidence),
|
|
136
138
|
storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
|
|
137
139
|
action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
|
|
138
|
-
explicitUserDirective,
|
|
140
|
+
explicitUserDirective, durability: "stable",
|
|
139
141
|
};
|
|
140
142
|
}
|
|
141
143
|
function renderKnowledgeFile(candidate) {
|
|
@@ -30,9 +30,9 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
|
|
|
30
30
|
intervalMs: 60_000,
|
|
31
31
|
idleMs: 60_000,
|
|
32
32
|
capabilityPollMs: 5_000,
|
|
33
|
-
modelTimeoutMs:
|
|
34
|
-
maxDeltaChars:
|
|
35
|
-
maxCandidates:
|
|
33
|
+
modelTimeoutMs: 180_000,
|
|
34
|
+
maxDeltaChars: 20_000,
|
|
35
|
+
maxCandidates: 5,
|
|
36
36
|
minimumConfidence: 0.72,
|
|
37
37
|
}),
|
|
38
38
|
storage: Object.freeze({
|
|
@@ -40,6 +40,7 @@ export function userRuntimePaths(home = homedir()) {
|
|
|
40
40
|
// Unix-domain socket paths are short on purpose (macOS caps them at roughly 104 bytes).
|
|
41
41
|
knowledgeCoordinatorSocket: join(root, "knowledge-v3.sock"),
|
|
42
42
|
knowledgeLeader: join(knowledge, "runtime", "leader.json"),
|
|
43
|
+
knowledgeStatus: join(knowledge, "runtime", "status.json"),
|
|
43
44
|
knowledgeCommitLock: join(knowledge, "runtime", "commit.lock"),
|
|
44
45
|
knowledgeElectionLock: join(knowledge, "runtime", "election.lock"),
|
|
45
46
|
};
|
|
@@ -94,21 +94,15 @@ function workerEntryUrl(): URL {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review_request" }>): Promise<void> {
|
|
97
|
-
|
|
98
|
-
if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
|
|
99
|
-
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "no-configured-model" });
|
|
100
|
-
updateCapability();
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
104
|
-
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "model-executor-is-busy" });
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
const controller = new AbortController();
|
|
108
|
-
runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
|
|
109
|
-
const timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
|
|
110
|
-
timeout.unref();
|
|
97
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
111
98
|
try {
|
|
99
|
+
const ctx = runtime.context;
|
|
100
|
+
if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
|
|
101
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) throw new Error("model-executor-is-busy");
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
|
|
104
|
+
timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
|
|
105
|
+
timeout.unref();
|
|
112
106
|
const response = await ctx.modelRegistry.complete(
|
|
113
107
|
ctx.model,
|
|
114
108
|
{
|
|
@@ -130,6 +124,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
130
124
|
type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
|
|
131
125
|
error: error instanceof Error ? error.message : String(error),
|
|
132
126
|
});
|
|
127
|
+
try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
|
|
133
128
|
} finally {
|
|
134
129
|
clearTimeout(timeout);
|
|
135
130
|
if (runtime.activeReview?.requestId === message.requestId) runtime.activeReview = undefined;
|
|
@@ -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());
|
|
@@ -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 {
|
|
@@ -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
|
};
|