@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.
- package/.pi/extensions/command-filter.ts +2 -3
- package/.pi/extensions/cwd.ts +1 -4
- package/.pi/extensions/knowledge.ts +224 -0
- package/.pi/extensions/workflows/cloud/activation.ts +57 -43
- package/.pi/extensions/workflows/cloud/commands.ts +1 -86
- package/.pi/extensions/workflows/cloud/events.ts +16 -19
- package/.pi/extensions/workflows/cloud/interactions.ts +102 -0
- package/.pi/extensions/workflows/cloud/provider-tools.ts +15 -9
- package/.pi/extensions/workflows/cloud/runner-tools.ts +21 -20
- package/.pi/extensions/workflows/cloud/runtime.ts +41 -4
- package/.pi/extensions/workflows/cloud/terraform-tools.ts +43 -30
- package/.pi/extensions/workflows/sdd.ts +2 -1
- package/.pi/extensions/workflows/vibe.ts +2 -1
- package/.pi/extensions/workflows/workspace-guard.ts +1 -5
- package/.pi/lib/extension-ui.ts +52 -0
- package/.pi/lib/knowledge/extractor.ts +35 -0
- package/.pi/lib/knowledge/matcher.ts +122 -0
- package/.pi/lib/knowledge/review-worker.ts +260 -0
- package/.pi/lib/knowledge/sanitize.ts +26 -0
- package/.pi/lib/knowledge/session-scanner.ts +155 -0
- package/.pi/lib/knowledge/store.ts +365 -0
- package/.pi/lib/knowledge/types.ts +91 -0
- package/.pi/lib/knowledge/worker-protocol.ts +20 -0
- package/.pi/lib/runtime/defaults.ts +31 -0
- package/.pi/lib/runtime/paths.ts +33 -16
- package/.pi/lib/tool-result.ts +7 -0
- package/.pi/lib/workflows/cloud/bundles.ts +73 -40
- package/.pi/lib/workflows/cloud/workspace.ts +6 -0
- package/.pi/lib/workflows/state.ts +6 -26
- package/.pi/skills/hwcode-cloud/SKILL.md +2 -2
- package/README.md +36 -31
- package/bin/hwcode.js +2 -3
- package/package.json +1 -1
- package/.pi/extensions/workflows/cloud/shared.ts +0 -230
- package/.pi/lib/workflows/cloud/template-save.ts +0 -108
- package/.pi/lib/workflows/cloud/templates.ts +0 -314
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync,
|
|
4
|
+
statSync, writeFileSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
10
|
+
import { userRuntimePaths } from "../runtime/paths.ts";
|
|
11
|
+
import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
|
|
12
|
+
import type {
|
|
13
|
+
KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
|
|
14
|
+
KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
|
|
17
|
+
const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
|
|
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
|
+
}
|
|
34
|
+
|
|
35
|
+
function ensureDirectory(path: string): void {
|
|
36
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
37
|
+
chmodSync(path, 0o700);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function atomicWrite(path: string, content: string): void {
|
|
41
|
+
ensureDirectory(dirname(path));
|
|
42
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
43
|
+
writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
44
|
+
renameSync(temporary, path);
|
|
45
|
+
chmodSync(path, 0o600);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ensureKnowledgeDirectories(home = homedir()): void {
|
|
49
|
+
const paths = userRuntimePaths(home);
|
|
50
|
+
for (const path of [paths.knowledge, paths.knowledgeGenerations, paths.knowledgeRuntime]) ensureDirectory(path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function projectKnowledgeKey(projectRoot: string): string {
|
|
54
|
+
return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 16);
|
|
55
|
+
}
|
|
56
|
+
|
|
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 {
|
|
65
|
+
try {
|
|
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;
|
|
71
|
+
} catch {
|
|
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;
|
|
103
|
+
}
|
|
104
|
+
return emptyManifest();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function loadKnowledgeCatalog(home = homedir()): KnowledgeCatalog {
|
|
108
|
+
return loadCurrentManifest(home).catalog;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeSlug(value: string): string {
|
|
112
|
+
return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/gu, "-")
|
|
113
|
+
.replace(/^-+|-+$/gu, "").slice(0, STORAGE.slugMaxChars) || "knowledge";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizedFingerprint(candidate: KnowledgeCandidate): string {
|
|
117
|
+
return createHash("sha256").update(`${candidate.scope}\0${candidate.key.trim().toLowerCase()}`).digest("hex");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function contentHash(candidate: KnowledgeCandidate): string {
|
|
121
|
+
return createHash("sha256").update(`${candidate.title}\0${candidate.summary}\0${candidate.body}`).digest("hex");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function asStringArray(value: unknown, maxItems: number): string[] {
|
|
125
|
+
if (!Array.isArray(value)) return [];
|
|
126
|
+
return [...new Set(value.filter((item): item is string => typeof item === "string")
|
|
127
|
+
.map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandidate | undefined {
|
|
131
|
+
if (!value || typeof value !== "object") return undefined;
|
|
132
|
+
const raw = value as Record<string, unknown>;
|
|
133
|
+
if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
|
|
134
|
+
|| typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
|
|
135
|
+
if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
|
|
136
|
+
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
137
|
+
const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
138
|
+
const body = compactKnowledgeText(raw.body, 20_000);
|
|
139
|
+
const evidence = asStringArray(raw.evidence, 8);
|
|
140
|
+
if (!body || evidence.length === 0) return undefined;
|
|
141
|
+
const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
142
|
+
const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
|
|
143
|
+
&& (explicitUserDirective || raw.confidence >= 0.9);
|
|
144
|
+
return {
|
|
145
|
+
key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
|
|
146
|
+
summary: compactKnowledgeText(raw.summary, STORAGE.maxSummaryChars),
|
|
147
|
+
keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
|
|
148
|
+
scope, body, evidence, confidence: Math.min(1, raw.confidence),
|
|
149
|
+
storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
|
|
150
|
+
action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
|
|
151
|
+
explicitUserDirective,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
|
|
156
|
+
if (candidate.storageHint === "rule") return `# ${candidate.title}\n\n${candidate.body}\n`;
|
|
157
|
+
return [
|
|
158
|
+
`# ${candidate.title}`, "", candidate.summary, "", `Keywords: ${candidate.keywords.join(", ")}`, "",
|
|
159
|
+
candidate.body, "", "## Evidence", "", ...candidate.evidence.map((item) => `- ${item}`), "",
|
|
160
|
+
].join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applicable(entry: KnowledgeCatalogEntry, projectKey: string): boolean {
|
|
164
|
+
return entry.scope === "global" || entry.scope === `project:${projectKey}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string {
|
|
168
|
+
const lines = [
|
|
169
|
+
"# HWCode Knowledge Index", "",
|
|
170
|
+
"Detailed topics are loaded only through hwcode_knowledge_lookup. Search by ID or keywords.", "",
|
|
171
|
+
];
|
|
172
|
+
const topics = catalog.items.filter((item) => item.track === "topic" && (!projectKey || applicable(item, projectKey)))
|
|
173
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
174
|
+
for (const item of topics) {
|
|
175
|
+
const scope = item.scope === "global" ? "global" : "project";
|
|
176
|
+
const line = `- [${item.id}] (${scope}; ${item.keywords.join(", ")}) ${item.title}: ${item.summary}`;
|
|
177
|
+
if (lines.length + 1 >= STORAGE.maxMemoryLines || [...lines, line].join("\n").length > STORAGE.maxMemoryChars) {
|
|
178
|
+
lines.push("- Additional topics remain searchable through hwcode_knowledge_lookup(query: \"keywords\").");
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
lines.push(line);
|
|
182
|
+
}
|
|
183
|
+
return `${lines.join("\n")}\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
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
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
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;
|
|
203
|
+
}
|
|
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 });
|
|
216
|
+
}
|
|
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); }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
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
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
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 {
|
|
334
|
+
if (!SAFE_ID_RE.test(entry.id) || basename(entry.file) !== `${entry.id}.md`) return undefined;
|
|
335
|
+
const root = generationDirectory(manifest.generationId, home);
|
|
336
|
+
if (!root) return undefined;
|
|
337
|
+
const path = resolve(root, entry.file);
|
|
338
|
+
if (!path.startsWith(`${root}${sep}`) || !existsSync(path)) return undefined;
|
|
339
|
+
return sanitizeKnowledgeText(readFileSync(path, "utf8"));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function loadKnowledgeSnapshot(projectKey: string, home = homedir()): KnowledgeSnapshot {
|
|
343
|
+
const manifest = loadCurrentManifest(home);
|
|
344
|
+
if (!manifest.generationId) {
|
|
345
|
+
return { rulesPrompt: "", memoryPrompt: generateMemory(manifest.catalog, projectKey), catalog: manifest.catalog };
|
|
346
|
+
}
|
|
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
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function loadKnowledgeById(
|
|
357
|
+
id: string, projectKey: string, home = homedir(),
|
|
358
|
+
): { entry: KnowledgeCatalogEntry; content: string } | undefined {
|
|
359
|
+
if (!SAFE_ID_RE.test(id)) return undefined;
|
|
360
|
+
const manifest = loadCurrentManifest(home);
|
|
361
|
+
const entry = manifest.catalog.items.find((item) => item.id === id && applicable(item, projectKey));
|
|
362
|
+
if (!entry) return undefined;
|
|
363
|
+
const content = readCatalogFile(entry, manifest, home);
|
|
364
|
+
return content ? { entry, content } : undefined;
|
|
365
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export type KnowledgeTrack = "rule" | "topic";
|
|
2
|
+
export type KnowledgeScope = "global" | `project:${string}`;
|
|
3
|
+
export type KnowledgeAction = "add" | "reinforce" | "revise";
|
|
4
|
+
|
|
5
|
+
export interface KnowledgeCandidate {
|
|
6
|
+
key: string;
|
|
7
|
+
title: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
keywords: string[];
|
|
10
|
+
scope: KnowledgeScope;
|
|
11
|
+
body: string;
|
|
12
|
+
evidence: string[];
|
|
13
|
+
confidence: number;
|
|
14
|
+
storageHint: KnowledgeTrack;
|
|
15
|
+
action: KnowledgeAction;
|
|
16
|
+
explicitUserDirective: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface KnowledgeCatalogEntry {
|
|
20
|
+
id: string;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
contentHash: string;
|
|
23
|
+
title: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
keywords: string[];
|
|
26
|
+
scope: KnowledgeScope;
|
|
27
|
+
track: KnowledgeTrack;
|
|
28
|
+
file: string;
|
|
29
|
+
evidenceCount: number;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface KnowledgeCatalog {
|
|
35
|
+
version: 3;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
items: KnowledgeCatalogEntry[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface KnowledgeSnapshot {
|
|
41
|
+
generationId?: string;
|
|
42
|
+
rulesPrompt: string;
|
|
43
|
+
memoryPrompt: string;
|
|
44
|
+
catalog: KnowledgeCatalog;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface KnowledgeReviewCursor {
|
|
48
|
+
sessionId: string;
|
|
49
|
+
sessionKey: string;
|
|
50
|
+
sessionFileHash: string;
|
|
51
|
+
projectRoot: string;
|
|
52
|
+
projectKey: string;
|
|
53
|
+
lastReviewedEntryId?: string;
|
|
54
|
+
lastReviewedAt?: string;
|
|
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>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PersistKnowledgeResult {
|
|
71
|
+
saved: number;
|
|
72
|
+
updated: number;
|
|
73
|
+
pending: number;
|
|
74
|
+
skipped: number;
|
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type KnowledgeWorkerInput =
|
|
4
|
+
| { type: "configure"; modelAvailable: boolean; sessionsRoot: string }
|
|
5
|
+
| { type: "review_result"; leaderToken: string; requestId: string; raw?: string; error?: string }
|
|
6
|
+
| { type: "scan_now" }
|
|
7
|
+
| { type: "stop" };
|
|
8
|
+
|
|
9
|
+
export type KnowledgeWorkerOutput =
|
|
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
|
+
}
|
|
@@ -18,3 +18,34 @@ export const CLOUD_RUNTIME_DEFAULTS = Object.freeze({
|
|
|
18
18
|
templates: Object.freeze({ slugMaxLength: 48, nameMaxLength: 80 }),
|
|
19
19
|
terraform: Object.freeze({ executable: "terraform", planFile: "hwcode.tfplan" }),
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Defaults for the cross-workflow knowledge base (`~/.hwcode/knowledge-v3/`).
|
|
24
|
+
*
|
|
25
|
+
* A Worker checks settled sessions on a fixed interval. Short rules are loaded
|
|
26
|
+
* in full; detailed topics are routed through a bounded MEMORY.md index so the
|
|
27
|
+
* prompt cost stays stable as the complete machine catalog grows.
|
|
28
|
+
*/
|
|
29
|
+
export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
|
|
30
|
+
review: Object.freeze({
|
|
31
|
+
intervalMs: 60_000,
|
|
32
|
+
idleMs: 60_000,
|
|
33
|
+
capabilityPollMs: 5_000,
|
|
34
|
+
modelTimeoutMs: 120_000,
|
|
35
|
+
maxDeltaChars: 30_000,
|
|
36
|
+
maxCandidates: 8,
|
|
37
|
+
minimumConfidence: 0.72,
|
|
38
|
+
}),
|
|
39
|
+
storage: Object.freeze({
|
|
40
|
+
maxRuleChars: 600,
|
|
41
|
+
maxRuleFileLines: 40,
|
|
42
|
+
maxRulesPromptChars: 12_000,
|
|
43
|
+
maxMemoryChars: 25_000,
|
|
44
|
+
maxMemoryLines: 200,
|
|
45
|
+
maxSummaryChars: 180,
|
|
46
|
+
maxTitleChars: 100,
|
|
47
|
+
maxKeywordCount: 12,
|
|
48
|
+
maxLookupResults: 5,
|
|
49
|
+
slugMaxChars: 64,
|
|
50
|
+
}),
|
|
51
|
+
});
|
package/.pi/lib/runtime/paths.ts
CHANGED
|
@@ -5,6 +5,15 @@ export const HWCODE_DATA_DIRECTORY = ".hwcode";
|
|
|
5
5
|
export const PROJECT_SDD_SPECS_RELATIVE = `${HWCODE_DATA_DIRECTORY}/specs`;
|
|
6
6
|
export const PROJECT_CLOUD_RUNS_RELATIVE = `${HWCODE_DATA_DIRECTORY}/cloud/runs`;
|
|
7
7
|
|
|
8
|
+
// Canonical subpath layout under ~/.hwcode. Kept together so filesystem topology
|
|
9
|
+
// stays in one place instead of scattered across cloud/knowledge modules.
|
|
10
|
+
const CLOUD_SUBDIR = "cloud";
|
|
11
|
+
const CLOUD_VAULT_FILE = "credentials.enc";
|
|
12
|
+
const CLOUD_KNOWN_HOSTS_FILE = "known_hosts";
|
|
13
|
+
const CLOUD_TEMPLATES_SUBDIR = "templates";
|
|
14
|
+
const CLOUD_TERRAFORM_SUBDIR = "terraform";
|
|
15
|
+
const KNOWLEDGE_SUBDIR = "knowledge-v3";
|
|
16
|
+
|
|
8
17
|
export interface ProjectRuntimePaths {
|
|
9
18
|
root: string;
|
|
10
19
|
hwcode: string;
|
|
@@ -18,14 +27,21 @@ export interface UserRuntimePaths {
|
|
|
18
27
|
cloud: string;
|
|
19
28
|
cloudVault: string;
|
|
20
29
|
cloudKnownHosts: string;
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
cloudTerraformTemplates: string;
|
|
31
|
+
knowledge: string;
|
|
32
|
+
knowledgeGenerations: string;
|
|
33
|
+
knowledgeCurrent: string;
|
|
34
|
+
knowledgeRuntime: string;
|
|
35
|
+
knowledgeCoordinatorSocket: string;
|
|
36
|
+
knowledgeLeader: string;
|
|
37
|
+
knowledgeCommitLock: string;
|
|
38
|
+
knowledgeElectionLock: string;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
export function projectRuntimePaths(projectRoot: string): ProjectRuntimePaths {
|
|
26
42
|
const root = resolve(projectRoot);
|
|
27
43
|
const hwcode = join(root, HWCODE_DATA_DIRECTORY);
|
|
28
|
-
const cloud = join(hwcode,
|
|
44
|
+
const cloud = join(hwcode, CLOUD_SUBDIR);
|
|
29
45
|
return {
|
|
30
46
|
root,
|
|
31
47
|
hwcode,
|
|
@@ -37,25 +53,26 @@ export function projectRuntimePaths(projectRoot: string): ProjectRuntimePaths {
|
|
|
37
53
|
|
|
38
54
|
export function userRuntimePaths(home = homedir()): UserRuntimePaths {
|
|
39
55
|
const root = join(home, HWCODE_DATA_DIRECTORY);
|
|
40
|
-
const cloud = join(root,
|
|
56
|
+
const cloud = join(root, CLOUD_SUBDIR);
|
|
57
|
+
const knowledge = join(root, KNOWLEDGE_SUBDIR);
|
|
41
58
|
return {
|
|
42
59
|
root,
|
|
43
60
|
cloud,
|
|
44
|
-
cloudVault: join(cloud,
|
|
45
|
-
cloudKnownHosts: join(cloud,
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
cloudVault: join(cloud, CLOUD_VAULT_FILE),
|
|
62
|
+
cloudKnownHosts: join(cloud, CLOUD_KNOWN_HOSTS_FILE),
|
|
63
|
+
cloudTerraformTemplates: join(cloud, CLOUD_TEMPLATES_SUBDIR, CLOUD_TERRAFORM_SUBDIR),
|
|
64
|
+
knowledge,
|
|
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"),
|
|
48
73
|
};
|
|
49
74
|
}
|
|
50
75
|
|
|
51
|
-
export function legacyUserCloudTemplatePaths(home = homedir()): {
|
|
52
|
-
prompts: string;
|
|
53
|
-
deployments: string;
|
|
54
|
-
} {
|
|
55
|
-
const cloud = join(home, HWCODE_DATA_DIRECTORY, "cloud");
|
|
56
|
-
return { prompts: join(cloud, "prompts"), deployments: join(cloud, "templates") };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
76
|
export function remoteRunnerPaths(user: string): { root: string; runs: string } {
|
|
60
77
|
const root = posix.join("/home", user, HWCODE_DATA_DIRECTORY);
|
|
61
78
|
return { root, runs: posix.join(root, "runs") };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function toolError(message: string, details: Record<string, unknown> = {}) {
|
|
2
|
+
return { content: [{ type: "text" as const, text: message }], isError: true, details };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function toolOk(message: string, details: Record<string, unknown> = {}) {
|
|
6
|
+
return { content: [{ type: "text" as const, text: message }], details };
|
|
7
|
+
}
|