@hadooppei/hwcode 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/dist/lib/knowledge/extractor.js +32 -0
- package/.pi/dist/lib/knowledge/review-worker.js +292 -0
- package/.pi/dist/lib/knowledge/sanitize.js +24 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +165 -0
- package/.pi/dist/lib/knowledge/store.js +370 -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 +55 -0
- package/.pi/extensions/knowledge.ts +186 -190
- package/.pi/lib/knowledge/review-worker.ts +240 -42
- package/.pi/lib/knowledge/session-scanner.ts +155 -0
- package/.pi/lib/knowledge/store.ts +237 -123
- package/.pi/lib/knowledge/types.ts +37 -5
- package/.pi/lib/knowledge/worker-protocol.ts +16 -7
- package/.pi/lib/runtime/defaults.ts +5 -2
- package/.pi/lib/runtime/paths.ts +16 -11
- package/README.md +19 -16
- package/package.json +5 -2
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
3
|
+
export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
|
|
4
|
+
Review only the supplied conversation delta. Return strict JSON and no markdown.
|
|
5
|
+
Persist only knowledge that is likely to be useful in future sessions:
|
|
6
|
+
- explicit user corrections or stable preferences;
|
|
7
|
+
- verified engineering rules, successful procedures, or expensive failed approaches;
|
|
8
|
+
- reusable architecture, testing, debugging, deployment, or operational knowledge.
|
|
9
|
+
Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
|
|
10
|
+
Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
|
|
11
|
+
Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
|
|
12
|
+
Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
|
|
13
|
+
Return: {"candidates":[{"key":"stable semantic key","title":"...","summary":"...","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false}]}
|
|
14
|
+
Return {"candidates":[]} when nothing meets the threshold.`;
|
|
15
|
+
export function buildKnowledgeExtractionPrompt(projectRoot, delta) {
|
|
16
|
+
return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
|
|
17
|
+
}
|
|
18
|
+
export function parseCandidateEnvelope(text) {
|
|
19
|
+
const trimmed = text.trim();
|
|
20
|
+
const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
|
|
21
|
+
const start = unfenced.indexOf("{");
|
|
22
|
+
const end = unfenced.lastIndexOf("}");
|
|
23
|
+
if (start < 0 || end <= start)
|
|
24
|
+
throw new Error("Knowledge reviewer did not return a JSON object");
|
|
25
|
+
const parsed = JSON.parse(unfenced.slice(start, end + 1));
|
|
26
|
+
if (!Array.isArray(parsed.candidates))
|
|
27
|
+
throw new Error("Knowledge reviewer response is missing candidates[]");
|
|
28
|
+
return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
|
|
29
|
+
}
|
|
30
|
+
export function knowledgeDeltaDigest(delta) {
|
|
31
|
+
return createHash("sha256").update(delta).digest("hex");
|
|
32
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createConnection, createServer } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
6
|
+
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
7
|
+
import { userRuntimePaths } from "../runtime/paths.js";
|
|
8
|
+
import { parseCandidateEnvelope } from "./extractor.js";
|
|
9
|
+
import { findNextReviewTask } from "./session-scanner.js";
|
|
10
|
+
import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.js";
|
|
11
|
+
if (!parentPort)
|
|
12
|
+
throw new Error("Knowledge review worker requires a parent port");
|
|
13
|
+
const home = workerData?.home ?? homedir();
|
|
14
|
+
const paths = userRuntimePaths(home);
|
|
15
|
+
const standbySockets = new Set();
|
|
16
|
+
let eligible = false;
|
|
17
|
+
let stopped = false;
|
|
18
|
+
let sessionsRoot = "";
|
|
19
|
+
let leaderServer;
|
|
20
|
+
let leaderToken = "";
|
|
21
|
+
let standbySocket;
|
|
22
|
+
let electionPending = false;
|
|
23
|
+
let activeTask;
|
|
24
|
+
function send(message) {
|
|
25
|
+
parentPort.postMessage(message);
|
|
26
|
+
}
|
|
27
|
+
function coordinatorPath() {
|
|
28
|
+
return process.platform === "win32"
|
|
29
|
+
? `\\\\.\\pipe\\hwcode-knowledge-v3-${Buffer.from(home).toString("hex").slice(0, 16)}`
|
|
30
|
+
: paths.knowledgeCoordinatorSocket;
|
|
31
|
+
}
|
|
32
|
+
function writeLeaderMetadata() {
|
|
33
|
+
writeFileSync(paths.knowledgeLeader, `${JSON.stringify({
|
|
34
|
+
protocolVersion: 3, leaderToken, pid: process.pid, electedAt: new Date().toISOString(),
|
|
35
|
+
}, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
36
|
+
chmodSync(paths.knowledgeLeader, 0o600);
|
|
37
|
+
}
|
|
38
|
+
function removeLeaderMetadata(token) {
|
|
39
|
+
try {
|
|
40
|
+
const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8"));
|
|
41
|
+
if (current.leaderToken === token)
|
|
42
|
+
rmSync(paths.knowledgeLeader, { force: true });
|
|
43
|
+
}
|
|
44
|
+
catch { /* Another contender may already have replaced it. */ }
|
|
45
|
+
}
|
|
46
|
+
function broadcast(message) {
|
|
47
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
48
|
+
for (const socket of standbySockets) {
|
|
49
|
+
if (socket.writable)
|
|
50
|
+
socket.write(line);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function scheduleElection(delay = Math.floor(Math.random() * 500)) {
|
|
54
|
+
if (!eligible || stopped || electionPending || leaderServer || standbySocket)
|
|
55
|
+
return;
|
|
56
|
+
electionPending = true;
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
electionPending = false;
|
|
59
|
+
attemptElection();
|
|
60
|
+
}, delay);
|
|
61
|
+
timer.unref();
|
|
62
|
+
}
|
|
63
|
+
function stepDown(reason) {
|
|
64
|
+
if (activeTask)
|
|
65
|
+
send({ type: "review_cancel", requestId: activeTask.requestId, reason });
|
|
66
|
+
activeTask = undefined;
|
|
67
|
+
const token = leaderToken;
|
|
68
|
+
leaderToken = "";
|
|
69
|
+
for (const socket of standbySockets)
|
|
70
|
+
socket.destroy();
|
|
71
|
+
standbySockets.clear();
|
|
72
|
+
const server = leaderServer;
|
|
73
|
+
leaderServer = undefined;
|
|
74
|
+
if (server) {
|
|
75
|
+
try {
|
|
76
|
+
server.close();
|
|
77
|
+
}
|
|
78
|
+
catch { /* The failing server may already be closed. */ }
|
|
79
|
+
if (process.platform !== "win32") {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(coordinatorPath());
|
|
82
|
+
}
|
|
83
|
+
catch { /* Socket may already be gone. */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (token)
|
|
87
|
+
removeLeaderMetadata(token);
|
|
88
|
+
}
|
|
89
|
+
function becomeStandby(socket) {
|
|
90
|
+
standbySocket = socket;
|
|
91
|
+
socket.unref();
|
|
92
|
+
send({ type: "leadership", state: "standby" });
|
|
93
|
+
let buffer = "";
|
|
94
|
+
socket.on("data", (chunk) => {
|
|
95
|
+
buffer += chunk.toString("utf8");
|
|
96
|
+
for (;;) {
|
|
97
|
+
const newline = buffer.indexOf("\n");
|
|
98
|
+
if (newline < 0)
|
|
99
|
+
break;
|
|
100
|
+
const line = buffer.slice(0, newline);
|
|
101
|
+
buffer = buffer.slice(newline + 1);
|
|
102
|
+
try {
|
|
103
|
+
const message = JSON.parse(line);
|
|
104
|
+
if (message.type === "generation_changed")
|
|
105
|
+
send(message);
|
|
106
|
+
}
|
|
107
|
+
catch { /* Ignore malformed coordinator notifications. */ }
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
socket.once("close", () => {
|
|
111
|
+
if (standbySocket === socket)
|
|
112
|
+
standbySocket = undefined;
|
|
113
|
+
scheduleElection();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function cleanStaleSocketAndRetry() {
|
|
117
|
+
if (process.platform === "win32") {
|
|
118
|
+
scheduleElection(250);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(paths.knowledgeElectionLock, { mode: 0o700 });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
scheduleElection(250);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
if (existsSync(coordinatorPath()))
|
|
130
|
+
unlinkSync(coordinatorPath());
|
|
131
|
+
}
|
|
132
|
+
catch { /* Retrying listen will surface persistent failures. */ }
|
|
133
|
+
finally {
|
|
134
|
+
rmSync(paths.knowledgeElectionLock, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
scheduleElection();
|
|
137
|
+
}
|
|
138
|
+
function connectToLeader(probe = 0) {
|
|
139
|
+
if (!eligible || stopped || leaderServer || standbySocket)
|
|
140
|
+
return;
|
|
141
|
+
const socket = createConnection(coordinatorPath());
|
|
142
|
+
let connected = false;
|
|
143
|
+
socket.once("connect", () => { connected = true; becomeStandby(socket); });
|
|
144
|
+
socket.once("error", (error) => {
|
|
145
|
+
socket.destroy();
|
|
146
|
+
if (connected)
|
|
147
|
+
return;
|
|
148
|
+
if (error.code === "ECONNREFUSED" || error.code === "ENOENT") {
|
|
149
|
+
if (probe < 2) {
|
|
150
|
+
const timer = setTimeout(() => connectToLeader(probe + 1), 100);
|
|
151
|
+
timer.unref();
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
cleanStaleSocketAndRetry();
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
send({ type: "review_failed", error: `Coordinator connection failed: ${error.message}` });
|
|
158
|
+
scheduleElection(1_000);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function becomeLeader(server) {
|
|
163
|
+
leaderServer = server;
|
|
164
|
+
leaderToken = randomUUID();
|
|
165
|
+
server.unref();
|
|
166
|
+
server.on("connection", (socket) => {
|
|
167
|
+
standbySockets.add(socket);
|
|
168
|
+
socket.unref();
|
|
169
|
+
socket.once("close", () => standbySockets.delete(socket));
|
|
170
|
+
const generationId = loadCurrentManifest(home).generationId;
|
|
171
|
+
if (generationId)
|
|
172
|
+
socket.write(`${JSON.stringify({ type: "generation_changed", generationId })}\n`);
|
|
173
|
+
});
|
|
174
|
+
server.on("error", (error) => {
|
|
175
|
+
send({ type: "review_failed", error: `Coordinator server failed: ${error.message}` });
|
|
176
|
+
stepDown("coordinator-server-failed");
|
|
177
|
+
scheduleElection();
|
|
178
|
+
});
|
|
179
|
+
writeLeaderMetadata();
|
|
180
|
+
send({ type: "leadership", state: "leader", leaderToken });
|
|
181
|
+
void scanForReview();
|
|
182
|
+
}
|
|
183
|
+
function attemptElection() {
|
|
184
|
+
if (!eligible || stopped || leaderServer || standbySocket)
|
|
185
|
+
return;
|
|
186
|
+
ensureKnowledgeDirectories(home);
|
|
187
|
+
const server = createServer();
|
|
188
|
+
const electionError = (error) => {
|
|
189
|
+
if (error.code === "EADDRINUSE")
|
|
190
|
+
connectToLeader();
|
|
191
|
+
else {
|
|
192
|
+
send({ type: "review_failed", error: `Coordinator election failed: ${error.message}` });
|
|
193
|
+
scheduleElection(1_000);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
server.once("error", electionError);
|
|
197
|
+
server.listen(coordinatorPath(), () => {
|
|
198
|
+
server.off("error", electionError);
|
|
199
|
+
if (process.platform !== "win32")
|
|
200
|
+
chmodSync(coordinatorPath(), 0o600);
|
|
201
|
+
becomeLeader(server);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async function scanForReview() {
|
|
205
|
+
if (!leaderServer || !leaderToken || activeTask || !sessionsRoot || !eligible)
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
const manifest = loadCurrentManifest(home);
|
|
209
|
+
const task = findNextReviewTask(sessionsRoot, manifest.reviews);
|
|
210
|
+
if (!task)
|
|
211
|
+
return;
|
|
212
|
+
activeTask = task;
|
|
213
|
+
if (!task.delta) {
|
|
214
|
+
const result = commitKnowledgeReview([], task, leaderToken, home);
|
|
215
|
+
const generationId = loadCurrentManifest(home).generationId;
|
|
216
|
+
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
217
|
+
if (generationId) {
|
|
218
|
+
send({ type: "generation_changed", generationId });
|
|
219
|
+
broadcast({ type: "generation_changed", generationId });
|
|
220
|
+
}
|
|
221
|
+
activeTask = undefined;
|
|
222
|
+
queueMicrotask(() => void scanForReview());
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
send({ type: "review_request", leaderToken, requestId: task.requestId, task });
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
activeTask = undefined;
|
|
229
|
+
send({ type: "review_failed", error: error instanceof Error ? error.message : String(error) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function handleReviewResult(message) {
|
|
233
|
+
const task = activeTask;
|
|
234
|
+
if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer)
|
|
235
|
+
return;
|
|
236
|
+
let committed = false;
|
|
237
|
+
try {
|
|
238
|
+
if (message.error || message.raw === undefined)
|
|
239
|
+
throw new Error(message.error || "Knowledge review returned no content");
|
|
240
|
+
const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
|
|
241
|
+
const generationId = loadCurrentManifest(home).generationId;
|
|
242
|
+
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
243
|
+
if (generationId) {
|
|
244
|
+
send({ type: "generation_changed", generationId });
|
|
245
|
+
broadcast({ type: "generation_changed", generationId });
|
|
246
|
+
}
|
|
247
|
+
committed = true;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
send({ type: "review_failed", requestId: task.requestId, error: error instanceof Error ? error.message : String(error) });
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
activeTask = undefined;
|
|
254
|
+
if (committed)
|
|
255
|
+
queueMicrotask(() => void scanForReview());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
|
|
259
|
+
scanTimer.unref();
|
|
260
|
+
parentPort.on("message", (message) => {
|
|
261
|
+
if (message.type === "configure") {
|
|
262
|
+
sessionsRoot = message.sessionsRoot;
|
|
263
|
+
if (eligible === message.modelAvailable)
|
|
264
|
+
return;
|
|
265
|
+
eligible = message.modelAvailable;
|
|
266
|
+
if (!eligible) {
|
|
267
|
+
standbySocket?.destroy();
|
|
268
|
+
standbySocket = undefined;
|
|
269
|
+
stepDown("model-unavailable");
|
|
270
|
+
send({ type: "leadership", state: "ineligible" });
|
|
271
|
+
}
|
|
272
|
+
else
|
|
273
|
+
scheduleElection(0);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (message.type === "review_result") {
|
|
277
|
+
handleReviewResult(message);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (message.type === "scan_now") {
|
|
281
|
+
void scanForReview();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
stopped = true;
|
|
285
|
+
eligible = false;
|
|
286
|
+
clearInterval(scanTimer);
|
|
287
|
+
standbySocket?.destroy();
|
|
288
|
+
standbySocket = undefined;
|
|
289
|
+
stepDown("worker-stopped");
|
|
290
|
+
process.exit(0);
|
|
291
|
+
});
|
|
292
|
+
send({ type: "ready" });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const PRIVATE_KEY_RE = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/giu;
|
|
2
|
+
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/giu;
|
|
3
|
+
const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
|
|
4
|
+
const SECRET_ASSIGNMENT_RE = /\b(access[_-]?key|secret(?:[_-]?(?:access|key))?|password|passwd|token|credential|client[_-]?secret)\b(\s*[:=]\s*)[^\s,;]+/giu;
|
|
5
|
+
const URL_CREDENTIAL_RE = /(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu;
|
|
6
|
+
export function sanitizeKnowledgeText(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replace(PRIVATE_KEY_RE, "<redacted-private-key>")
|
|
9
|
+
.replace(BEARER_RE, "Bearer <redacted>")
|
|
10
|
+
.replace(JWT_RE, "<redacted-jwt>")
|
|
11
|
+
.replace(SECRET_ASSIGNMENT_RE, (_match, key, separator) => `${key}${separator}<redacted>`)
|
|
12
|
+
.replace(URL_CREDENTIAL_RE, "$1<redacted>@")
|
|
13
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/giu, "<runtime-uuid>")
|
|
14
|
+
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, "<runtime-ip>")
|
|
15
|
+
.replace(/\b(vpc|subnet|secgroup|sg|vol|snap|img|inst|eip)-[0-9a-zA-Z]+/gu, "<$1-runtime-id>")
|
|
16
|
+
.replace(/\/(?:Users|home)\/[a-zA-Z0-9_-]+/gu, "~");
|
|
17
|
+
}
|
|
18
|
+
export function compactKnowledgeText(value, maxChars) {
|
|
19
|
+
return sanitizeKnowledgeText(value)
|
|
20
|
+
.replace(/\r\n/gu, "\n")
|
|
21
|
+
.replace(/[ \t]+$/gmu, "")
|
|
22
|
+
.trim()
|
|
23
|
+
.slice(0, maxChars);
|
|
24
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { buildContextEntries, parseSessionEntries, } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
6
|
+
import { knowledgeDeltaDigest } from "./extractor.js";
|
|
7
|
+
import { sanitizeKnowledgeText } from "./sanitize.js";
|
|
8
|
+
import { projectKnowledgeKey } from "./store.js";
|
|
9
|
+
function hash(value, length = 64) {
|
|
10
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
11
|
+
}
|
|
12
|
+
function textContent(content) {
|
|
13
|
+
if (typeof content === "string")
|
|
14
|
+
return content;
|
|
15
|
+
if (!Array.isArray(content))
|
|
16
|
+
return "";
|
|
17
|
+
return content.map((item) => {
|
|
18
|
+
if (!item || typeof item !== "object")
|
|
19
|
+
return "";
|
|
20
|
+
const value = item;
|
|
21
|
+
return typeof value.text === "string" ? value.text : "";
|
|
22
|
+
}).filter(Boolean).join("\n");
|
|
23
|
+
}
|
|
24
|
+
function reviewPiece(entry, index) {
|
|
25
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
26
|
+
return { index, priority: 1, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
|
|
27
|
+
}
|
|
28
|
+
if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
|
|
29
|
+
return {
|
|
30
|
+
index, priority: 0, category: "workflow",
|
|
31
|
+
text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object")
|
|
35
|
+
return undefined;
|
|
36
|
+
const message = entry.message;
|
|
37
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
38
|
+
const content = sanitizeKnowledgeText(textContent(message.content));
|
|
39
|
+
if (!content)
|
|
40
|
+
return undefined;
|
|
41
|
+
const priority = role === "user" ? 0 : role === "toolResult" ? 3 : 2;
|
|
42
|
+
const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
|
|
43
|
+
const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
|
|
44
|
+
return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
|
|
45
|
+
}
|
|
46
|
+
function buildDelta(entries) {
|
|
47
|
+
const pieces = entries.map(reviewPiece).filter((piece) => Boolean(piece));
|
|
48
|
+
const selected = [];
|
|
49
|
+
const categoryRemaining = {
|
|
50
|
+
user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
|
|
51
|
+
};
|
|
52
|
+
let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
|
|
53
|
+
for (const piece of pieces.slice().sort((left, right) => left.priority - right.priority || left.index - right.index)) {
|
|
54
|
+
if (remaining <= 0)
|
|
55
|
+
break;
|
|
56
|
+
const text = piece.text.slice(0, Math.min(remaining, categoryRemaining[piece.category]));
|
|
57
|
+
if (text)
|
|
58
|
+
selected.push({ ...piece, text });
|
|
59
|
+
remaining -= text.length;
|
|
60
|
+
categoryRemaining[piece.category] -= text.length;
|
|
61
|
+
}
|
|
62
|
+
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n");
|
|
63
|
+
}
|
|
64
|
+
function projectRootForSession(header, branch) {
|
|
65
|
+
for (const entry of branch.slice().reverse()) {
|
|
66
|
+
if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
|
|
67
|
+
|| !entry.data || typeof entry.data !== "object")
|
|
68
|
+
continue;
|
|
69
|
+
const cwd = entry.data.cwd;
|
|
70
|
+
if (typeof cwd === "string" && cwd)
|
|
71
|
+
return resolve(cwd);
|
|
72
|
+
}
|
|
73
|
+
return resolve(header.cwd);
|
|
74
|
+
}
|
|
75
|
+
export function discoverSessionFiles(root) {
|
|
76
|
+
const files = [];
|
|
77
|
+
const visit = (directory) => {
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const path = resolve(directory, entry.name);
|
|
87
|
+
if (entry.isDirectory())
|
|
88
|
+
visit(path);
|
|
89
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
90
|
+
files.push(path);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
visit(resolve(root));
|
|
94
|
+
return files.sort();
|
|
95
|
+
}
|
|
96
|
+
export function readReviewTask(sessionFile, cursor, now = Date.now()) {
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = statSync(sessionFile);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!stat.isFile() || now - stat.mtimeMs < KNOWLEDGE_RUNTIME_DEFAULTS.review.idleMs)
|
|
105
|
+
return undefined;
|
|
106
|
+
let content;
|
|
107
|
+
try {
|
|
108
|
+
content = readFileSync(sessionFile, "utf8");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const lastNewline = content.lastIndexOf("\n");
|
|
114
|
+
if (lastNewline < 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const parsed = parseSessionEntries(content.slice(0, lastNewline + 1));
|
|
117
|
+
const header = parsed.find((entry) => entry.type === "session");
|
|
118
|
+
if (!header?.id || !header.cwd)
|
|
119
|
+
return undefined;
|
|
120
|
+
const allEntries = parsed.filter((entry) => entry.type !== "session");
|
|
121
|
+
const branch = buildContextEntries(allEntries);
|
|
122
|
+
if (branch.length === 0)
|
|
123
|
+
return undefined;
|
|
124
|
+
const cursorIndex = cursor?.lastReviewedEntryId
|
|
125
|
+
? branch.findIndex((entry) => entry.id === cursor.lastReviewedEntryId)
|
|
126
|
+
: -1;
|
|
127
|
+
const pending = branch.slice(cursorIndex + 1);
|
|
128
|
+
if (pending.length === 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
const delta = buildDelta(pending);
|
|
131
|
+
const firstEntryId = pending[0].id;
|
|
132
|
+
const lastEntryId = pending[pending.length - 1].id;
|
|
133
|
+
const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
|
|
134
|
+
const sessionFileHash = hash(resolve(sessionFile), 16);
|
|
135
|
+
const sessionKey = hash(header.id, 24);
|
|
136
|
+
const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
|
|
137
|
+
const projectRoot = projectRootForSession(header, branch);
|
|
138
|
+
return {
|
|
139
|
+
requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
|
|
140
|
+
projectRoot, projectKey: projectKnowledgeKey(projectRoot),
|
|
141
|
+
firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
|
|
145
|
+
const files = discoverSessionFiles(sessionsRoot).map((path) => {
|
|
146
|
+
try {
|
|
147
|
+
return { path, mtimeMs: statSync(path).mtimeMs };
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}).filter((item) => Boolean(item))
|
|
153
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
const fileHash = hash(resolve(file.path), 16);
|
|
156
|
+
const cursor = Object.values(reviews).find((item) => item.sessionFileHash === fileHash);
|
|
157
|
+
let task = readReviewTask(file.path, cursor, now);
|
|
158
|
+
const stableCursor = task ? reviews[task.sessionKey] : undefined;
|
|
159
|
+
if (task && stableCursor && stableCursor !== cursor)
|
|
160
|
+
task = readReviewTask(file.path, stableCursor, now);
|
|
161
|
+
if (task)
|
|
162
|
+
return task;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|