@hadooppei/hwcode 1.0.7 → 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.
Files changed (36) hide show
  1. package/.pi/extensions/command-filter.ts +2 -3
  2. package/.pi/extensions/cwd.ts +1 -4
  3. package/.pi/extensions/knowledge.ts +224 -0
  4. package/.pi/extensions/workflows/cloud/activation.ts +57 -43
  5. package/.pi/extensions/workflows/cloud/commands.ts +1 -86
  6. package/.pi/extensions/workflows/cloud/events.ts +16 -19
  7. package/.pi/extensions/workflows/cloud/interactions.ts +102 -0
  8. package/.pi/extensions/workflows/cloud/provider-tools.ts +15 -9
  9. package/.pi/extensions/workflows/cloud/runner-tools.ts +21 -20
  10. package/.pi/extensions/workflows/cloud/runtime.ts +41 -4
  11. package/.pi/extensions/workflows/cloud/terraform-tools.ts +43 -30
  12. package/.pi/extensions/workflows/sdd.ts +2 -1
  13. package/.pi/extensions/workflows/vibe.ts +2 -1
  14. package/.pi/extensions/workflows/workspace-guard.ts +1 -5
  15. package/.pi/lib/extension-ui.ts +52 -0
  16. package/.pi/lib/knowledge/extractor.ts +35 -0
  17. package/.pi/lib/knowledge/matcher.ts +122 -0
  18. package/.pi/lib/knowledge/review-worker.ts +260 -0
  19. package/.pi/lib/knowledge/sanitize.ts +26 -0
  20. package/.pi/lib/knowledge/session-scanner.ts +155 -0
  21. package/.pi/lib/knowledge/store.ts +365 -0
  22. package/.pi/lib/knowledge/types.ts +91 -0
  23. package/.pi/lib/knowledge/worker-protocol.ts +20 -0
  24. package/.pi/lib/runtime/defaults.ts +31 -0
  25. package/.pi/lib/runtime/paths.ts +33 -16
  26. package/.pi/lib/tool-result.ts +7 -0
  27. package/.pi/lib/workflows/cloud/bundles.ts +73 -40
  28. package/.pi/lib/workflows/cloud/workspace.ts +6 -0
  29. package/.pi/lib/workflows/state.ts +6 -26
  30. package/.pi/skills/hwcode-cloud/SKILL.md +2 -2
  31. package/README.md +36 -31
  32. package/bin/hwcode.js +2 -3
  33. package/package.json +1 -1
  34. package/.pi/extensions/workflows/cloud/shared.ts +0 -230
  35. package/.pi/lib/workflows/cloud/template-save.ts +0 -108
  36. package/.pi/lib/workflows/cloud/templates.ts +0 -314
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
+
5
+ export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
6
+ Review only the supplied conversation delta. Return strict JSON and no markdown.
7
+ Persist only knowledge that is likely to be useful in future sessions:
8
+ - explicit user corrections or stable preferences;
9
+ - verified engineering rules, successful procedures, or expensive failed approaches;
10
+ - reusable architecture, testing, debugging, deployment, or operational knowledge.
11
+ Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
12
+ Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
13
+ Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
14
+ Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
15
+ 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}]}
16
+ Return {"candidates":[]} when nothing meets the threshold.`;
17
+
18
+ export function buildKnowledgeExtractionPrompt(projectRoot: string, delta: string): string {
19
+ return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
20
+ }
21
+
22
+ export function parseCandidateEnvelope(text: string): unknown[] {
23
+ const trimmed = text.trim();
24
+ const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
25
+ const start = unfenced.indexOf("{");
26
+ const end = unfenced.lastIndexOf("}");
27
+ if (start < 0 || end <= start) throw new Error("Knowledge reviewer did not return a JSON object");
28
+ const parsed = JSON.parse(unfenced.slice(start, end + 1)) as { candidates?: unknown };
29
+ if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
30
+ return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
31
+ }
32
+
33
+ export function knowledgeDeltaDigest(delta: string): string {
34
+ return createHash("sha256").update(delta).digest("hex");
35
+ }
@@ -0,0 +1,122 @@
1
+ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
2
+ import type { KnowledgeCatalog, KnowledgeCatalogEntry } from "./types.ts";
3
+
4
+ const MAX_QUERY_TERMS = 64;
5
+ const HAN_RUN = /^\p{Script=Han}+$/u;
6
+
7
+ interface SearchField {
8
+ compact: string;
9
+ terms: Set<string>;
10
+ }
11
+
12
+ function normalizeSearchText(value: string): string {
13
+ return value
14
+ .normalize("NFKC")
15
+ .toLowerCase()
16
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
17
+ .trim()
18
+ .replace(/\s+/gu, " ");
19
+ }
20
+
21
+ function searchTerms(value: string): string[] {
22
+ const normalized = normalizeSearchText(value);
23
+ const terms = new Set<string>();
24
+ for (const segment of normalized.match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? []) {
25
+ if (segment.length < 2) continue;
26
+ terms.add(segment);
27
+ if (HAN_RUN.test(segment) && segment.length > 2) {
28
+ for (let index = 0; index < segment.length - 1; index += 1) {
29
+ terms.add(segment.slice(index, index + 2));
30
+ }
31
+ }
32
+ if (terms.size >= MAX_QUERY_TERMS) break;
33
+ }
34
+ return [...terms].slice(0, MAX_QUERY_TERMS);
35
+ }
36
+
37
+ function searchField(value: string): SearchField {
38
+ const normalized = normalizeSearchText(value);
39
+ return {
40
+ compact: normalized.replace(/\s+/gu, ""),
41
+ terms: new Set(searchTerms(normalized)),
42
+ };
43
+ }
44
+
45
+ function supportsPartialMatch(term: string): boolean {
46
+ return HAN_RUN.test(term) ? term.length >= 2 : term.length >= 3;
47
+ }
48
+
49
+ function fieldMatch(term: string, field: SearchField): "exact" | "partial" | undefined {
50
+ if (field.terms.has(term)) return "exact";
51
+ if (!supportsPartialMatch(term)) return undefined;
52
+ if (field.compact.includes(term)) return "partial";
53
+ for (const candidate of field.terms) {
54
+ if (!supportsPartialMatch(candidate)) continue;
55
+ if (term.includes(candidate) || candidate.includes(term)) return "partial";
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function containsSearchPhrase(container: string, phrase: string): boolean {
61
+ return supportsPartialMatch(phrase) && container.includes(phrase);
62
+ }
63
+
64
+ function phraseScore(query: string, title: SearchField, summary: SearchField, keywords: SearchField[]): number {
65
+ if (query.length < 2) return 0;
66
+ if (keywords.some((keyword) => keyword.compact === query)) return 18;
67
+ if (title.compact === query) return 14;
68
+ if (keywords.some((keyword) => (
69
+ containsSearchPhrase(keyword.compact, query) || containsSearchPhrase(query, keyword.compact)
70
+ ))) return 10;
71
+ if (containsSearchPhrase(title.compact, query) || containsSearchPhrase(query, title.compact)) return 8;
72
+ if (containsSearchPhrase(summary.compact, query)) return 4;
73
+ return 0;
74
+ }
75
+
76
+ /**
77
+ * Cheaply recalls plausible catalog entries. The agent sees these entries and
78
+ * remains responsible for deciding whether a detailed topic is actually relevant.
79
+ */
80
+ export function matchKnowledge(
81
+ query: string,
82
+ catalog: KnowledgeCatalog,
83
+ limit = KNOWLEDGE_RUNTIME_DEFAULTS.storage.maxLookupResults,
84
+ ): KnowledgeCatalogEntry[] {
85
+ const normalizedQuery = normalizeSearchText(query);
86
+ const compactQuery = normalizedQuery.replace(/\s+/gu, "");
87
+ const terms = searchTerms(normalizedQuery);
88
+ if (terms.length === 0 || limit <= 0) return [];
89
+
90
+ return catalog.items
91
+ .map((item) => {
92
+ const title = searchField(item.title);
93
+ const summary = searchField(item.summary);
94
+ const keywords = item.keywords.map(searchField);
95
+ let score = phraseScore(compactQuery, title, summary, keywords);
96
+ let matchedTerms = 0;
97
+
98
+ for (const term of terms) {
99
+ const keywordMatch = keywords.some((keyword) => fieldMatch(term, keyword) === "exact")
100
+ ? "exact"
101
+ : keywords.some((keyword) => fieldMatch(term, keyword) === "partial") ? "partial" : undefined;
102
+ const titleMatch = fieldMatch(term, title);
103
+ const summaryMatch = fieldMatch(term, summary);
104
+ const termScore = Math.max(
105
+ keywordMatch === "exact" ? 10 : keywordMatch === "partial" ? 7 : 0,
106
+ titleMatch === "exact" ? 7 : titleMatch === "partial" ? 5 : 0,
107
+ summaryMatch === "exact" ? 3 : summaryMatch === "partial" ? 1 : 0,
108
+ );
109
+ if (termScore > 0) {
110
+ score += termScore;
111
+ matchedTerms += 1;
112
+ }
113
+ }
114
+
115
+ score += (matchedTerms / terms.length) * 6;
116
+ return { item, score };
117
+ })
118
+ .filter(({ score }) => score > 0)
119
+ .sort((left, right) => right.score - left.score || right.item.updatedAt.localeCompare(left.item.updatedAt))
120
+ .slice(0, limit)
121
+ .map(({ item }) => item);
122
+ }
@@ -0,0 +1,260 @@
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";
6
+
7
+ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
8
+ import { userRuntimePaths } from "../runtime/paths.ts";
9
+ import { parseCandidateEnvelope } from "./extractor.ts";
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";
14
+
15
+ if (!parentPort) throw new Error("Knowledge review worker requires a parent port");
16
+
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;
28
+
29
+ function send(message: KnowledgeWorkerOutput): void {
30
+ parentPort!.postMessage(message);
31
+ }
32
+
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;
37
+ }
38
+
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
+ }
45
+
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);
57
+ }
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);
118
+ return;
119
+ }
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);
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);
249
+ return;
250
+ }
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");
259
+ process.exit(0);
260
+ });
@@ -0,0 +1,26 @@
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
+
7
+ export function sanitizeKnowledgeText(value: string): string {
8
+ return value
9
+ .replace(PRIVATE_KEY_RE, "<redacted-private-key>")
10
+ .replace(BEARER_RE, "Bearer <redacted>")
11
+ .replace(JWT_RE, "<redacted-jwt>")
12
+ .replace(SECRET_ASSIGNMENT_RE, (_match, key: string, separator: string) => `${key}${separator}<redacted>`)
13
+ .replace(URL_CREDENTIAL_RE, "$1<redacted>@")
14
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/giu, "<runtime-uuid>")
15
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, "<runtime-ip>")
16
+ .replace(/\b(vpc|subnet|secgroup|sg|vol|snap|img|inst|eip)-[0-9a-zA-Z]+/gu, "<$1-runtime-id>")
17
+ .replace(/\/(?:Users|home)\/[a-zA-Z0-9_-]+/gu, "~");
18
+ }
19
+
20
+ export function compactKnowledgeText(value: string, maxChars: number): string {
21
+ return sanitizeKnowledgeText(value)
22
+ .replace(/\r\n/gu, "\n")
23
+ .replace(/[ \t]+$/gmu, "")
24
+ .trim()
25
+ .slice(0, maxChars);
26
+ }
@@ -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
+ }