@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,5 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync,
|
|
4
|
+
statSync, writeFileSync,
|
|
5
|
+
} from "node:fs";
|
|
3
6
|
import { homedir } from "node:os";
|
|
4
7
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
8
|
|
|
@@ -7,13 +10,27 @@ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
|
7
10
|
import { userRuntimePaths } from "../runtime/paths.ts";
|
|
8
11
|
import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
|
|
9
12
|
import type {
|
|
10
|
-
KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry,
|
|
11
|
-
KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
|
|
13
|
+
KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
|
|
14
|
+
KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
|
|
12
15
|
} from "./types.ts";
|
|
13
16
|
|
|
14
17
|
const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
|
|
15
|
-
const EMPTY_CATALOG = (): KnowledgeCatalog => ({ version: 2, updatedAt: new Date().toISOString(), items: [] });
|
|
16
18
|
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
|
|
19
|
+
const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
|
|
20
|
+
const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
|
|
21
|
+
|
|
22
|
+
export class KnowledgeCommitBusyError extends Error {}
|
|
23
|
+
|
|
24
|
+
function emptyCatalog(): KnowledgeCatalog {
|
|
25
|
+
return { version: 3, updatedAt: new Date().toISOString(), items: [] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyManifest(): KnowledgeManifest {
|
|
29
|
+
return {
|
|
30
|
+
version: 3, generationId: "", createdAt: new Date().toISOString(), leaderToken: "",
|
|
31
|
+
catalog: emptyCatalog(), reviews: {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
function ensureDirectory(path: string): void {
|
|
19
36
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
@@ -30,30 +47,65 @@ function atomicWrite(path: string, content: string): void {
|
|
|
30
47
|
|
|
31
48
|
export function ensureKnowledgeDirectories(home = homedir()): void {
|
|
32
49
|
const paths = userRuntimePaths(home);
|
|
33
|
-
for (const path of [paths.knowledge, paths.
|
|
34
|
-
ensureDirectory(path);
|
|
35
|
-
}
|
|
50
|
+
for (const path of [paths.knowledge, paths.knowledgeGenerations, paths.knowledgeRuntime]) ensureDirectory(path);
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
export function projectKnowledgeKey(projectRoot: string): string {
|
|
39
54
|
return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 16);
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
function generationDirectory(generationId: string, home: string): string | undefined {
|
|
58
|
+
if (!SAFE_GENERATION_RE.test(generationId)) return undefined;
|
|
59
|
+
const root = resolve(userRuntimePaths(home).knowledgeGenerations);
|
|
60
|
+
const directory = resolve(root, generationId);
|
|
61
|
+
return directory.startsWith(`${root}${sep}`) ? directory : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseManifest(path: string): KnowledgeManifest | undefined {
|
|
45
65
|
try {
|
|
46
|
-
const parsed = JSON.parse(readFileSync(path, "utf8")) as
|
|
47
|
-
if (parsed.version
|
|
66
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as KnowledgeManifest;
|
|
67
|
+
if (parsed.version !== 3 || !SAFE_GENERATION_RE.test(parsed.generationId)
|
|
68
|
+
|| parsed.catalog?.version !== 3 || !Array.isArray(parsed.catalog.items)
|
|
69
|
+
|| !parsed.reviews || typeof parsed.reviews !== "object") return undefined;
|
|
70
|
+
return parsed;
|
|
48
71
|
} catch {
|
|
49
|
-
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validManifestAt(manifest: KnowledgeManifest, directory: string): boolean {
|
|
77
|
+
return manifest.catalog.items.every((entry) => {
|
|
78
|
+
if (!SAFE_ID_RE.test(entry.id) || basename(entry.file) !== `${entry.id}.md`) return false;
|
|
79
|
+
if (entry.scope !== "global" && !PROJECT_SCOPE_RE.test(entry.scope)) return false;
|
|
80
|
+
const path = resolve(directory, entry.file);
|
|
81
|
+
return path.startsWith(`${directory}${sep}`) && existsSync(path);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validManifest(manifest: KnowledgeManifest, home: string): boolean {
|
|
86
|
+
const directory = generationDirectory(manifest.generationId, home);
|
|
87
|
+
return Boolean(directory && validManifestAt(manifest, directory));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadCurrentManifest(home = homedir()): KnowledgeManifest {
|
|
91
|
+
ensureKnowledgeDirectories(home);
|
|
92
|
+
const paths = userRuntimePaths(home);
|
|
93
|
+
const preferred = existsSync(paths.knowledgeCurrent) ? readFileSync(paths.knowledgeCurrent, "utf8").trim() : "";
|
|
94
|
+
const candidates = [preferred, ...readdirSync(paths.knowledgeGenerations, { withFileTypes: true })
|
|
95
|
+
.filter((entry) => entry.isDirectory() && SAFE_GENERATION_RE.test(entry.name))
|
|
96
|
+
.map((entry) => entry.name).sort().reverse()]
|
|
97
|
+
.filter((value, index, values) => value && values.indexOf(value) === index);
|
|
98
|
+
for (const generationId of candidates) {
|
|
99
|
+
const directory = generationDirectory(generationId, home);
|
|
100
|
+
if (!directory) continue;
|
|
101
|
+
const manifest = parseManifest(join(directory, "manifest.json"));
|
|
102
|
+
if (manifest && manifest.generationId === generationId && validManifest(manifest, home)) return manifest;
|
|
50
103
|
}
|
|
51
|
-
return
|
|
104
|
+
return emptyManifest();
|
|
52
105
|
}
|
|
53
106
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
atomicWrite(userRuntimePaths(home).knowledgeCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
107
|
+
export function loadKnowledgeCatalog(home = homedir()): KnowledgeCatalog {
|
|
108
|
+
return loadCurrentManifest(home).catalog;
|
|
57
109
|
}
|
|
58
110
|
|
|
59
111
|
function normalizeSlug(value: string): string {
|
|
@@ -81,26 +133,20 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
|
|
|
81
133
|
if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
|
|
82
134
|
|| typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
|
|
83
135
|
if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
|
|
84
|
-
const storageHint: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
85
136
|
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
86
|
-
const
|
|
87
|
-
const scope: KnowledgeScope = requestedScope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
137
|
+
const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
88
138
|
const body = compactKnowledgeText(raw.body, 20_000);
|
|
89
139
|
const evidence = asStringArray(raw.evidence, 8);
|
|
90
140
|
if (!body || evidence.length === 0) return undefined;
|
|
91
|
-
const
|
|
92
|
-
|
|
141
|
+
const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
142
|
+
const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
|
|
93
143
|
&& (explicitUserDirective || raw.confidence >= 0.9);
|
|
94
144
|
return {
|
|
95
|
-
key: compactKnowledgeText(raw.key, 160),
|
|
96
|
-
title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
|
|
145
|
+
key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
|
|
97
146
|
summary: compactKnowledgeText(raw.summary, STORAGE.maxSummaryChars),
|
|
98
147
|
keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
|
|
99
|
-
scope,
|
|
100
|
-
|
|
101
|
-
evidence,
|
|
102
|
-
confidence: Math.min(1, raw.confidence),
|
|
103
|
-
storageHint: storageHint === "rule" && ruleEligible ? "rule" : "topic",
|
|
148
|
+
scope, body, evidence, confidence: Math.min(1, raw.confidence),
|
|
149
|
+
storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
|
|
104
150
|
action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
|
|
105
151
|
explicitUserDirective,
|
|
106
152
|
};
|
|
@@ -109,41 +155,19 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
|
|
|
109
155
|
function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
|
|
110
156
|
if (candidate.storageHint === "rule") return `# ${candidate.title}\n\n${candidate.body}\n`;
|
|
111
157
|
return [
|
|
112
|
-
`# ${candidate.title}`,
|
|
113
|
-
"",
|
|
114
|
-
candidate.summary,
|
|
115
|
-
"",
|
|
116
|
-
`Keywords: ${candidate.keywords.join(", ")}`,
|
|
117
|
-
"",
|
|
118
|
-
candidate.body,
|
|
119
|
-
"",
|
|
120
|
-
"## Evidence",
|
|
121
|
-
"",
|
|
122
|
-
...candidate.evidence.map((item) => `- ${item}`),
|
|
123
|
-
"",
|
|
158
|
+
`# ${candidate.title}`, "", candidate.summary, "", `Keywords: ${candidate.keywords.join(", ")}`, "",
|
|
159
|
+
candidate.body, "", "## Evidence", "", ...candidate.evidence.map((item) => `- ${item}`), "",
|
|
124
160
|
].join("\n");
|
|
125
161
|
}
|
|
126
162
|
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
if (!existsSync(directory)) return 0;
|
|
130
|
-
return readdirSync(directory, { withFileTypes: true })
|
|
131
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
132
|
-
.reduce((total, entry) => total + readFileSync(join(directory, entry.name), "utf8").length, 0);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function savePending(candidate: KnowledgeCandidate, reason: string, home: string): void {
|
|
136
|
-
const paths = userRuntimePaths(home);
|
|
137
|
-
const name = `${Date.now()}-${normalizeSlug(candidate.title)}-${randomUUID().slice(0, 8)}.json`;
|
|
138
|
-
atomicWrite(join(paths.knowledgePending, name), `${JSON.stringify({ reason, candidate, createdAt: new Date().toISOString() }, null, 2)}\n`);
|
|
163
|
+
function applicable(entry: KnowledgeCatalogEntry, projectKey: string): boolean {
|
|
164
|
+
return entry.scope === "global" || entry.scope === `project:${projectKey}`;
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string {
|
|
142
168
|
const lines = [
|
|
143
|
-
"# HWCode Knowledge Index",
|
|
144
|
-
"",
|
|
145
|
-
"Detailed topics are loaded only through hwcode_knowledge_lookup. Search by ID or keywords.",
|
|
146
|
-
"",
|
|
169
|
+
"# HWCode Knowledge Index", "",
|
|
170
|
+
"Detailed topics are loaded only through hwcode_knowledge_lookup. Search by ID or keywords.", "",
|
|
147
171
|
];
|
|
148
172
|
const topics = catalog.items.filter((item) => item.track === "topic" && (!projectKey || applicable(item, projectKey)))
|
|
149
173
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
@@ -159,93 +183,183 @@ function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string
|
|
|
159
183
|
return `${lines.join("\n")}\n`;
|
|
160
184
|
}
|
|
161
185
|
|
|
162
|
-
function
|
|
163
|
-
|
|
186
|
+
function processExists(pid: number): boolean {
|
|
187
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
188
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
189
|
+
}
|
|
164
190
|
}
|
|
165
191
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const fingerprint = normalizedFingerprint(candidate);
|
|
178
|
-
const hash = contentHash(candidate);
|
|
179
|
-
const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
|
|
180
|
-
const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
|
|
181
|
-
if (existing?.contentHash === hash) {
|
|
182
|
-
existing.evidenceCount += candidate.evidence.length;
|
|
183
|
-
existing.updatedAt = new Date().toISOString();
|
|
184
|
-
result.updated++;
|
|
185
|
-
continue;
|
|
192
|
+
function acquireCommitLock(leaderToken: string, home: string): () => void {
|
|
193
|
+
const paths = userRuntimePaths(home);
|
|
194
|
+
ensureDirectory(paths.knowledgeRuntime);
|
|
195
|
+
const attempt = (): boolean => {
|
|
196
|
+
try {
|
|
197
|
+
mkdirSync(paths.knowledgeCommitLock, { mode: 0o700 });
|
|
198
|
+
writeFileSync(join(paths.knowledgeCommitLock, "owner.json"), JSON.stringify({ pid: process.pid, leaderToken }), { mode: 0o600 });
|
|
199
|
+
return true;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
202
|
+
return false;
|
|
186
203
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
};
|
|
205
|
+
if (!attempt()) {
|
|
206
|
+
try {
|
|
207
|
+
const owner = JSON.parse(readFileSync(join(paths.knowledgeCommitLock, "owner.json"), "utf8")) as {
|
|
208
|
+
pid?: number; leaderToken?: string;
|
|
209
|
+
};
|
|
210
|
+
if (owner.leaderToken !== leaderToken || (typeof owner.pid === "number" && !processExists(owner.pid))) {
|
|
211
|
+
rmSync(paths.knowledgeCommitLock, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
const age = Date.now() - statSync(paths.knowledgeCommitLock).mtimeMs;
|
|
215
|
+
if (age > KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs) rmSync(paths.knowledgeCommitLock, { recursive: true, force: true });
|
|
191
216
|
}
|
|
192
|
-
if (!
|
|
193
|
-
|
|
194
|
-
|
|
217
|
+
if (!attempt()) throw new KnowledgeCommitBusyError("Knowledge repository is busy");
|
|
218
|
+
}
|
|
219
|
+
return () => rmSync(paths.knowledgeCommitLock, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function newGenerationId(): string {
|
|
223
|
+
return `${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID().replace(/-/gu, "").slice(0, 12)}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function copyCurrentContent(manifest: KnowledgeManifest, target: string, home: string): void {
|
|
227
|
+
for (const name of ["rules", "topics", "pending"]) ensureDirectory(join(target, name));
|
|
228
|
+
if (!manifest.generationId) return;
|
|
229
|
+
const source = generationDirectory(manifest.generationId, home);
|
|
230
|
+
if (!source) return;
|
|
231
|
+
for (const name of ["rules", "topics", "pending"]) {
|
|
232
|
+
const sourceDirectory = join(source, name);
|
|
233
|
+
if (!existsSync(sourceDirectory)) continue;
|
|
234
|
+
for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) {
|
|
235
|
+
if (!entry.isFile()) continue;
|
|
236
|
+
const sourceFile = join(sourceDirectory, entry.name);
|
|
237
|
+
const targetFile = join(target, name, entry.name);
|
|
238
|
+
try { linkSync(sourceFile, targetFile); } catch { copyFileSync(sourceFile, targetFile); }
|
|
195
239
|
}
|
|
196
|
-
const now = new Date().toISOString();
|
|
197
|
-
const id = existing?.id ?? `${normalizeSlug(candidate.title)}-${fingerprint.slice(0, 8)}`;
|
|
198
|
-
const track = existing?.track ?? candidate.storageHint;
|
|
199
|
-
candidate.storageHint = track;
|
|
200
|
-
const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
|
|
201
|
-
const absoluteFile = join(userRuntimePaths(home).knowledge, relativeFile);
|
|
202
|
-
atomicWrite(absoluteFile, renderKnowledgeFile(candidate));
|
|
203
|
-
const entry: KnowledgeCatalogEntry = {
|
|
204
|
-
id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
|
|
205
|
-
keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
|
|
206
|
-
evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
|
|
207
|
-
createdAt: existing?.createdAt ?? now, updatedAt: now,
|
|
208
|
-
};
|
|
209
|
-
if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
|
|
210
|
-
else { catalog.items.push(entry); result.saved++; }
|
|
211
240
|
}
|
|
212
|
-
saveCatalog(catalog, home);
|
|
213
|
-
writeMemory(catalog, home);
|
|
214
|
-
return result;
|
|
215
241
|
}
|
|
216
242
|
|
|
217
|
-
function
|
|
218
|
-
return
|
|
243
|
+
function storedRuleCharacters(directory: string): number {
|
|
244
|
+
return readdirSync(join(directory, "rules"), { withFileTypes: true })
|
|
245
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
246
|
+
.reduce((total, entry) => total + readFileSync(join(directory, "rules", entry.name), "utf8").length, 0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function reviewCursor(task: KnowledgeReviewTask): KnowledgeReviewCursor {
|
|
250
|
+
return {
|
|
251
|
+
sessionId: task.sessionId, sessionKey: task.sessionKey, sessionFileHash: task.sessionFileHash,
|
|
252
|
+
projectRoot: task.projectRoot, projectKey: task.projectKey, lastReviewedEntryId: task.lastEntryId,
|
|
253
|
+
lastReviewedAt: new Date().toISOString(), lastDeltaDigest: task.deltaDigest,
|
|
254
|
+
lastReviewKey: task.reviewKey, fileSize: task.fileSize, fileMtimeMs: task.fileMtimeMs,
|
|
255
|
+
};
|
|
219
256
|
}
|
|
220
257
|
|
|
221
|
-
function
|
|
258
|
+
export function commitKnowledgeReview(
|
|
259
|
+
values: unknown[], task: KnowledgeReviewTask, leaderToken: string, home = homedir(),
|
|
260
|
+
): PersistKnowledgeResult {
|
|
261
|
+
ensureKnowledgeDirectories(home);
|
|
262
|
+
const release = acquireCommitLock(leaderToken, home);
|
|
263
|
+
const paths = userRuntimePaths(home);
|
|
264
|
+
let temporary = "";
|
|
265
|
+
try {
|
|
266
|
+
const current = loadCurrentManifest(home);
|
|
267
|
+
if (current.reviews[task.sessionKey]?.lastReviewKey === task.reviewKey) {
|
|
268
|
+
return { saved: 0, updated: 0, pending: 0, skipped: 0 };
|
|
269
|
+
}
|
|
270
|
+
const generationId = newGenerationId();
|
|
271
|
+
temporary = join(paths.knowledgeGenerations, `.tmp-${generationId}-${process.pid}`);
|
|
272
|
+
ensureDirectory(temporary);
|
|
273
|
+
copyCurrentContent(current, temporary, home);
|
|
274
|
+
const catalog: KnowledgeCatalog = structuredClone(current.catalog);
|
|
275
|
+
const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
|
|
276
|
+
for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
|
|
277
|
+
const candidate = normalizeCandidate(value, task.projectKey);
|
|
278
|
+
if (!candidate) { result.skipped++; continue; }
|
|
279
|
+
const fingerprint = normalizedFingerprint(candidate);
|
|
280
|
+
const hash = contentHash(candidate);
|
|
281
|
+
const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
|
|
282
|
+
const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
|
|
283
|
+
if (existing?.contentHash === hash) {
|
|
284
|
+
existing.evidenceCount += candidate.evidence.length;
|
|
285
|
+
existing.updatedAt = new Date().toISOString();
|
|
286
|
+
result.updated++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (existing && !(candidate.action === "revise" && (candidate.explicitUserDirective || candidate.confidence >= 0.92))) {
|
|
290
|
+
const name = `${Date.now()}-${normalizeSlug(candidate.title)}-${randomUUID().slice(0, 8)}.json`;
|
|
291
|
+
atomicWrite(join(temporary, "pending", name), `${JSON.stringify({ reason: "conflicting-or-ambiguous-update", candidate }, null, 2)}\n`);
|
|
292
|
+
result.pending++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (!existing && candidate.storageHint === "rule"
|
|
296
|
+
&& storedRuleCharacters(temporary) + renderKnowledgeFile(candidate).length > STORAGE.maxRulesPromptChars) {
|
|
297
|
+
candidate.storageHint = "topic";
|
|
298
|
+
}
|
|
299
|
+
const now = new Date().toISOString();
|
|
300
|
+
const id = existing?.id ?? `${normalizeSlug(candidate.title)}-${fingerprint.slice(0, 8)}`;
|
|
301
|
+
const track = existing?.track ?? candidate.storageHint;
|
|
302
|
+
candidate.storageHint = track;
|
|
303
|
+
const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
|
|
304
|
+
atomicWrite(join(temporary, relativeFile), renderKnowledgeFile(candidate));
|
|
305
|
+
const entry: KnowledgeCatalogEntry = {
|
|
306
|
+
id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
|
|
307
|
+
keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
|
|
308
|
+
evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
|
|
309
|
+
createdAt: existing?.createdAt ?? now, updatedAt: now,
|
|
310
|
+
};
|
|
311
|
+
if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
|
|
312
|
+
else { catalog.items.push(entry); result.saved++; }
|
|
313
|
+
}
|
|
314
|
+
catalog.updatedAt = new Date().toISOString();
|
|
315
|
+
const manifest: KnowledgeManifest = {
|
|
316
|
+
version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
|
|
317
|
+
reviews: { ...current.reviews, [task.sessionKey]: reviewCursor(task) },
|
|
318
|
+
};
|
|
319
|
+
atomicWrite(join(temporary, "MEMORY.md"), generateMemory(catalog));
|
|
320
|
+
atomicWrite(join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
321
|
+
if (!validManifestAt(manifest, temporary)) throw new Error("Refusing to publish an incomplete knowledge generation");
|
|
322
|
+
const finalDirectory = join(paths.knowledgeGenerations, generationId);
|
|
323
|
+
renameSync(temporary, finalDirectory);
|
|
324
|
+
temporary = "";
|
|
325
|
+
atomicWrite(paths.knowledgeCurrent, `${generationId}\n`);
|
|
326
|
+
return result;
|
|
327
|
+
} finally {
|
|
328
|
+
if (temporary) rmSync(temporary, { recursive: true, force: true });
|
|
329
|
+
release();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function readCatalogFile(entry: KnowledgeCatalogEntry, manifest: KnowledgeManifest, home: string): string | undefined {
|
|
222
334
|
if (!SAFE_ID_RE.test(entry.id) || basename(entry.file) !== `${entry.id}.md`) return undefined;
|
|
223
|
-
const root =
|
|
335
|
+
const root = generationDirectory(manifest.generationId, home);
|
|
336
|
+
if (!root) return undefined;
|
|
224
337
|
const path = resolve(root, entry.file);
|
|
225
338
|
if (!path.startsWith(`${root}${sep}`) || !existsSync(path)) return undefined;
|
|
226
339
|
return sanitizeKnowledgeText(readFileSync(path, "utf8"));
|
|
227
340
|
}
|
|
228
341
|
|
|
229
342
|
export function loadKnowledgeSnapshot(projectKey: string, home = homedir()): KnowledgeSnapshot {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const paths = userRuntimePaths(home);
|
|
234
|
-
for (const file of readdirSync(paths.knowledgeRules, { withFileTypes: true })
|
|
235
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
236
|
-
const relativeFile = `rules/${file.name}`;
|
|
237
|
-
const entry = catalog.items.find((item) => item.file === relativeFile);
|
|
238
|
-
if (entry && !applicable(entry, projectKey)) continue;
|
|
239
|
-
ruleParts.push(sanitizeKnowledgeText(readFileSync(join(paths.knowledgeRules, file.name), "utf8")).trim());
|
|
343
|
+
const manifest = loadCurrentManifest(home);
|
|
344
|
+
if (!manifest.generationId) {
|
|
345
|
+
return { rulesPrompt: "", memoryPrompt: generateMemory(manifest.catalog, projectKey), catalog: manifest.catalog };
|
|
240
346
|
}
|
|
241
|
-
const
|
|
242
|
-
|
|
347
|
+
const rules = manifest.catalog.items.filter((entry) => entry.track === "rule" && applicable(entry, projectKey))
|
|
348
|
+
.sort((left, right) => left.file.localeCompare(right.file))
|
|
349
|
+
.map((entry) => readCatalogFile(entry, manifest, home)).filter((content): content is string => Boolean(content));
|
|
350
|
+
return {
|
|
351
|
+
generationId: manifest.generationId, rulesPrompt: rules.join("\n\n"),
|
|
352
|
+
memoryPrompt: generateMemory(manifest.catalog, projectKey), catalog: manifest.catalog,
|
|
353
|
+
};
|
|
243
354
|
}
|
|
244
355
|
|
|
245
|
-
export function loadKnowledgeById(
|
|
356
|
+
export function loadKnowledgeById(
|
|
357
|
+
id: string, projectKey: string, home = homedir(),
|
|
358
|
+
): { entry: KnowledgeCatalogEntry; content: string } | undefined {
|
|
246
359
|
if (!SAFE_ID_RE.test(id)) return undefined;
|
|
247
|
-
const
|
|
360
|
+
const manifest = loadCurrentManifest(home);
|
|
361
|
+
const entry = manifest.catalog.items.find((item) => item.id === id && applicable(item, projectKey));
|
|
248
362
|
if (!entry) return undefined;
|
|
249
|
-
const content = readCatalogFile(entry, home);
|
|
363
|
+
const content = readCatalogFile(entry, manifest, home);
|
|
250
364
|
return content ? { entry, content } : undefined;
|
|
251
365
|
}
|
|
@@ -32,23 +32,39 @@ export interface KnowledgeCatalogEntry {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface KnowledgeCatalog {
|
|
35
|
-
version:
|
|
35
|
+
version: 3;
|
|
36
36
|
updatedAt: string;
|
|
37
37
|
items: KnowledgeCatalogEntry[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export interface KnowledgeSnapshot {
|
|
41
|
+
generationId?: string;
|
|
41
42
|
rulesPrompt: string;
|
|
42
43
|
memoryPrompt: string;
|
|
43
44
|
catalog: KnowledgeCatalog;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export interface
|
|
47
|
-
|
|
47
|
+
export interface KnowledgeReviewCursor {
|
|
48
|
+
sessionId: string;
|
|
49
|
+
sessionKey: string;
|
|
50
|
+
sessionFileHash: string;
|
|
51
|
+
projectRoot: string;
|
|
52
|
+
projectKey: string;
|
|
48
53
|
lastReviewedEntryId?: string;
|
|
49
54
|
lastReviewedAt?: string;
|
|
50
|
-
lastDeltaDigest
|
|
51
|
-
|
|
55
|
+
lastDeltaDigest: string;
|
|
56
|
+
lastReviewKey: string;
|
|
57
|
+
fileSize: number;
|
|
58
|
+
fileMtimeMs: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface KnowledgeManifest {
|
|
62
|
+
version: 3;
|
|
63
|
+
generationId: string;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
leaderToken: string;
|
|
66
|
+
catalog: KnowledgeCatalog;
|
|
67
|
+
reviews: Record<string, KnowledgeReviewCursor>;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
export interface PersistKnowledgeResult {
|
|
@@ -57,3 +73,19 @@ export interface PersistKnowledgeResult {
|
|
|
57
73
|
pending: number;
|
|
58
74
|
skipped: number;
|
|
59
75
|
}
|
|
76
|
+
|
|
77
|
+
export interface KnowledgeReviewTask {
|
|
78
|
+
requestId: string;
|
|
79
|
+
reviewKey: string;
|
|
80
|
+
sessionId: string;
|
|
81
|
+
sessionKey: string;
|
|
82
|
+
sessionFileHash: string;
|
|
83
|
+
projectRoot: string;
|
|
84
|
+
projectKey: string;
|
|
85
|
+
firstEntryId: string;
|
|
86
|
+
lastEntryId: string;
|
|
87
|
+
delta: string;
|
|
88
|
+
deltaDigest: string;
|
|
89
|
+
fileSize: number;
|
|
90
|
+
fileMtimeMs: number;
|
|
91
|
+
}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import type { PersistKnowledgeResult } from "./types.ts";
|
|
1
|
+
import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
export type KnowledgeWorkerInput =
|
|
4
|
-
| { type: "configure";
|
|
5
|
-
| { type: "
|
|
6
|
-
| { type: "
|
|
4
|
+
| { type: "configure"; modelAvailable: boolean; sessionsRoot: string }
|
|
5
|
+
| { type: "review_result"; leaderToken: string; requestId: string; raw?: string; error?: string }
|
|
6
|
+
| { type: "scan_now" }
|
|
7
7
|
| { type: "stop" };
|
|
8
8
|
|
|
9
9
|
export type KnowledgeWorkerOutput =
|
|
10
|
-
| { type: "
|
|
11
|
-
| { type: "
|
|
12
|
-
| { type: "
|
|
10
|
+
| { type: "leadership"; state: "leader" | "standby" | "ineligible"; leaderToken?: string }
|
|
11
|
+
| { type: "review_request"; leaderToken: string; requestId: string; task: KnowledgeReviewTask }
|
|
12
|
+
| { type: "review_cancel"; requestId: string; reason: string }
|
|
13
|
+
| { type: "review_saved"; requestId: string; generationId?: string; result: PersistKnowledgeResult }
|
|
14
|
+
| { type: "review_failed"; requestId?: string; error: string }
|
|
15
|
+
| { type: "generation_changed"; generationId: string };
|
|
16
|
+
|
|
17
|
+
export interface CoordinatorBroadcast {
|
|
18
|
+
type: "generation_changed";
|
|
19
|
+
generationId: string;
|
|
20
|
+
}
|
|
@@ -20,7 +20,7 @@ export const CLOUD_RUNTIME_DEFAULTS = Object.freeze({
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Defaults for the cross-workflow knowledge base (`~/.hwcode/knowledge/`).
|
|
23
|
+
* Defaults for the cross-workflow knowledge base (`~/.hwcode/knowledge-v3/`).
|
|
24
24
|
*
|
|
25
25
|
* A Worker checks settled sessions on a fixed interval. Short rules are loaded
|
|
26
26
|
* in full; detailed topics are routed through a bounded MEMORY.md index so the
|
|
@@ -29,8 +29,11 @@ export const CLOUD_RUNTIME_DEFAULTS = Object.freeze({
|
|
|
29
29
|
export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
|
|
30
30
|
review: Object.freeze({
|
|
31
31
|
intervalMs: 60_000,
|
|
32
|
+
idleMs: 60_000,
|
|
33
|
+
capabilityPollMs: 5_000,
|
|
34
|
+
modelTimeoutMs: 120_000,
|
|
32
35
|
maxDeltaChars: 30_000,
|
|
33
|
-
maxCandidates:
|
|
36
|
+
maxCandidates: 8,
|
|
34
37
|
minimumConfidence: 0.72,
|
|
35
38
|
}),
|
|
36
39
|
storage: Object.freeze({
|
package/.pi/lib/runtime/paths.ts
CHANGED
|
@@ -12,7 +12,7 @@ const CLOUD_VAULT_FILE = "credentials.enc";
|
|
|
12
12
|
const CLOUD_KNOWN_HOSTS_FILE = "known_hosts";
|
|
13
13
|
const CLOUD_TEMPLATES_SUBDIR = "templates";
|
|
14
14
|
const CLOUD_TERRAFORM_SUBDIR = "terraform";
|
|
15
|
-
const KNOWLEDGE_SUBDIR = "knowledge";
|
|
15
|
+
const KNOWLEDGE_SUBDIR = "knowledge-v3";
|
|
16
16
|
|
|
17
17
|
export interface ProjectRuntimePaths {
|
|
18
18
|
root: string;
|
|
@@ -29,11 +29,13 @@ export interface UserRuntimePaths {
|
|
|
29
29
|
cloudKnownHosts: string;
|
|
30
30
|
cloudTerraformTemplates: string;
|
|
31
31
|
knowledge: string;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
knowledgeGenerations: string;
|
|
33
|
+
knowledgeCurrent: string;
|
|
34
|
+
knowledgeRuntime: string;
|
|
35
|
+
knowledgeCoordinatorSocket: string;
|
|
36
|
+
knowledgeLeader: string;
|
|
37
|
+
knowledgeCommitLock: string;
|
|
38
|
+
knowledgeElectionLock: string;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export function projectRuntimePaths(projectRoot: string): ProjectRuntimePaths {
|
|
@@ -60,11 +62,14 @@ export function userRuntimePaths(home = homedir()): UserRuntimePaths {
|
|
|
60
62
|
cloudKnownHosts: join(cloud, CLOUD_KNOWN_HOSTS_FILE),
|
|
61
63
|
cloudTerraformTemplates: join(cloud, CLOUD_TEMPLATES_SUBDIR, CLOUD_TERRAFORM_SUBDIR),
|
|
62
64
|
knowledge,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
knowledgeGenerations: join(knowledge, "generations"),
|
|
66
|
+
knowledgeCurrent: join(knowledge, "CURRENT"),
|
|
67
|
+
knowledgeRuntime: join(knowledge, "runtime"),
|
|
68
|
+
// Unix-domain socket paths are short on purpose (macOS caps them at roughly 104 bytes).
|
|
69
|
+
knowledgeCoordinatorSocket: join(root, "knowledge-v3.sock"),
|
|
70
|
+
knowledgeLeader: join(knowledge, "runtime", "leader.json"),
|
|
71
|
+
knowledgeCommitLock: join(knowledge, "runtime", "commit.lock"),
|
|
72
|
+
knowledgeElectionLock: join(knowledge, "runtime", "election.lock"),
|
|
68
73
|
};
|
|
69
74
|
}
|
|
70
75
|
|