@hadooppei/hwcode 1.0.6 → 1.0.8

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 (35) 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 +279 -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 +64 -0
  19. package/.pi/lib/knowledge/sanitize.ts +26 -0
  20. package/.pi/lib/knowledge/store.ts +251 -0
  21. package/.pi/lib/knowledge/types.ts +59 -0
  22. package/.pi/lib/knowledge/worker-protocol.ts +12 -0
  23. package/.pi/lib/runtime/defaults.ts +28 -0
  24. package/.pi/lib/runtime/paths.ts +28 -16
  25. package/.pi/lib/tool-result.ts +7 -0
  26. package/.pi/lib/workflows/cloud/bundles.ts +73 -40
  27. package/.pi/lib/workflows/cloud/workspace.ts +6 -0
  28. package/.pi/lib/workflows/state.ts +6 -26
  29. package/.pi/skills/hwcode-cloud/SKILL.md +2 -2
  30. package/README.md +33 -31
  31. package/bin/hwcode.js +2 -3
  32. package/package.json +1 -1
  33. package/.pi/extensions/workflows/cloud/shared.ts +0 -224
  34. package/.pi/lib/workflows/cloud/template-save.ts +0 -108
  35. 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,64 @@
1
+ import { parentPort } from "node:worker_threads";
2
+
3
+ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
+ import { parseCandidateEnvelope } from "./extractor.ts";
5
+ import { persistKnowledgeCandidates } from "./store.ts";
6
+ import type { KnowledgeWorkerInput, KnowledgeWorkerOutput } from "./worker-protocol.ts";
7
+
8
+ if (!parentPort) throw new Error("Knowledge review worker requires a parent port");
9
+
10
+ let sessionId = "";
11
+ let projectKey = "";
12
+ let dirty = false;
13
+ let settled = false;
14
+ let reviewing = false;
15
+ let activeRequestId = "";
16
+
17
+ function send(message: KnowledgeWorkerOutput): void {
18
+ parentPort!.postMessage(message);
19
+ }
20
+
21
+ function checkForReview(): void {
22
+ if (!sessionId || !projectKey || !dirty || !settled || reviewing) return;
23
+ reviewing = true;
24
+ activeRequestId = `${sessionId}-${Date.now()}`;
25
+ send({ type: "review_due", requestId: activeRequestId });
26
+ }
27
+
28
+ const timer = setInterval(checkForReview, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
29
+ timer.unref();
30
+
31
+ parentPort.on("message", (message: KnowledgeWorkerInput) => {
32
+ if (message.type === "configure") {
33
+ sessionId = message.sessionId;
34
+ projectKey = message.projectKey;
35
+ dirty = message.dirty;
36
+ settled = true;
37
+ reviewing = false;
38
+ activeRequestId = "";
39
+ return;
40
+ }
41
+ if (message.type === "activity") {
42
+ settled = message.state === "settled";
43
+ if (message.dirty !== undefined) dirty = message.dirty;
44
+ return;
45
+ }
46
+ if (message.type === "review_result") {
47
+ if (!reviewing || message.requestId !== activeRequestId) return;
48
+ try {
49
+ if (message.error || message.raw === undefined) throw new Error(message.error || "Knowledge review returned no content");
50
+ const candidates = parseCandidateEnvelope(message.raw);
51
+ const result = persistKnowledgeCandidates(candidates, projectKey);
52
+ dirty = false;
53
+ send({ type: "review_saved", requestId: message.requestId, result });
54
+ } catch (error) {
55
+ send({ type: "review_failed", requestId: message.requestId, error: error instanceof Error ? error.message : String(error) });
56
+ } finally {
57
+ reviewing = false;
58
+ activeRequestId = "";
59
+ }
60
+ return;
61
+ }
62
+ clearInterval(timer);
63
+ process.exit(0);
64
+ });
@@ -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,251 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
5
+
6
+ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
7
+ import { userRuntimePaths } from "../runtime/paths.ts";
8
+ import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
9
+ import type {
10
+ KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeScope,
11
+ KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
12
+ } from "./types.ts";
13
+
14
+ const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
15
+ const EMPTY_CATALOG = (): KnowledgeCatalog => ({ version: 2, updatedAt: new Date().toISOString(), items: [] });
16
+ const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
17
+
18
+ function ensureDirectory(path: string): void {
19
+ mkdirSync(path, { recursive: true, mode: 0o700 });
20
+ chmodSync(path, 0o700);
21
+ }
22
+
23
+ function atomicWrite(path: string, content: string): void {
24
+ ensureDirectory(dirname(path));
25
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
26
+ writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
27
+ renameSync(temporary, path);
28
+ chmodSync(path, 0o600);
29
+ }
30
+
31
+ export function ensureKnowledgeDirectories(home = homedir()): void {
32
+ const paths = userRuntimePaths(home);
33
+ for (const path of [paths.knowledge, paths.knowledgeRules, paths.knowledgeTopics, paths.knowledgePending]) {
34
+ ensureDirectory(path);
35
+ }
36
+ }
37
+
38
+ export function projectKnowledgeKey(projectRoot: string): string {
39
+ return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 16);
40
+ }
41
+
42
+ export function loadKnowledgeCatalog(home = homedir()): KnowledgeCatalog {
43
+ const path = userRuntimePaths(home).knowledgeCatalog;
44
+ if (!existsSync(path)) return EMPTY_CATALOG();
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as KnowledgeCatalog;
47
+ if (parsed.version === 2 && Array.isArray(parsed.items)) return parsed;
48
+ } catch {
49
+ // The active schema never falls back to legacy index.json or records/ data.
50
+ }
51
+ return EMPTY_CATALOG();
52
+ }
53
+
54
+ function saveCatalog(catalog: KnowledgeCatalog, home: string): void {
55
+ catalog.updatedAt = new Date().toISOString();
56
+ atomicWrite(userRuntimePaths(home).knowledgeCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
57
+ }
58
+
59
+ function normalizeSlug(value: string): string {
60
+ return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/gu, "-")
61
+ .replace(/^-+|-+$/gu, "").slice(0, STORAGE.slugMaxChars) || "knowledge";
62
+ }
63
+
64
+ function normalizedFingerprint(candidate: KnowledgeCandidate): string {
65
+ return createHash("sha256").update(`${candidate.scope}\0${candidate.key.trim().toLowerCase()}`).digest("hex");
66
+ }
67
+
68
+ function contentHash(candidate: KnowledgeCandidate): string {
69
+ return createHash("sha256").update(`${candidate.title}\0${candidate.summary}\0${candidate.body}`).digest("hex");
70
+ }
71
+
72
+ function asStringArray(value: unknown, maxItems: number): string[] {
73
+ if (!Array.isArray(value)) return [];
74
+ return [...new Set(value.filter((item): item is string => typeof item === "string")
75
+ .map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
76
+ }
77
+
78
+ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandidate | undefined {
79
+ if (!value || typeof value !== "object") return undefined;
80
+ const raw = value as Record<string, unknown>;
81
+ if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
82
+ || typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
83
+ if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
84
+ const storageHint: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
85
+ const explicitUserDirective = raw.explicitUserDirective === true;
86
+ const requestedScope = raw.scope === "global" ? "global" : `project:${projectKey}`;
87
+ const scope: KnowledgeScope = requestedScope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
88
+ const body = compactKnowledgeText(raw.body, 20_000);
89
+ const evidence = asStringArray(raw.evidence, 8);
90
+ if (!body || evidence.length === 0) return undefined;
91
+ const ruleEligible = body.length <= STORAGE.maxRuleChars
92
+ && body.split("\n").length <= STORAGE.maxRuleFileLines
93
+ && (explicitUserDirective || raw.confidence >= 0.9);
94
+ return {
95
+ key: compactKnowledgeText(raw.key, 160),
96
+ title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
97
+ summary: compactKnowledgeText(raw.summary, STORAGE.maxSummaryChars),
98
+ keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
99
+ scope,
100
+ body,
101
+ evidence,
102
+ confidence: Math.min(1, raw.confidence),
103
+ storageHint: storageHint === "rule" && ruleEligible ? "rule" : "topic",
104
+ action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
105
+ explicitUserDirective,
106
+ };
107
+ }
108
+
109
+ function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
110
+ if (candidate.storageHint === "rule") return `# ${candidate.title}\n\n${candidate.body}\n`;
111
+ 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
+ "",
124
+ ].join("\n");
125
+ }
126
+
127
+ function storedRuleCharacters(home: string): number {
128
+ const directory = userRuntimePaths(home).knowledgeRules;
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`);
139
+ }
140
+
141
+ function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string {
142
+ const lines = [
143
+ "# HWCode Knowledge Index",
144
+ "",
145
+ "Detailed topics are loaded only through hwcode_knowledge_lookup. Search by ID or keywords.",
146
+ "",
147
+ ];
148
+ const topics = catalog.items.filter((item) => item.track === "topic" && (!projectKey || applicable(item, projectKey)))
149
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
150
+ for (const item of topics) {
151
+ const scope = item.scope === "global" ? "global" : "project";
152
+ const line = `- [${item.id}] (${scope}; ${item.keywords.join(", ")}) ${item.title}: ${item.summary}`;
153
+ if (lines.length + 1 >= STORAGE.maxMemoryLines || [...lines, line].join("\n").length > STORAGE.maxMemoryChars) {
154
+ lines.push("- Additional topics remain searchable through hwcode_knowledge_lookup(query: \"keywords\").");
155
+ break;
156
+ }
157
+ lines.push(line);
158
+ }
159
+ return `${lines.join("\n")}\n`;
160
+ }
161
+
162
+ function writeMemory(catalog: KnowledgeCatalog, home: string): void {
163
+ atomicWrite(userRuntimePaths(home).knowledgeMemory, generateMemory(catalog));
164
+ }
165
+
166
+ export function persistKnowledgeCandidates(
167
+ values: unknown[],
168
+ projectKey: string,
169
+ home = homedir(),
170
+ ): PersistKnowledgeResult {
171
+ ensureKnowledgeDirectories(home);
172
+ const catalog = loadKnowledgeCatalog(home);
173
+ const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
174
+ for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
175
+ const candidate = normalizeCandidate(value, projectKey);
176
+ if (!candidate) { result.skipped++; continue; }
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;
186
+ }
187
+ if (existing && !(candidate.action === "revise" && (candidate.explicitUserDirective || candidate.confidence >= 0.92))) {
188
+ savePending(candidate, "conflicting-or-ambiguous-update", home);
189
+ result.pending++;
190
+ continue;
191
+ }
192
+ if (!existing && candidate.storageHint === "rule"
193
+ && storedRuleCharacters(home) + renderKnowledgeFile(candidate).length > STORAGE.maxRulesPromptChars) {
194
+ candidate.storageHint = "topic";
195
+ }
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
+ }
212
+ saveCatalog(catalog, home);
213
+ writeMemory(catalog, home);
214
+ return result;
215
+ }
216
+
217
+ function applicable(entry: KnowledgeCatalogEntry, projectKey: string): boolean {
218
+ return entry.scope === "global" || entry.scope === `project:${projectKey}`;
219
+ }
220
+
221
+ function readCatalogFile(entry: KnowledgeCatalogEntry, home: string): string | undefined {
222
+ if (!SAFE_ID_RE.test(entry.id) || basename(entry.file) !== `${entry.id}.md`) return undefined;
223
+ const root = resolve(userRuntimePaths(home).knowledge);
224
+ const path = resolve(root, entry.file);
225
+ if (!path.startsWith(`${root}${sep}`) || !existsSync(path)) return undefined;
226
+ return sanitizeKnowledgeText(readFileSync(path, "utf8"));
227
+ }
228
+
229
+ export function loadKnowledgeSnapshot(projectKey: string, home = homedir()): KnowledgeSnapshot {
230
+ ensureKnowledgeDirectories(home);
231
+ const catalog = loadKnowledgeCatalog(home);
232
+ const ruleParts: string[] = [];
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());
240
+ }
241
+ const memoryPrompt = generateMemory(catalog, projectKey);
242
+ return { rulesPrompt: ruleParts.join("\n\n"), memoryPrompt, catalog };
243
+ }
244
+
245
+ export function loadKnowledgeById(id: string, projectKey: string, home = homedir()): { entry: KnowledgeCatalogEntry; content: string } | undefined {
246
+ if (!SAFE_ID_RE.test(id)) return undefined;
247
+ const entry = loadKnowledgeCatalog(home).items.find((item) => item.id === id && applicable(item, projectKey));
248
+ if (!entry) return undefined;
249
+ const content = readCatalogFile(entry, home);
250
+ return content ? { entry, content } : undefined;
251
+ }
@@ -0,0 +1,59 @@
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: 2;
36
+ updatedAt: string;
37
+ items: KnowledgeCatalogEntry[];
38
+ }
39
+
40
+ export interface KnowledgeSnapshot {
41
+ rulesPrompt: string;
42
+ memoryPrompt: string;
43
+ catalog: KnowledgeCatalog;
44
+ }
45
+
46
+ export interface KnowledgeReviewState {
47
+ version: 1;
48
+ lastReviewedEntryId?: string;
49
+ lastReviewedAt?: string;
50
+ lastDeltaDigest?: string;
51
+ lastResult?: "saved" | "noop";
52
+ }
53
+
54
+ export interface PersistKnowledgeResult {
55
+ saved: number;
56
+ updated: number;
57
+ pending: number;
58
+ skipped: number;
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { PersistKnowledgeResult } from "./types.ts";
2
+
3
+ export type KnowledgeWorkerInput =
4
+ | { type: "configure"; sessionId: string; projectKey: string; dirty: boolean }
5
+ | { type: "activity"; state: "busy" | "settled"; dirty?: boolean }
6
+ | { type: "review_result"; requestId: string; raw?: string; error?: string }
7
+ | { type: "stop" };
8
+
9
+ export type KnowledgeWorkerOutput =
10
+ | { type: "review_due"; requestId: string }
11
+ | { type: "review_saved"; requestId: string; result: PersistKnowledgeResult }
12
+ | { type: "review_failed"; requestId: string; error: string };
@@ -18,3 +18,31 @@ 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/`).
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
+ maxDeltaChars: 30_000,
33
+ maxCandidates: 3,
34
+ minimumConfidence: 0.72,
35
+ }),
36
+ storage: Object.freeze({
37
+ maxRuleChars: 600,
38
+ maxRuleFileLines: 40,
39
+ maxRulesPromptChars: 12_000,
40
+ maxMemoryChars: 25_000,
41
+ maxMemoryLines: 200,
42
+ maxSummaryChars: 180,
43
+ maxTitleChars: 100,
44
+ maxKeywordCount: 12,
45
+ maxLookupResults: 5,
46
+ slugMaxChars: 64,
47
+ }),
48
+ });