@hadooppei/hwcode 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/knowledge.ts +137 -192
- package/.pi/lib/knowledge/review-worker.ts +238 -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 +15 -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 +1 -1
|
@@ -1,64 +1,260 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
6
|
|
|
3
7
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
8
|
+
import { userRuntimePaths } from "../runtime/paths.ts";
|
|
4
9
|
import { parseCandidateEnvelope } from "./extractor.ts";
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
10
|
+
import { findNextReviewTask } from "./session-scanner.ts";
|
|
11
|
+
import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.ts";
|
|
12
|
+
import type { KnowledgeReviewTask } from "./types.ts";
|
|
13
|
+
import type { CoordinatorBroadcast, KnowledgeWorkerInput, KnowledgeWorkerOutput } from "./worker-protocol.ts";
|
|
7
14
|
|
|
8
15
|
if (!parentPort) throw new Error("Knowledge review worker requires a parent port");
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
let
|
|
14
|
-
let
|
|
15
|
-
let
|
|
17
|
+
const home = (workerData as { home?: string } | null)?.home ?? homedir();
|
|
18
|
+
const paths = userRuntimePaths(home);
|
|
19
|
+
const standbySockets = new Set<Socket>();
|
|
20
|
+
let eligible = false;
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let sessionsRoot = "";
|
|
23
|
+
let leaderServer: Server | undefined;
|
|
24
|
+
let leaderToken = "";
|
|
25
|
+
let standbySocket: Socket | undefined;
|
|
26
|
+
let electionPending = false;
|
|
27
|
+
let activeTask: KnowledgeReviewTask | undefined;
|
|
16
28
|
|
|
17
29
|
function send(message: KnowledgeWorkerOutput): void {
|
|
18
30
|
parentPort!.postMessage(message);
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
send({ type: "review_due", requestId: activeRequestId });
|
|
33
|
+
function coordinatorPath(): string {
|
|
34
|
+
return process.platform === "win32"
|
|
35
|
+
? `\\\\.\\pipe\\hwcode-knowledge-v3-${Buffer.from(home).toString("hex").slice(0, 16)}`
|
|
36
|
+
: paths.knowledgeCoordinatorSocket;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
function writeLeaderMetadata(): void {
|
|
40
|
+
writeFileSync(paths.knowledgeLeader, `${JSON.stringify({
|
|
41
|
+
protocolVersion: 3, leaderToken, pid: process.pid, electedAt: new Date().toISOString(),
|
|
42
|
+
}, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
43
|
+
chmodSync(paths.knowledgeLeader, 0o600);
|
|
44
|
+
}
|
|
30
45
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
function removeLeaderMetadata(token: string): void {
|
|
47
|
+
try {
|
|
48
|
+
const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8")) as { leaderToken?: string };
|
|
49
|
+
if (current.leaderToken === token) rmSync(paths.knowledgeLeader, { force: true });
|
|
50
|
+
} catch { /* Another contender may already have replaced it. */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function broadcast(message: CoordinatorBroadcast): void {
|
|
54
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
55
|
+
for (const socket of standbySockets) {
|
|
56
|
+
if (socket.writable) socket.write(line);
|
|
40
57
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scheduleElection(delay = Math.floor(Math.random() * 500)): void {
|
|
61
|
+
if (!eligible || stopped || electionPending || leaderServer || standbySocket) return;
|
|
62
|
+
electionPending = true;
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
electionPending = false;
|
|
65
|
+
attemptElection();
|
|
66
|
+
}, delay);
|
|
67
|
+
timer.unref();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stepDown(reason: string): void {
|
|
71
|
+
if (activeTask) send({ type: "review_cancel", requestId: activeTask.requestId, reason });
|
|
72
|
+
activeTask = undefined;
|
|
73
|
+
const token = leaderToken;
|
|
74
|
+
leaderToken = "";
|
|
75
|
+
for (const socket of standbySockets) socket.destroy();
|
|
76
|
+
standbySockets.clear();
|
|
77
|
+
const server = leaderServer;
|
|
78
|
+
leaderServer = undefined;
|
|
79
|
+
if (server) {
|
|
80
|
+
try { server.close(); } catch { /* The failing server may already be closed. */ }
|
|
81
|
+
if (process.platform !== "win32") {
|
|
82
|
+
try { unlinkSync(coordinatorPath()); } catch { /* Socket may already be gone. */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (token) removeLeaderMetadata(token);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function becomeStandby(socket: Socket): void {
|
|
89
|
+
standbySocket = socket;
|
|
90
|
+
socket.unref();
|
|
91
|
+
send({ type: "leadership", state: "standby" });
|
|
92
|
+
let buffer = "";
|
|
93
|
+
socket.on("data", (chunk) => {
|
|
94
|
+
buffer += chunk.toString("utf8");
|
|
95
|
+
for (;;) {
|
|
96
|
+
const newline = buffer.indexOf("\n");
|
|
97
|
+
if (newline < 0) break;
|
|
98
|
+
const line = buffer.slice(0, newline);
|
|
99
|
+
buffer = buffer.slice(newline + 1);
|
|
100
|
+
try {
|
|
101
|
+
const message = JSON.parse(line) as CoordinatorBroadcast;
|
|
102
|
+
if (message.type === "generation_changed") send(message);
|
|
103
|
+
} catch { /* Ignore malformed coordinator notifications. */ }
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
socket.once("close", () => {
|
|
107
|
+
if (standbySocket === socket) standbySocket = undefined;
|
|
108
|
+
scheduleElection();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cleanStaleSocketAndRetry(): void {
|
|
113
|
+
if (process.platform === "win32") { scheduleElection(250); return; }
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(paths.knowledgeElectionLock, { mode: 0o700 });
|
|
116
|
+
} catch {
|
|
117
|
+
scheduleElection(250);
|
|
44
118
|
return;
|
|
45
119
|
}
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
120
|
+
try {
|
|
121
|
+
if (existsSync(coordinatorPath())) unlinkSync(coordinatorPath());
|
|
122
|
+
} catch { /* Retrying listen will surface persistent failures. */ }
|
|
123
|
+
finally { rmSync(paths.knowledgeElectionLock, { recursive: true, force: true }); }
|
|
124
|
+
scheduleElection();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function connectToLeader(probe = 0): void {
|
|
128
|
+
if (!eligible || stopped || leaderServer || standbySocket) return;
|
|
129
|
+
const socket = createConnection(coordinatorPath());
|
|
130
|
+
let connected = false;
|
|
131
|
+
socket.once("connect", () => { connected = true; becomeStandby(socket); });
|
|
132
|
+
socket.once("error", (error: NodeJS.ErrnoException) => {
|
|
133
|
+
socket.destroy();
|
|
134
|
+
if (connected) return;
|
|
135
|
+
if (error.code === "ECONNREFUSED" || error.code === "ENOENT") {
|
|
136
|
+
if (probe < 2) {
|
|
137
|
+
const timer = setTimeout(() => connectToLeader(probe + 1), 100);
|
|
138
|
+
timer.unref();
|
|
139
|
+
} else cleanStaleSocketAndRetry();
|
|
140
|
+
} else {
|
|
141
|
+
send({ type: "review_failed", error: `Coordinator connection failed: ${error.message}` });
|
|
142
|
+
scheduleElection(1_000);
|
|
59
143
|
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function becomeLeader(server: Server): void {
|
|
148
|
+
leaderServer = server;
|
|
149
|
+
leaderToken = randomUUID();
|
|
150
|
+
server.unref();
|
|
151
|
+
server.on("connection", (socket) => {
|
|
152
|
+
standbySockets.add(socket);
|
|
153
|
+
socket.unref();
|
|
154
|
+
socket.once("close", () => standbySockets.delete(socket));
|
|
155
|
+
const generationId = loadCurrentManifest(home).generationId;
|
|
156
|
+
if (generationId) socket.write(`${JSON.stringify({ type: "generation_changed", generationId })}\n`);
|
|
157
|
+
});
|
|
158
|
+
server.on("error", (error) => {
|
|
159
|
+
send({ type: "review_failed", error: `Coordinator server failed: ${error.message}` });
|
|
160
|
+
stepDown("coordinator-server-failed");
|
|
161
|
+
scheduleElection();
|
|
162
|
+
});
|
|
163
|
+
writeLeaderMetadata();
|
|
164
|
+
send({ type: "leadership", state: "leader", leaderToken });
|
|
165
|
+
void scanForReview();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function attemptElection(): void {
|
|
169
|
+
if (!eligible || stopped || leaderServer || standbySocket) return;
|
|
170
|
+
ensureKnowledgeDirectories(home);
|
|
171
|
+
const server = createServer();
|
|
172
|
+
const electionError = (error: NodeJS.ErrnoException): void => {
|
|
173
|
+
if (error.code === "EADDRINUSE") connectToLeader();
|
|
174
|
+
else {
|
|
175
|
+
send({ type: "review_failed", error: `Coordinator election failed: ${error.message}` });
|
|
176
|
+
scheduleElection(1_000);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
server.once("error", electionError);
|
|
180
|
+
server.listen(coordinatorPath(), () => {
|
|
181
|
+
server.off("error", electionError);
|
|
182
|
+
if (process.platform !== "win32") chmodSync(coordinatorPath(), 0o600);
|
|
183
|
+
becomeLeader(server);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function scanForReview(): Promise<void> {
|
|
188
|
+
if (!leaderServer || !leaderToken || activeTask || !sessionsRoot || !eligible) return;
|
|
189
|
+
try {
|
|
190
|
+
const manifest = loadCurrentManifest(home);
|
|
191
|
+
const task = findNextReviewTask(sessionsRoot, manifest.reviews);
|
|
192
|
+
if (!task) return;
|
|
193
|
+
activeTask = task;
|
|
194
|
+
if (!task.delta) {
|
|
195
|
+
const result = commitKnowledgeReview([], task, leaderToken, home);
|
|
196
|
+
const generationId = loadCurrentManifest(home).generationId;
|
|
197
|
+
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
198
|
+
if (generationId) {
|
|
199
|
+
send({ type: "generation_changed", generationId });
|
|
200
|
+
broadcast({ type: "generation_changed", generationId });
|
|
201
|
+
}
|
|
202
|
+
activeTask = undefined;
|
|
203
|
+
queueMicrotask(() => void scanForReview());
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
send({ type: "review_request", leaderToken, requestId: task.requestId, task });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
activeTask = undefined;
|
|
209
|
+
send({ type: "review_failed", error: error instanceof Error ? error.message : String(error) });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "review_result" }>): void {
|
|
214
|
+
const task = activeTask;
|
|
215
|
+
if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer) return;
|
|
216
|
+
let committed = false;
|
|
217
|
+
try {
|
|
218
|
+
if (message.error || message.raw === undefined) throw new Error(message.error || "Knowledge review returned no content");
|
|
219
|
+
const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
|
|
220
|
+
const generationId = loadCurrentManifest(home).generationId;
|
|
221
|
+
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
222
|
+
if (generationId) {
|
|
223
|
+
send({ type: "generation_changed", generationId });
|
|
224
|
+
broadcast({ type: "generation_changed", generationId });
|
|
225
|
+
}
|
|
226
|
+
committed = true;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
send({ type: "review_failed", requestId: task.requestId, error: error instanceof Error ? error.message : String(error) });
|
|
229
|
+
} finally {
|
|
230
|
+
activeTask = undefined;
|
|
231
|
+
if (committed) queueMicrotask(() => void scanForReview());
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
|
|
236
|
+
scanTimer.unref();
|
|
237
|
+
|
|
238
|
+
parentPort.on("message", (message: KnowledgeWorkerInput) => {
|
|
239
|
+
if (message.type === "configure") {
|
|
240
|
+
sessionsRoot = message.sessionsRoot;
|
|
241
|
+
if (eligible === message.modelAvailable) return;
|
|
242
|
+
eligible = message.modelAvailable;
|
|
243
|
+
if (!eligible) {
|
|
244
|
+
standbySocket?.destroy();
|
|
245
|
+
standbySocket = undefined;
|
|
246
|
+
stepDown("model-unavailable");
|
|
247
|
+
send({ type: "leadership", state: "ineligible" });
|
|
248
|
+
} else scheduleElection(0);
|
|
60
249
|
return;
|
|
61
250
|
}
|
|
62
|
-
|
|
251
|
+
if (message.type === "review_result") { handleReviewResult(message); return; }
|
|
252
|
+
if (message.type === "scan_now") { void scanForReview(); return; }
|
|
253
|
+
stopped = true;
|
|
254
|
+
eligible = false;
|
|
255
|
+
clearInterval(scanTimer);
|
|
256
|
+
standbySocket?.destroy();
|
|
257
|
+
standbySocket = undefined;
|
|
258
|
+
stepDown("worker-stopped");
|
|
63
259
|
process.exit(0);
|
|
64
260
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildContextEntries, parseSessionEntries, type SessionEntry, type SessionHeader,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
10
|
+
import { knowledgeDeltaDigest } from "./extractor.ts";
|
|
11
|
+
import { sanitizeKnowledgeText } from "./sanitize.ts";
|
|
12
|
+
import { projectKnowledgeKey } from "./store.ts";
|
|
13
|
+
import type { KnowledgeReviewCursor, KnowledgeReviewTask } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
interface ReviewPiece {
|
|
16
|
+
index: number;
|
|
17
|
+
priority: number;
|
|
18
|
+
category: "user" | "workflow" | "summary" | "assistant" | "tool";
|
|
19
|
+
text: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hash(value: string, length = 64): string {
|
|
23
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function textContent(content: unknown): string {
|
|
27
|
+
if (typeof content === "string") return content;
|
|
28
|
+
if (!Array.isArray(content)) return "";
|
|
29
|
+
return content.map((item) => {
|
|
30
|
+
if (!item || typeof item !== "object") return "";
|
|
31
|
+
const value = item as Record<string, unknown>;
|
|
32
|
+
return typeof value.text === "string" ? value.text : "";
|
|
33
|
+
}).filter(Boolean).join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefined {
|
|
37
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
38
|
+
return { index, priority: 1, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
|
|
39
|
+
}
|
|
40
|
+
if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
|
|
41
|
+
return {
|
|
42
|
+
index, priority: 0, category: "workflow",
|
|
43
|
+
text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
|
|
47
|
+
const message = entry.message as unknown as Record<string, unknown>;
|
|
48
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
49
|
+
const content = sanitizeKnowledgeText(textContent(message.content));
|
|
50
|
+
if (!content) return undefined;
|
|
51
|
+
const priority = role === "user" ? 0 : role === "toolResult" ? 3 : 2;
|
|
52
|
+
const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
|
|
53
|
+
const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
|
|
54
|
+
return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildDelta(entries: SessionEntry[]): string {
|
|
58
|
+
const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
|
|
59
|
+
const selected: ReviewPiece[] = [];
|
|
60
|
+
const categoryRemaining: Record<ReviewPiece["category"], number> = {
|
|
61
|
+
user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
|
|
62
|
+
};
|
|
63
|
+
let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
|
|
64
|
+
for (const piece of pieces.slice().sort((left, right) => left.priority - right.priority || left.index - right.index)) {
|
|
65
|
+
if (remaining <= 0) break;
|
|
66
|
+
const text = piece.text.slice(0, Math.min(remaining, categoryRemaining[piece.category]));
|
|
67
|
+
if (text) selected.push({ ...piece, text });
|
|
68
|
+
remaining -= text.length;
|
|
69
|
+
categoryRemaining[piece.category] -= text.length;
|
|
70
|
+
}
|
|
71
|
+
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
|
|
75
|
+
for (const entry of branch.slice().reverse()) {
|
|
76
|
+
if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
|
|
77
|
+
|| !entry.data || typeof entry.data !== "object") continue;
|
|
78
|
+
const cwd = (entry.data as Record<string, unknown>).cwd;
|
|
79
|
+
if (typeof cwd === "string" && cwd) return resolve(cwd);
|
|
80
|
+
}
|
|
81
|
+
return resolve(header.cwd);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function discoverSessionFiles(root: string): string[] {
|
|
85
|
+
const files: string[] = [];
|
|
86
|
+
const visit = (directory: string): void => {
|
|
87
|
+
let entries;
|
|
88
|
+
try { entries = readdirSync(directory, { withFileTypes: true }); } catch { return; }
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const path = resolve(directory, entry.name);
|
|
91
|
+
if (entry.isDirectory()) visit(path);
|
|
92
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
visit(resolve(root));
|
|
96
|
+
return files.sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function readReviewTask(
|
|
100
|
+
sessionFile: string,
|
|
101
|
+
cursor: KnowledgeReviewCursor | undefined,
|
|
102
|
+
now = Date.now(),
|
|
103
|
+
): KnowledgeReviewTask | undefined {
|
|
104
|
+
let stat;
|
|
105
|
+
try { stat = statSync(sessionFile); } catch { return undefined; }
|
|
106
|
+
if (!stat.isFile() || now - stat.mtimeMs < KNOWLEDGE_RUNTIME_DEFAULTS.review.idleMs) return undefined;
|
|
107
|
+
let content: string;
|
|
108
|
+
try { content = readFileSync(sessionFile, "utf8"); } catch { return undefined; }
|
|
109
|
+
const lastNewline = content.lastIndexOf("\n");
|
|
110
|
+
if (lastNewline < 0) return undefined;
|
|
111
|
+
const parsed = parseSessionEntries(content.slice(0, lastNewline + 1));
|
|
112
|
+
const header = parsed.find((entry): entry is SessionHeader => entry.type === "session");
|
|
113
|
+
if (!header?.id || !header.cwd) return undefined;
|
|
114
|
+
const allEntries = parsed.filter((entry): entry is SessionEntry => entry.type !== "session");
|
|
115
|
+
const branch = buildContextEntries(allEntries);
|
|
116
|
+
if (branch.length === 0) return undefined;
|
|
117
|
+
const cursorIndex = cursor?.lastReviewedEntryId
|
|
118
|
+
? branch.findIndex((entry) => entry.id === cursor.lastReviewedEntryId)
|
|
119
|
+
: -1;
|
|
120
|
+
const pending = branch.slice(cursorIndex + 1);
|
|
121
|
+
if (pending.length === 0) return undefined;
|
|
122
|
+
const delta = buildDelta(pending);
|
|
123
|
+
const firstEntryId = pending[0].id;
|
|
124
|
+
const lastEntryId = pending[pending.length - 1].id;
|
|
125
|
+
const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
|
|
126
|
+
const sessionFileHash = hash(resolve(sessionFile), 16);
|
|
127
|
+
const sessionKey = hash(header.id, 24);
|
|
128
|
+
const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
|
|
129
|
+
const projectRoot = projectRootForSession(header, branch);
|
|
130
|
+
return {
|
|
131
|
+
requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
|
|
132
|
+
projectRoot, projectKey: projectKnowledgeKey(projectRoot),
|
|
133
|
+
firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function findNextReviewTask(
|
|
138
|
+
sessionsRoot: string,
|
|
139
|
+
reviews: Record<string, KnowledgeReviewCursor>,
|
|
140
|
+
now = Date.now(),
|
|
141
|
+
): KnowledgeReviewTask | undefined {
|
|
142
|
+
const files = discoverSessionFiles(sessionsRoot).map((path) => {
|
|
143
|
+
try { return { path, mtimeMs: statSync(path).mtimeMs }; } catch { return undefined; }
|
|
144
|
+
}).filter((item): item is { path: string; mtimeMs: number } => Boolean(item))
|
|
145
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const fileHash = hash(resolve(file.path), 16);
|
|
148
|
+
const cursor = Object.values(reviews).find((item) => item.sessionFileHash === fileHash);
|
|
149
|
+
let task = readReviewTask(file.path, cursor, now);
|
|
150
|
+
const stableCursor = task ? reviews[task.sessionKey] : undefined;
|
|
151
|
+
if (task && stableCursor && stableCursor !== cursor) task = readReviewTask(file.path, stableCursor, now);
|
|
152
|
+
if (task) return task;
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|