@holt-os/holt 0.9.0 → 0.11.0
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/README.md +79 -10
- package/dist/cli.js +2132 -1133
- package/package.json +2 -1
- package/skills/action-items/SKILL.md +31 -0
- package/skills/brief/SKILL.md +25 -0
- package/skills/code-review/SKILL.md +24 -0
- package/skills/commit/SKILL.md +26 -0
- package/skills/decide/SKILL.md +28 -0
- package/skills/explain/SKILL.md +24 -0
- package/skills/explain-code/SKILL.md +27 -0
- package/skills/plan/SKILL.md +29 -0
- package/skills/rewrite/SKILL.md +27 -0
- package/skills/summarize/SKILL.md +27 -0
package/dist/cli.js
CHANGED
|
@@ -41,11 +41,11 @@ function createReader() {
|
|
|
41
41
|
closed = true;
|
|
42
42
|
while (waiters.length) waiters.shift()(null);
|
|
43
43
|
});
|
|
44
|
-
const ask = (q) => new Promise((
|
|
44
|
+
const ask = (q) => new Promise((resolve8) => {
|
|
45
45
|
if (q) process.stdout.write(q);
|
|
46
|
-
if (buffer.length)
|
|
47
|
-
else if (closed)
|
|
48
|
-
else waiters.push(
|
|
46
|
+
if (buffer.length) resolve8(buffer.shift());
|
|
47
|
+
else if (closed) resolve8(null);
|
|
48
|
+
else waiters.push(resolve8);
|
|
49
49
|
});
|
|
50
50
|
return { ask, close: () => rl.close() };
|
|
51
51
|
}
|
|
@@ -243,12 +243,12 @@ function renderPrompt(history, message, memory = []) {
|
|
|
243
243
|
return parts.join("\n");
|
|
244
244
|
}
|
|
245
245
|
function runBrain(brain, prompt, onChunk) {
|
|
246
|
-
return new Promise((
|
|
246
|
+
return new Promise((resolve8) => {
|
|
247
247
|
let child;
|
|
248
248
|
try {
|
|
249
249
|
child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
|
|
250
250
|
} catch (e) {
|
|
251
|
-
|
|
251
|
+
resolve8({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
|
|
252
252
|
return;
|
|
253
253
|
}
|
|
254
254
|
let out = "";
|
|
@@ -261,11 +261,11 @@ function runBrain(brain, prompt, onChunk) {
|
|
|
261
261
|
child.stderr.on("data", (d) => {
|
|
262
262
|
err2 += d.toString();
|
|
263
263
|
});
|
|
264
|
-
child.on("error", (e) =>
|
|
264
|
+
child.on("error", (e) => resolve8({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
|
|
265
265
|
child.on("close", (code) => {
|
|
266
266
|
const text = out.trim();
|
|
267
|
-
if (code === 0 && text)
|
|
268
|
-
else
|
|
267
|
+
if (code === 0 && text) resolve8({ ok: true, text });
|
|
268
|
+
else resolve8({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
}
|
|
@@ -435,16 +435,16 @@ function currentAlias() {
|
|
|
435
435
|
// src/install.ts
|
|
436
436
|
import { spawn as spawn2 } from "child_process";
|
|
437
437
|
function runInteractive(cmd, args) {
|
|
438
|
-
return new Promise((
|
|
438
|
+
return new Promise((resolve8) => {
|
|
439
439
|
let child;
|
|
440
440
|
try {
|
|
441
441
|
child = spawn2(cmd, args, { stdio: "inherit" });
|
|
442
442
|
} catch {
|
|
443
|
-
|
|
443
|
+
resolve8(-1);
|
|
444
444
|
return;
|
|
445
445
|
}
|
|
446
|
-
child.on("error", () =>
|
|
447
|
-
child.on("close", (code) =>
|
|
446
|
+
child.on("error", () => resolve8(-1));
|
|
447
|
+
child.on("close", (code) => resolve8(code ?? -1));
|
|
448
448
|
});
|
|
449
449
|
}
|
|
450
450
|
|
|
@@ -1032,9 +1032,15 @@ async function runApiBrain(brain, prompt, onChunk) {
|
|
|
1032
1032
|
|
|
1033
1033
|
// src/skills.ts
|
|
1034
1034
|
import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync6, statSync as statSync2 } from "fs";
|
|
1035
|
-
import { join as join6 } from "path";
|
|
1035
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
1036
|
+
import { fileURLToPath } from "url";
|
|
1037
|
+
function builtinSkillsRoot() {
|
|
1038
|
+
return join6(dirname2(fileURLToPath(import.meta.url)), "..", "skills");
|
|
1039
|
+
}
|
|
1036
1040
|
function skillsRoot(scope) {
|
|
1037
|
-
|
|
1041
|
+
if (scope === "workspace") return join6(wsHoltDir(), "skills");
|
|
1042
|
+
if (scope === "builtin") return builtinSkillsRoot();
|
|
1043
|
+
return join6(GLOBAL_DIR, "skills");
|
|
1038
1044
|
}
|
|
1039
1045
|
function sanitizeName(name) {
|
|
1040
1046
|
return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
@@ -1114,6 +1120,7 @@ function listScope(scope) {
|
|
|
1114
1120
|
}
|
|
1115
1121
|
function listSkills() {
|
|
1116
1122
|
const byName = /* @__PURE__ */ new Map();
|
|
1123
|
+
for (const s of listScope("builtin")) byName.set(s.name, s);
|
|
1117
1124
|
for (const s of listScope("global")) byName.set(s.name, s);
|
|
1118
1125
|
for (const s of listScope("workspace")) byName.set(s.name, s);
|
|
1119
1126
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -2224,1086 +2231,1123 @@ async function extractAndSaveFacts(active, cfg, history, session) {
|
|
|
2224
2231
|
}
|
|
2225
2232
|
}
|
|
2226
2233
|
|
|
2227
|
-
// src/
|
|
2228
|
-
import {
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2234
|
+
// src/wiki.ts
|
|
2235
|
+
import {
|
|
2236
|
+
readFileSync as readFileSync9,
|
|
2237
|
+
writeFileSync as writeFileSync7,
|
|
2238
|
+
existsSync as existsSync8,
|
|
2239
|
+
mkdirSync as mkdirSync7,
|
|
2240
|
+
rmSync as rmSync4,
|
|
2241
|
+
readdirSync as readdirSync2,
|
|
2242
|
+
statSync as statSync3
|
|
2243
|
+
} from "fs";
|
|
2244
|
+
import { join as join8 } from "path";
|
|
2245
|
+
import { totalmem } from "os";
|
|
2246
|
+
|
|
2247
|
+
// src/localmodel.ts
|
|
2248
|
+
var OLLAMA_URL2 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
|
|
2249
|
+
async function localModelStatus(model) {
|
|
2250
|
+
try {
|
|
2251
|
+
const res = await fetch(`${OLLAMA_URL2}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
2252
|
+
if (!res.ok) return { reachable: false, hasModel: false };
|
|
2253
|
+
const data = await res.json();
|
|
2254
|
+
const want = model.includes(":") ? model : `${model}:latest`;
|
|
2255
|
+
const hasModel = !!data.models?.some((m) => {
|
|
2256
|
+
const n = m.name || "";
|
|
2257
|
+
return n === model || n === want || n.startsWith(`${model}:`);
|
|
2258
|
+
});
|
|
2259
|
+
return { reachable: true, hasModel };
|
|
2260
|
+
} catch {
|
|
2261
|
+
return { reachable: false, hasModel: false };
|
|
2262
|
+
}
|
|
2252
2263
|
}
|
|
2253
|
-
function
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
}
|
|
2270
|
-
};
|
|
2271
|
-
while (i < lines.length) {
|
|
2272
|
-
const line = lines[i] ?? "";
|
|
2273
|
-
const fence = line.match(/^```(.*)$/);
|
|
2274
|
-
if (fence) {
|
|
2275
|
-
flushParagraph();
|
|
2276
|
-
closeList();
|
|
2277
|
-
const code = [];
|
|
2278
|
-
i++;
|
|
2279
|
-
while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
|
|
2280
|
-
code.push(lines[i] ?? "");
|
|
2281
|
-
i++;
|
|
2282
|
-
}
|
|
2283
|
-
i++;
|
|
2284
|
-
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
2285
|
-
continue;
|
|
2286
|
-
}
|
|
2287
|
-
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
2288
|
-
if (h) {
|
|
2289
|
-
flushParagraph();
|
|
2290
|
-
closeList();
|
|
2291
|
-
const level = (h[1] ?? "#").length;
|
|
2292
|
-
out.push(`<h${level}>${renderInline((h[2] ?? "").trim())}</h${level}>`);
|
|
2293
|
-
i++;
|
|
2294
|
-
continue;
|
|
2295
|
-
}
|
|
2296
|
-
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
2297
|
-
if (li) {
|
|
2298
|
-
flushParagraph();
|
|
2299
|
-
if (!listOpen) {
|
|
2300
|
-
out.push("<ul>");
|
|
2301
|
-
listOpen = true;
|
|
2302
|
-
}
|
|
2303
|
-
out.push(`<li>${renderInline((li[1] ?? "").trim())}</li>`);
|
|
2304
|
-
i++;
|
|
2305
|
-
continue;
|
|
2306
|
-
}
|
|
2307
|
-
if (line.trim() === "") {
|
|
2308
|
-
flushParagraph();
|
|
2309
|
-
closeList();
|
|
2310
|
-
i++;
|
|
2311
|
-
continue;
|
|
2264
|
+
async function localGenerate(model, prompt, timeoutMs = 12e4) {
|
|
2265
|
+
let res;
|
|
2266
|
+
try {
|
|
2267
|
+
res = await fetch(`${OLLAMA_URL2}/api/generate`, {
|
|
2268
|
+
method: "POST",
|
|
2269
|
+
headers: { "content-type": "application/json" },
|
|
2270
|
+
body: JSON.stringify({ model, prompt, stream: false }),
|
|
2271
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2272
|
+
});
|
|
2273
|
+
} catch (e) {
|
|
2274
|
+
return { ok: false, text: `Could not reach local Ollama at ${OLLAMA_URL2}: ${e.message}` };
|
|
2275
|
+
}
|
|
2276
|
+
if (!res.ok) {
|
|
2277
|
+
let body = "";
|
|
2278
|
+
try {
|
|
2279
|
+
body = await res.text();
|
|
2280
|
+
} catch {
|
|
2312
2281
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2282
|
+
return { ok: false, text: `Ollama error ${res.status}: ${body.slice(0, 200)}` };
|
|
2283
|
+
}
|
|
2284
|
+
try {
|
|
2285
|
+
const data = await res.json();
|
|
2286
|
+
const text = (data.response || "").trim();
|
|
2287
|
+
if (text) return { ok: true, text };
|
|
2288
|
+
return { ok: false, text: "Local model returned an empty reply." };
|
|
2289
|
+
} catch (e) {
|
|
2290
|
+
return { ok: false, text: `Could not parse Ollama reply: ${e.message}` };
|
|
2316
2291
|
}
|
|
2317
|
-
flushParagraph();
|
|
2318
|
-
closeList();
|
|
2319
|
-
return out.join("\n");
|
|
2320
2292
|
}
|
|
2321
|
-
function
|
|
2322
|
-
return
|
|
2323
|
-
<html lang="en">
|
|
2324
|
-
<head>
|
|
2325
|
-
<meta charset="utf-8">
|
|
2326
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2327
|
-
<title>${escapeHtml(title)}</title>
|
|
2328
|
-
<style>
|
|
2329
|
-
:root { --bg:#0f1115; --fg:#e7e9ee; --accent:#f0b91e; }
|
|
2330
|
-
* { box-sizing: border-box; }
|
|
2331
|
-
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
2332
|
-
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
|
|
2333
|
-
main { max-width: 760px; margin: 0 auto; }
|
|
2334
|
-
h1,h2,h3 { color:var(--accent); line-height:1.25; margin:1.4em 0 .5em; }
|
|
2335
|
-
h1 { font-size:1.6rem; } h2 { font-size:1.3rem; } h3 { font-size:1.1rem; }
|
|
2336
|
-
a { color:var(--accent); }
|
|
2337
|
-
code { background:#1b1e26; padding:.1em .35em; border-radius:4px; font-size:.9em; }
|
|
2338
|
-
pre { background:#1b1e26; padding:1rem; border-radius:8px; overflow:auto; }
|
|
2339
|
-
pre code { background:none; padding:0; }
|
|
2340
|
-
ul { padding-left:1.3rem; }
|
|
2341
|
-
p { margin:.7em 0; }
|
|
2342
|
-
</style>
|
|
2343
|
-
</head>
|
|
2344
|
-
<body>
|
|
2345
|
-
<main>
|
|
2346
|
-
${body}
|
|
2347
|
-
</main>
|
|
2348
|
-
</body>
|
|
2349
|
-
</html>
|
|
2350
|
-
`;
|
|
2293
|
+
function pullHint(model) {
|
|
2294
|
+
return `ollama pull ${model}`;
|
|
2351
2295
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2296
|
+
|
|
2297
|
+
// src/wiki.ts
|
|
2298
|
+
function wikiDir() {
|
|
2299
|
+
return join8(wsHoltDir(), "wiki");
|
|
2355
2300
|
}
|
|
2356
|
-
function
|
|
2357
|
-
|
|
2358
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
2359
|
-
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
2301
|
+
function wikiStatePath() {
|
|
2302
|
+
return join8(wikiDir(), ".state.json");
|
|
2360
2303
|
}
|
|
2361
|
-
function
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2304
|
+
function wikiIndexPath() {
|
|
2305
|
+
return join8(wikiDir(), "index.md");
|
|
2306
|
+
}
|
|
2307
|
+
function pagePath(slug) {
|
|
2308
|
+
return join8(wikiDir(), `${slug}.md`);
|
|
2309
|
+
}
|
|
2310
|
+
function loadState() {
|
|
2311
|
+
try {
|
|
2312
|
+
const s = JSON.parse(readFileSync9(wikiStatePath(), "utf8"));
|
|
2313
|
+
return { lastSyncTs: typeof s.lastSyncTs === "number" ? s.lastSyncTs : 0, updatedAt: s.updatedAt ?? 0 };
|
|
2314
|
+
} catch {
|
|
2315
|
+
return { lastSyncTs: 0, updatedAt: 0 };
|
|
2367
2316
|
}
|
|
2368
|
-
if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
|
|
2369
|
-
const path = join8(process.cwd(), base);
|
|
2370
|
-
writeFileSync7(path, renderReply(reply, format), "utf8");
|
|
2371
|
-
return path;
|
|
2372
2317
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
const live = Math.min(history.length, MAX_REPLAY_TURNS);
|
|
2377
|
-
const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
|
|
2378
|
-
const pct = Math.round(frac * 100);
|
|
2379
|
-
const mem = memStats().turns;
|
|
2380
|
-
return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
|
|
2318
|
+
function saveState(s) {
|
|
2319
|
+
mkdirSync7(wikiDir(), { recursive: true });
|
|
2320
|
+
writeFileSync7(wikiStatePath(), JSON.stringify(s, null, 2) + "\n", "utf8");
|
|
2381
2321
|
}
|
|
2382
|
-
function
|
|
2383
|
-
|
|
2384
|
-
" commands:",
|
|
2385
|
-
" /brain [name] switch brain (CLI or API). context is kept.",
|
|
2386
|
-
" /memory [query] memory stats, or preview what a query would recall",
|
|
2387
|
-
' /skill [name] [input] run a skill, or list them. "holt skill" manages them.',
|
|
2388
|
-
" /output [fmt] show or set output format: markdown | html",
|
|
2389
|
-
" /save [name] save the last reply to a file in this folder",
|
|
2390
|
-
" /setting configure brains, API brains, and your launch command",
|
|
2391
|
-
" /clear forget this session so far (saved memory stays)",
|
|
2392
|
-
" /help this list",
|
|
2393
|
-
" /exit leave"
|
|
2394
|
-
].join("\n")));
|
|
2322
|
+
function saveSyncMarker(ts) {
|
|
2323
|
+
saveState({ lastSyncTs: ts, updatedAt: Date.now() });
|
|
2395
2324
|
}
|
|
2396
|
-
function
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2325
|
+
function isPageFile(name) {
|
|
2326
|
+
return name.endsWith(".md") && name !== "index.md";
|
|
2327
|
+
}
|
|
2328
|
+
function listPageSlugs() {
|
|
2329
|
+
const dir = wikiDir();
|
|
2330
|
+
if (!existsSync8(dir)) return [];
|
|
2331
|
+
try {
|
|
2332
|
+
return readdirSync2(dir).filter(isPageFile).map((f) => f.slice(0, -3)).sort();
|
|
2333
|
+
} catch {
|
|
2334
|
+
return [];
|
|
2400
2335
|
}
|
|
2401
|
-
const api = findApiBrain(cfg, id);
|
|
2402
|
-
if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
|
|
2403
|
-
return null;
|
|
2404
2336
|
}
|
|
2405
|
-
|
|
2406
|
-
const
|
|
2407
|
-
if (!
|
|
2408
|
-
|
|
2409
|
-
|
|
2337
|
+
function readPage(slug) {
|
|
2338
|
+
const p = pagePath(slug);
|
|
2339
|
+
if (!existsSync8(p)) return null;
|
|
2340
|
+
let raw = "";
|
|
2341
|
+
try {
|
|
2342
|
+
raw = readFileSync9(p, "utf8");
|
|
2343
|
+
} catch {
|
|
2344
|
+
return null;
|
|
2410
2345
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2346
|
+
const { data, body } = parseFrontmatter(raw);
|
|
2347
|
+
return {
|
|
2348
|
+
slug,
|
|
2349
|
+
title: data.title || slug,
|
|
2350
|
+
updated: data.updated || "",
|
|
2351
|
+
sources: parseSources(data.sources),
|
|
2352
|
+
body
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
function loadPages() {
|
|
2356
|
+
const out = [];
|
|
2357
|
+
for (const slug of listPageSlugs()) {
|
|
2358
|
+
const pg = readPage(slug);
|
|
2359
|
+
if (pg) out.push(pg);
|
|
2422
2360
|
}
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2361
|
+
return out;
|
|
2362
|
+
}
|
|
2363
|
+
function parseSources(raw) {
|
|
2364
|
+
if (!raw) return [];
|
|
2365
|
+
return raw.replace(/^\[|\]$/g, "").split(/[\s,]+/).map((s) => s.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
2366
|
+
}
|
|
2367
|
+
var PAGE_HEADER_NOTE = "Derived page, maintained by Holt. Safe to edit; a rebuild will regenerate it from facts.";
|
|
2368
|
+
function renderPage(pg) {
|
|
2369
|
+
const fm = [
|
|
2370
|
+
"---",
|
|
2371
|
+
`title: ${pg.title}`,
|
|
2372
|
+
`updated: ${pg.updated}`,
|
|
2373
|
+
`sources: ${pg.sources.join(", ")}`,
|
|
2374
|
+
"---",
|
|
2375
|
+
"",
|
|
2376
|
+
`> ${PAGE_HEADER_NOTE}`,
|
|
2377
|
+
"",
|
|
2378
|
+
pg.body.trim(),
|
|
2379
|
+
""
|
|
2380
|
+
].join("\n");
|
|
2381
|
+
return fm;
|
|
2382
|
+
}
|
|
2383
|
+
function writePage(pg) {
|
|
2384
|
+
mkdirSync7(wikiDir(), { recursive: true });
|
|
2385
|
+
writeFileSync7(pagePath(pg.slug), renderPage(pg), "utf8");
|
|
2386
|
+
}
|
|
2387
|
+
var WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
2388
|
+
function extractLinks(body) {
|
|
2389
|
+
const out = [];
|
|
2390
|
+
let m;
|
|
2391
|
+
WIKILINK_RE.lastIndex = 0;
|
|
2392
|
+
while ((m = WIKILINK_RE.exec(body)) !== null) {
|
|
2393
|
+
const target = (m[1] || "").split("|")[0].trim();
|
|
2394
|
+
if (target) out.push(target);
|
|
2432
2395
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2396
|
+
return out;
|
|
2397
|
+
}
|
|
2398
|
+
function cosine2(a, b) {
|
|
2399
|
+
let dot = 0;
|
|
2400
|
+
let na = 0;
|
|
2401
|
+
let nb = 0;
|
|
2402
|
+
const n = Math.min(a.length, b.length);
|
|
2403
|
+
for (let i = 0; i < n; i++) {
|
|
2404
|
+
const x = a[i];
|
|
2405
|
+
const y = b[i];
|
|
2406
|
+
dot += x * y;
|
|
2407
|
+
na += x * x;
|
|
2408
|
+
nb += y * y;
|
|
2444
2409
|
}
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
if (
|
|
2454
|
-
const inv = resolveSkillInvocation(line.replace(/^\/skills\b/, "/skill"));
|
|
2455
|
-
if (!inv) {
|
|
2456
|
-
console.log(c.dim(' no such skill. Try "holt skill list".'));
|
|
2457
|
-
continue;
|
|
2458
|
-
}
|
|
2459
|
-
promptOverride = inv.prompt;
|
|
2460
|
-
console.log(c.dim(` running skill "${inv.skillName}"...`));
|
|
2461
|
-
} else if (line.startsWith("/")) {
|
|
2462
|
-
const parts = line.slice(1).split(/\s+/);
|
|
2463
|
-
const cmd = (parts[0] || "").toLowerCase();
|
|
2464
|
-
const rest = parts.slice(1).join(" ");
|
|
2465
|
-
const arg = (parts[1] || "").toLowerCase();
|
|
2466
|
-
if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
|
|
2467
|
-
if (cmd === "help" || cmd === "h") {
|
|
2468
|
-
help();
|
|
2469
|
-
continue;
|
|
2470
|
-
}
|
|
2471
|
-
if (cmd === "clear") {
|
|
2472
|
-
history.length = 0;
|
|
2473
|
-
console.log(c.dim(" session context cleared. Saved memory is untouched."));
|
|
2474
|
-
continue;
|
|
2475
|
-
}
|
|
2476
|
-
if (cmd === "memory" || cmd === "mem") {
|
|
2477
|
-
if (rest) {
|
|
2478
|
-
const hits = await recall(rest, session, 5);
|
|
2479
|
-
if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
|
|
2480
|
-
else for (const h of hits) console.log(c.dim(` ${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.slice(0, 110).replace(/\s+/g, " ")}`));
|
|
2481
|
-
} else {
|
|
2482
|
-
const s = memStats();
|
|
2483
|
-
console.log(c.dim(` ${s.turns} moments, ${s.facts} facts, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
|
|
2484
|
-
console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
|
|
2485
|
-
}
|
|
2486
|
-
continue;
|
|
2487
|
-
}
|
|
2488
|
-
if (cmd === "output") {
|
|
2489
|
-
const want = arg;
|
|
2490
|
-
if (want === "markdown" || want === "md") {
|
|
2491
|
-
cfg.outputFormat = "markdown";
|
|
2492
|
-
saveConfig(cfg);
|
|
2493
|
-
console.log(c.green(" output format: markdown"));
|
|
2494
|
-
} else if (want === "html") {
|
|
2495
|
-
cfg.outputFormat = "html";
|
|
2496
|
-
saveConfig(cfg);
|
|
2497
|
-
console.log(c.green(" output format: html"));
|
|
2498
|
-
} else if (want) console.log(c.dim(" usage: /output markdown | html"));
|
|
2499
|
-
else console.log(c.dim(` output format: ${cfg.outputFormat} (change with /output markdown | html)`));
|
|
2500
|
-
continue;
|
|
2501
|
-
}
|
|
2502
|
-
if (cmd === "save") {
|
|
2503
|
-
if (!lastReply) {
|
|
2504
|
-
console.log(c.dim(" nothing to save yet. Ask something first."));
|
|
2505
|
-
continue;
|
|
2506
|
-
}
|
|
2507
|
-
try {
|
|
2508
|
-
const path = saveReply(lastReply, cfg.outputFormat, rest || void 0);
|
|
2509
|
-
console.log(c.green(` saved ${cfg.outputFormat} to ${path}`));
|
|
2510
|
-
} catch (e) {
|
|
2511
|
-
console.log(c.red(` could not save: ${e.message}`));
|
|
2512
|
-
}
|
|
2513
|
-
continue;
|
|
2514
|
-
}
|
|
2515
|
-
if (cmd === "setting" || cmd === "settings") {
|
|
2516
|
-
cfg = await runSettings(ask);
|
|
2517
|
-
const next = cfg.defaultBrain ? resolveActive2(cfg, cfg.defaultBrain) : null;
|
|
2518
|
-
const stillValid = resolveActive2(cfg, active.id);
|
|
2519
|
-
active = stillValid ?? next ?? active;
|
|
2520
|
-
console.log(c.dim(` brain: ${active.label}`));
|
|
2521
|
-
continue;
|
|
2522
|
-
}
|
|
2523
|
-
if (cmd === "brain") {
|
|
2524
|
-
const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
|
|
2525
|
-
const apiIds = cfg.apiBrains.map((a) => a.id);
|
|
2526
|
-
const all = [...cliEnabled, ...apiIds];
|
|
2527
|
-
const rawArg = parts[1] || "";
|
|
2528
|
-
const match = all.find((id) => id.toLowerCase() === rawArg.toLowerCase());
|
|
2529
|
-
if (rawArg && match) {
|
|
2530
|
-
const next = resolveActive2(cfg, match);
|
|
2531
|
-
if (next) {
|
|
2532
|
-
active = next;
|
|
2533
|
-
const turns = Math.floor(history.length / 2);
|
|
2534
|
-
console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
|
|
2535
|
-
}
|
|
2536
|
-
} else if (rawArg) {
|
|
2537
|
-
console.log(c.dim(` "${rawArg}" is not available. Available: ${all.join(", ") || "none"}`));
|
|
2538
|
-
} else {
|
|
2539
|
-
const cfgRef = cfg;
|
|
2540
|
-
const activeId = active.id;
|
|
2541
|
-
const labels = all.map((id) => {
|
|
2542
|
-
const a = findApiBrain(cfgRef, id);
|
|
2543
|
-
const shown = a ? `${id} (api: ${a.provider}/${a.model})` : id;
|
|
2544
|
-
return id === activeId ? c.accent(shown + " (current)") : shown;
|
|
2545
|
-
});
|
|
2546
|
-
console.log(c.dim(" brains: " + labels.join(" ")));
|
|
2547
|
-
console.log(c.dim(" usage: /brain <name>"));
|
|
2548
|
-
}
|
|
2549
|
-
continue;
|
|
2550
|
-
}
|
|
2551
|
-
if (cmd === "skill" || cmd === "skills") {
|
|
2552
|
-
const names = listSkills().map((s) => s.name);
|
|
2553
|
-
console.log(c.dim(" skills: " + (names.join(" ") || "none") + " usage: /skill <name> [input]"));
|
|
2554
|
-
continue;
|
|
2555
|
-
}
|
|
2556
|
-
console.log(c.dim(` unknown command: /${cmd} (try /help)`));
|
|
2557
|
-
continue;
|
|
2558
|
-
}
|
|
2559
|
-
if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
|
|
2560
|
-
console.log(c.red(` ${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Use /brain to switch or /setting.`));
|
|
2561
|
-
continue;
|
|
2562
|
-
}
|
|
2563
|
-
if (active.kind === "api" && !resolveApiKey(active.brain)) {
|
|
2564
|
-
console.log(c.red(` ${active.label} has no API key. Use /setting to add one, or /brain to switch.`));
|
|
2565
|
-
continue;
|
|
2566
|
-
}
|
|
2567
|
-
const remembered = await recall(line, session, 4);
|
|
2568
|
-
const label = remembered.length ? `${active.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${active.label} is thinking...`;
|
|
2569
|
-
console.log(c.dim(` ${label}`) + "\n");
|
|
2570
|
-
const skillsBlock = skillsPromptBlock();
|
|
2571
|
-
const base = renderPrompt(history, line, remembered);
|
|
2572
|
-
const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
|
|
2573
|
-
let streamed = false;
|
|
2574
|
-
const onChunk = (chunk2) => {
|
|
2575
|
-
streamed = true;
|
|
2576
|
-
process.stdout.write(chunk2);
|
|
2577
|
-
};
|
|
2578
|
-
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
|
|
2579
|
-
if (res.ok) {
|
|
2580
|
-
if (!streamed) console.log(res.text);
|
|
2581
|
-
if (!res.text.endsWith("\n")) console.log("");
|
|
2582
|
-
console.log("");
|
|
2583
|
-
lastReply = res.text;
|
|
2584
|
-
history.push({ role: "user", content: line });
|
|
2585
|
-
history.push({ role: "assistant", content: res.text });
|
|
2586
|
-
const now = Date.now();
|
|
2587
|
-
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
|
|
2588
|
-
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
|
|
2589
|
-
console.log(statusLine(active.label, history) + "\n");
|
|
2590
|
-
} else {
|
|
2591
|
-
console.log(c.red("\n " + res.text + "\n"));
|
|
2592
|
-
}
|
|
2410
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
2411
|
+
}
|
|
2412
|
+
var ROUTE_SIMILARITY_THRESHOLD = 0.55;
|
|
2413
|
+
async function pageEmbeddings(pages) {
|
|
2414
|
+
const out = [];
|
|
2415
|
+
for (const page of pages) {
|
|
2416
|
+
const emb = await embed(`${page.title}
|
|
2417
|
+
${page.body.slice(0, 1500)}`);
|
|
2418
|
+
if (emb) out.push({ page, emb });
|
|
2593
2419
|
}
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2420
|
+
return out;
|
|
2421
|
+
}
|
|
2422
|
+
async function routeFact(content, targets) {
|
|
2423
|
+
const e = await embed(content);
|
|
2424
|
+
if (!e) {
|
|
2425
|
+
return { kind: targets.length ? "existing" : "new", slug: targets[0]?.page.slug ?? "notes", score: 0 };
|
|
2426
|
+
}
|
|
2427
|
+
let best = null;
|
|
2428
|
+
let bestScore = -1;
|
|
2429
|
+
for (const t of targets) {
|
|
2430
|
+
const s = cosine2(e, t.emb);
|
|
2431
|
+
if (s > bestScore) {
|
|
2432
|
+
bestScore = s;
|
|
2433
|
+
best = t;
|
|
2600
2434
|
}
|
|
2601
2435
|
}
|
|
2602
|
-
|
|
2603
|
-
|
|
2436
|
+
if (best && bestScore >= ROUTE_SIMILARITY_THRESHOLD) {
|
|
2437
|
+
return { kind: "existing", slug: best.page.slug, score: bestScore };
|
|
2438
|
+
}
|
|
2439
|
+
return { kind: "new", slug: proposeSlug(content), score: bestScore < 0 ? 0 : bestScore };
|
|
2604
2440
|
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2441
|
+
function proposeSlug(content) {
|
|
2442
|
+
const words2 = content.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !ROUTE_STOPWORDS.has(w)).slice(0, 4);
|
|
2443
|
+
const slug = sanitizeName(words2.join("-"));
|
|
2444
|
+
return slug || "notes";
|
|
2445
|
+
}
|
|
2446
|
+
var ROUTE_STOPWORDS = /* @__PURE__ */ new Set([
|
|
2447
|
+
"the",
|
|
2448
|
+
"and",
|
|
2449
|
+
"that",
|
|
2450
|
+
"this",
|
|
2451
|
+
"with",
|
|
2452
|
+
"from",
|
|
2453
|
+
"for",
|
|
2454
|
+
"was",
|
|
2455
|
+
"are",
|
|
2456
|
+
"has",
|
|
2457
|
+
"have",
|
|
2458
|
+
"will",
|
|
2459
|
+
"user",
|
|
2460
|
+
"their",
|
|
2461
|
+
"they",
|
|
2462
|
+
"about",
|
|
2463
|
+
"into",
|
|
2464
|
+
"per",
|
|
2465
|
+
"via"
|
|
2466
|
+
]);
|
|
2467
|
+
async function resolveMaintainer(cfg, resolveBrain) {
|
|
2468
|
+
if (cfg.wiki.maintainer === "local") {
|
|
2469
|
+
const model = cfg.wiki.localModel;
|
|
2470
|
+
const status2 = await localModelStatus(model);
|
|
2471
|
+
if (status2.reachable && status2.hasModel) {
|
|
2472
|
+
const m = { kind: "local", model, label: `local (${model})` };
|
|
2473
|
+
return { maintainer: m, report: { maintainer: m, fellBack: false } };
|
|
2619
2474
|
}
|
|
2620
|
-
|
|
2475
|
+
const why = status2.reachable ? `local model "${model}" is not pulled. Run: ${pullHint(model)}` : "local Ollama is not reachable.";
|
|
2476
|
+
const brain2 = resolveBrain();
|
|
2477
|
+
if (brain2) {
|
|
2478
|
+
return {
|
|
2479
|
+
maintainer: brain2,
|
|
2480
|
+
report: { maintainer: brain2, fellBack: true, note: `${why} Falling back to ${brain2.label}.` }
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
return { maintainer: null, report: null };
|
|
2621
2484
|
}
|
|
2622
|
-
const
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
`));
|
|
2626
|
-
await runInteractive(s.login[0], s.login.slice(1));
|
|
2485
|
+
const brain = resolveBrain();
|
|
2486
|
+
if (!brain) return { maintainer: null, report: null };
|
|
2487
|
+
return { maintainer: brain, report: { maintainer: brain, fellBack: false } };
|
|
2627
2488
|
}
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
close();
|
|
2636
|
-
return;
|
|
2489
|
+
function resolveBrainMaintainer(cfg) {
|
|
2490
|
+
const id = cfg.defaultBrain;
|
|
2491
|
+
if (!id) return null;
|
|
2492
|
+
if (BRAIN_IDS.includes(id)) {
|
|
2493
|
+
const b = cfg.brains[id];
|
|
2494
|
+
if (!isInstalled(b.command)) return null;
|
|
2495
|
+
return { kind: "cli", id, label: b.label };
|
|
2637
2496
|
}
|
|
2638
|
-
const
|
|
2639
|
-
if (
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2497
|
+
const api = findApiBrain(cfg, id);
|
|
2498
|
+
if (api && resolveApiKey(api)) return { kind: "api", brain: api, label: `${id} (api: ${api.provider}/${api.model})` };
|
|
2499
|
+
return null;
|
|
2500
|
+
}
|
|
2501
|
+
async function runMaintainer(m, cfg, prompt) {
|
|
2502
|
+
if (m.kind === "cli") return runBrain(cfg.brains[m.id], prompt);
|
|
2503
|
+
if (m.kind === "api") return runApiBrain(m.brain, prompt);
|
|
2504
|
+
const r = await localGenerate(m.model, prompt);
|
|
2505
|
+
return { ok: r.ok, text: r.text };
|
|
2506
|
+
}
|
|
2507
|
+
function buildPagePrompt(title, existingBody, facts, otherTitles) {
|
|
2508
|
+
const lines = [
|
|
2509
|
+
`You maintain a personal knowledge wiki. Write or update ONE page titled "${title}".`,
|
|
2510
|
+
"Output ONLY the page body in Markdown. No frontmatter, no code fences, no preamble.",
|
|
2511
|
+
"",
|
|
2512
|
+
"Rules:",
|
|
2513
|
+
"- Synthesize the facts into concise, self-contained prose (a few short paragraphs or tight bullets).",
|
|
2514
|
+
"- Merge new facts into the existing text; do not just append. Resolve overlaps. Keep it current.",
|
|
2515
|
+
"- Do not invent anything beyond the facts given.",
|
|
2516
|
+
'- End with a line "## Related" followed by [[Wiki Page]] links to other pages that connect.',
|
|
2517
|
+
"- Use [[double-bracket]] links for any other page you reference."
|
|
2518
|
+
];
|
|
2519
|
+
if (otherTitles.length) {
|
|
2520
|
+
lines.push("", "Other existing pages you may link to:", ...otherTitles.map((t) => `- ${t}`));
|
|
2654
2521
|
}
|
|
2655
|
-
if (
|
|
2656
|
-
|
|
2657
|
-
console.log(c.dim(`
|
|
2658
|
-
No local Ollama with ${EMBED_MODEL} reachable. Run "holt init" to set it up.
|
|
2659
|
-
`));
|
|
2660
|
-
close();
|
|
2661
|
-
return;
|
|
2662
|
-
}
|
|
2663
|
-
const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
|
|
2664
|
-
if (missing === 0) {
|
|
2665
|
-
console.log(c.dim("\n All memories already have embeddings.\n"));
|
|
2666
|
-
close();
|
|
2667
|
-
return;
|
|
2668
|
-
}
|
|
2669
|
-
console.log("");
|
|
2670
|
-
const r = await backfillEmbeddings((done, total) => {
|
|
2671
|
-
process.stdout.write(`\r embedding ${done}/${total}...`);
|
|
2672
|
-
});
|
|
2673
|
-
console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} memories embedded.`) + "\n");
|
|
2674
|
-
close();
|
|
2675
|
-
return;
|
|
2522
|
+
if (existingBody.trim()) {
|
|
2523
|
+
lines.push("", "Current page body:", existingBody.trim());
|
|
2676
2524
|
}
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2525
|
+
lines.push("", "New facts to integrate:", ...facts.map((f) => `- ${f}`), "", "Updated page body:");
|
|
2526
|
+
return lines.join("\n");
|
|
2527
|
+
}
|
|
2528
|
+
function emptySummary() {
|
|
2529
|
+
return { created: 0, updated: 0, factsIntegrated: 0 };
|
|
2530
|
+
}
|
|
2531
|
+
function dedupe(a) {
|
|
2532
|
+
return [...new Set(a)];
|
|
2533
|
+
}
|
|
2534
|
+
function deriveTitle(content, slug) {
|
|
2535
|
+
const fromSlug = slug.split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
2536
|
+
return fromSlug || content.slice(0, 40);
|
|
2537
|
+
}
|
|
2538
|
+
function stripFrontmatterEcho(text) {
|
|
2539
|
+
let s = text.trim();
|
|
2540
|
+
s = s.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
2541
|
+
if (s.startsWith("---")) {
|
|
2542
|
+
const end = s.indexOf("\n---", 3);
|
|
2543
|
+
if (end >= 0) s = s.slice(end + 4).trim();
|
|
2687
2544
|
}
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2545
|
+
return s;
|
|
2546
|
+
}
|
|
2547
|
+
async function foldFacts(cfg, maintainer, facts) {
|
|
2548
|
+
const summary = emptySummary();
|
|
2549
|
+
if (facts.length === 0) return { summary, changed: [] };
|
|
2550
|
+
let pages = loadPages();
|
|
2551
|
+
const targets = await pageEmbeddings(pages);
|
|
2552
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2553
|
+
for (const f of facts) {
|
|
2554
|
+
const decision = await routeFact(f.content, targets);
|
|
2555
|
+
const slug = decision.slug;
|
|
2556
|
+
let isNew = decision.kind === "new";
|
|
2557
|
+
const existingGroup = groups.get(slug);
|
|
2558
|
+
if (existingGroup) {
|
|
2559
|
+
existingGroup.facts.push(f);
|
|
2560
|
+
} else {
|
|
2561
|
+
if (isNew && listPageSlugs().includes(slug)) isNew = false;
|
|
2562
|
+
groups.set(slug, { slug, isNew, facts: [f] });
|
|
2704
2563
|
}
|
|
2705
|
-
console.log("");
|
|
2706
|
-
close();
|
|
2707
|
-
return;
|
|
2708
2564
|
}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
return;
|
|
2565
|
+
const knownTitles = new Set(pages.map((p) => p.title));
|
|
2566
|
+
for (const group of groups.values()) {
|
|
2567
|
+
knownTitles.add(readPage(group.slug)?.title ?? deriveTitle(group.facts[0].content, group.slug));
|
|
2713
2568
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2569
|
+
const changed = [];
|
|
2570
|
+
for (const group of groups.values()) {
|
|
2571
|
+
const existing = readPage(group.slug);
|
|
2572
|
+
const title = existing?.title ?? deriveTitle(group.facts[0].content, group.slug);
|
|
2573
|
+
const prompt = buildPagePrompt(
|
|
2574
|
+
title,
|
|
2575
|
+
existing?.body ?? "",
|
|
2576
|
+
group.facts.map((f) => f.content),
|
|
2577
|
+
[...knownTitles].filter((t) => t !== title)
|
|
2578
|
+
);
|
|
2579
|
+
const res = await runMaintainer(maintainer, cfg, prompt);
|
|
2580
|
+
if (!res.ok || !res.text.trim()) continue;
|
|
2581
|
+
const body = stripFrontmatterEcho(res.text);
|
|
2582
|
+
const prevSources = existing?.sources ?? [];
|
|
2583
|
+
const page = {
|
|
2584
|
+
slug: group.slug,
|
|
2585
|
+
title,
|
|
2586
|
+
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2587
|
+
sources: dedupe([...prevSources, ...group.facts.map((f) => f.id)]),
|
|
2588
|
+
body
|
|
2589
|
+
};
|
|
2590
|
+
writePage(page);
|
|
2591
|
+
changed.push(page);
|
|
2592
|
+
if (existing) summary.updated++;
|
|
2593
|
+
else summary.created++;
|
|
2594
|
+
summary.factsIntegrated += group.facts.length;
|
|
2721
2595
|
}
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2596
|
+
pages = loadPages();
|
|
2597
|
+
writeIndex(pages);
|
|
2598
|
+
await indexPagesForRecall(changed);
|
|
2599
|
+
return { summary, changed };
|
|
2600
|
+
}
|
|
2601
|
+
async function syncWiki(cfg, resolveBrain) {
|
|
2602
|
+
const base = {
|
|
2603
|
+
...emptySummary(),
|
|
2604
|
+
status: "nothing",
|
|
2605
|
+
freshFacts: 0,
|
|
2606
|
+
maintainer: null,
|
|
2607
|
+
report: null,
|
|
2608
|
+
changed: false
|
|
2609
|
+
};
|
|
2610
|
+
try {
|
|
2611
|
+
const state = loadState();
|
|
2612
|
+
const fresh = factRows().filter((f) => f.ts > state.lastSyncTs);
|
|
2613
|
+
if (fresh.length === 0) return base;
|
|
2614
|
+
base.freshFacts = fresh.length;
|
|
2615
|
+
const { maintainer, report } = await resolveMaintainer(cfg, resolveBrain);
|
|
2616
|
+
base.maintainer = maintainer;
|
|
2617
|
+
base.report = report;
|
|
2618
|
+
if (!maintainer) return { ...base, status: "no-maintainer" };
|
|
2619
|
+
const { summary, changed } = await foldFacts(cfg, maintainer, fresh);
|
|
2620
|
+
const newest = Math.max(state.lastSyncTs, ...fresh.map((f) => f.ts));
|
|
2621
|
+
saveSyncMarker(newest);
|
|
2622
|
+
return {
|
|
2623
|
+
...summary,
|
|
2624
|
+
status: "ok",
|
|
2625
|
+
freshFacts: fresh.length,
|
|
2626
|
+
maintainer,
|
|
2627
|
+
report,
|
|
2628
|
+
changed: changed.length > 0
|
|
2629
|
+
};
|
|
2630
|
+
} catch {
|
|
2631
|
+
return base;
|
|
2735
2632
|
}
|
|
2736
|
-
const gEnabled = isGlobalEnabled(workspace());
|
|
2737
|
-
console.log(` global ${gEnabled ? c.green("on") + c.dim(" (contributes + reads shared facts)") : c.dim("off (this folder is isolated, the default)")}`);
|
|
2738
|
-
console.log(c.dim("\n holt memory search <query> find remembered moments"));
|
|
2739
|
-
console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
|
|
2740
|
-
console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
|
|
2741
|
-
console.log(c.dim(" holt memory global share high-value facts across your folders"));
|
|
2742
|
-
console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
|
|
2743
|
-
close();
|
|
2744
2633
|
}
|
|
2745
|
-
function
|
|
2746
|
-
const
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
`
|
|
2756
|
-
|
|
2634
|
+
function renderIndex(pages) {
|
|
2635
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2636
|
+
const lines = [
|
|
2637
|
+
"---",
|
|
2638
|
+
"title: Wiki Index",
|
|
2639
|
+
`updated: ${today}`,
|
|
2640
|
+
"---",
|
|
2641
|
+
"",
|
|
2642
|
+
"# Wiki",
|
|
2643
|
+
"",
|
|
2644
|
+
"Derived knowledge for this folder. Pages are synthesized from `facts.md` and",
|
|
2645
|
+
"`turns.jsonl` and are regenerable with `holt wiki rebuild`. Open in Obsidian to",
|
|
2646
|
+
"browse the [[links]].",
|
|
2647
|
+
""
|
|
2648
|
+
];
|
|
2649
|
+
if (pages.length === 0) {
|
|
2650
|
+
lines.push("_No pages yet. Run `holt wiki sync` after chatting._", "");
|
|
2651
|
+
return lines.join("\n");
|
|
2757
2652
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
return;
|
|
2763
|
-
}
|
|
2764
|
-
const { purged } = disableGlobal(ws, purge);
|
|
2765
|
-
console.log("\n" + c.green(" Global memory OFF for this folder.") + c.dim(" (recall is local-only again)"));
|
|
2766
|
-
if (purge) console.log(c.dim(` Purged ${purged} of this folder's row${purged === 1 ? "" : "s"} from the shared store.`));
|
|
2767
|
-
else console.log(c.dim(" This folder's rows stay in the shared store. Re-run with --purge to remove them."));
|
|
2768
|
-
console.log("");
|
|
2769
|
-
return;
|
|
2653
|
+
lines.push("## Pages", "");
|
|
2654
|
+
for (const pg of [...pages].sort((a, b) => a.title.localeCompare(b.title))) {
|
|
2655
|
+
const when = pg.updated ? ` _(updated ${pg.updated})_` : "";
|
|
2656
|
+
lines.push(`- [[${pg.title}]]${when}`);
|
|
2770
2657
|
}
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2658
|
+
lines.push("");
|
|
2659
|
+
return lines.join("\n");
|
|
2660
|
+
}
|
|
2661
|
+
function writeIndex(pages) {
|
|
2662
|
+
mkdirSync7(wikiDir(), { recursive: true });
|
|
2663
|
+
writeFileSync7(wikiIndexPath(), renderIndex(pages), "utf8");
|
|
2664
|
+
}
|
|
2665
|
+
var WIKI_SESSION = "wiki";
|
|
2666
|
+
function wikiRowId(slug) {
|
|
2667
|
+
return "wiki-" + sanitizeName(slug).slice(0, 24);
|
|
2668
|
+
}
|
|
2669
|
+
function dropWikiRows(slugs) {
|
|
2670
|
+
const keep = loadTurns().filter((t) => !(t.session === WIKI_SESSION && slugs.has(t.id.replace(/^wiki-/, ""))));
|
|
2671
|
+
return keep;
|
|
2672
|
+
}
|
|
2673
|
+
async function indexPagesForRecall(pages) {
|
|
2674
|
+
try {
|
|
2675
|
+
if (pages.length === 0) return;
|
|
2676
|
+
const slugs = new Set(pages.map((p) => sanitizeName(p.slug).slice(0, 24)));
|
|
2677
|
+
const kept = dropWikiRows(slugs);
|
|
2678
|
+
mkdirSync7(join8(wsHoltDir(), "memory"), { recursive: true });
|
|
2679
|
+
writeFileSync7(
|
|
2680
|
+
join8(wsHoltDir(), "memory", "turns.jsonl"),
|
|
2681
|
+
kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
|
|
2682
|
+
"utf8"
|
|
2683
|
+
);
|
|
2684
|
+
for (const pg of pages) {
|
|
2685
|
+
const content = `${pg.title}
|
|
2686
|
+
${pg.body}`.slice(0, 4e3);
|
|
2687
|
+
appendTurn({
|
|
2688
|
+
id: wikiRowId(pg.slug),
|
|
2689
|
+
ts: Date.now(),
|
|
2690
|
+
session: WIKI_SESSION,
|
|
2691
|
+
role: "fact",
|
|
2692
|
+
content,
|
|
2693
|
+
emb: await embed(content) ?? void 0
|
|
2694
|
+
});
|
|
2784
2695
|
}
|
|
2785
|
-
|
|
2786
|
-
console.log(c.dim(" holt memory global off stop sharing + reading (add --purge to delete rows)"));
|
|
2787
|
-
console.log(c.dim(" holt memory global status this view\n"));
|
|
2788
|
-
return;
|
|
2696
|
+
} catch {
|
|
2789
2697
|
}
|
|
2790
|
-
console.error(`
|
|
2791
|
-
Unknown "memory global" action: "${act}". Use: on | off [--purge] | status
|
|
2792
|
-
`);
|
|
2793
|
-
process.exitCode = 1;
|
|
2794
2698
|
}
|
|
2795
|
-
|
|
2796
|
-
// src/commands/wiki.ts
|
|
2797
|
-
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
|
|
2798
|
-
import { join as join10 } from "path";
|
|
2799
|
-
import { spawn as spawn3 } from "child_process";
|
|
2800
|
-
|
|
2801
|
-
// src/localmodel.ts
|
|
2802
|
-
var OLLAMA_URL2 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
|
|
2803
|
-
async function localModelStatus(model) {
|
|
2699
|
+
function clearWikiRecallRows() {
|
|
2804
2700
|
try {
|
|
2805
|
-
const
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
});
|
|
2813
|
-
return { reachable: true, hasModel };
|
|
2701
|
+
const kept = loadTurns().filter((t) => t.session !== WIKI_SESSION);
|
|
2702
|
+
mkdirSync7(join8(wsHoltDir(), "memory"), { recursive: true });
|
|
2703
|
+
writeFileSync7(
|
|
2704
|
+
join8(wsHoltDir(), "memory", "turns.jsonl"),
|
|
2705
|
+
kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
|
|
2706
|
+
"utf8"
|
|
2707
|
+
);
|
|
2814
2708
|
} catch {
|
|
2815
|
-
return { reachable: false, hasModel: false };
|
|
2816
2709
|
}
|
|
2817
2710
|
}
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
2826
|
-
});
|
|
2827
|
-
} catch (e) {
|
|
2828
|
-
return { ok: false, text: `Could not reach local Ollama at ${OLLAMA_URL2}: ${e.message}` };
|
|
2829
|
-
}
|
|
2830
|
-
if (!res.ok) {
|
|
2831
|
-
let body = "";
|
|
2711
|
+
function wipeWiki() {
|
|
2712
|
+
if (existsSync8(wikiDir())) rmSync4(wikiDir(), { recursive: true, force: true });
|
|
2713
|
+
}
|
|
2714
|
+
function wikiStats() {
|
|
2715
|
+
const slugs = listPageSlugs();
|
|
2716
|
+
let bytes = 0;
|
|
2717
|
+
for (const slug of slugs) {
|
|
2832
2718
|
try {
|
|
2833
|
-
|
|
2719
|
+
bytes += statSync3(pagePath(slug)).size;
|
|
2834
2720
|
} catch {
|
|
2835
2721
|
}
|
|
2836
|
-
return { ok: false, text: `Ollama error ${res.status}: ${body.slice(0, 200)}` };
|
|
2837
2722
|
}
|
|
2838
|
-
|
|
2839
|
-
const data = await res.json();
|
|
2840
|
-
const text = (data.response || "").trim();
|
|
2841
|
-
if (text) return { ok: true, text };
|
|
2842
|
-
return { ok: false, text: "Local model returned an empty reply." };
|
|
2843
|
-
} catch (e) {
|
|
2844
|
-
return { ok: false, text: `Could not parse Ollama reply: ${e.message}` };
|
|
2845
|
-
}
|
|
2846
|
-
}
|
|
2847
|
-
function pullHint(model) {
|
|
2848
|
-
return `ollama pull ${model}`;
|
|
2849
|
-
}
|
|
2850
|
-
|
|
2851
|
-
// src/wiki.ts
|
|
2852
|
-
import {
|
|
2853
|
-
readFileSync as readFileSync10,
|
|
2854
|
-
writeFileSync as writeFileSync8,
|
|
2855
|
-
existsSync as existsSync9,
|
|
2856
|
-
mkdirSync as mkdirSync7,
|
|
2857
|
-
rmSync as rmSync4,
|
|
2858
|
-
readdirSync as readdirSync2,
|
|
2859
|
-
statSync as statSync3
|
|
2860
|
-
} from "fs";
|
|
2861
|
-
import { join as join9 } from "path";
|
|
2862
|
-
import { totalmem } from "os";
|
|
2863
|
-
function wikiDir() {
|
|
2864
|
-
return join9(wsHoltDir(), "wiki");
|
|
2865
|
-
}
|
|
2866
|
-
function wikiStatePath() {
|
|
2867
|
-
return join9(wikiDir(), ".state.json");
|
|
2868
|
-
}
|
|
2869
|
-
function wikiIndexPath() {
|
|
2870
|
-
return join9(wikiDir(), "index.md");
|
|
2723
|
+
return { pages: slugs.length, bytes, lastSyncTs: loadState().lastSyncTs };
|
|
2871
2724
|
}
|
|
2872
|
-
function
|
|
2873
|
-
return
|
|
2725
|
+
function factRows() {
|
|
2726
|
+
return loadTurns().filter((t) => t.role === "fact" && t.session !== WIKI_SESSION).map((t) => ({ id: t.id, content: t.content, ts: t.ts }));
|
|
2874
2727
|
}
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2728
|
+
var RAM_RECOMMENDATIONS = [
|
|
2729
|
+
{ minGiB: 48, model: "qwen2.5:32b", note: "plenty of headroom for a large local model (~20GB)." },
|
|
2730
|
+
{ minGiB: 24, model: "qwen2.5:14b", note: "a 14B model (~9GB) fits comfortably." },
|
|
2731
|
+
{ minGiB: 16, model: "qwen2.5:7b", note: "default. ~4.7GB; tight alongside the embed model + editor. An always-on machine is a better host. Alternative: llama3.1:8b." },
|
|
2732
|
+
{ minGiB: 0, model: "llama3.2:3b", note: "under 16GB, local is discouraged; if you insist, this ~2GB model is modest quality. The brain maintainer is the better default." }
|
|
2733
|
+
];
|
|
2734
|
+
function totalGiB() {
|
|
2735
|
+
return Math.round(totalmem() / 1024 ** 3 * 10) / 10;
|
|
2882
2736
|
}
|
|
2883
|
-
function
|
|
2884
|
-
|
|
2885
|
-
|
|
2737
|
+
function recommendLocalModel(gib = totalGiB()) {
|
|
2738
|
+
const tier = RAM_RECOMMENDATIONS.find((r) => gib >= r.minGiB) ?? RAM_RECOMMENDATIONS[RAM_RECOMMENDATIONS.length - 1];
|
|
2739
|
+
return {
|
|
2740
|
+
gib,
|
|
2741
|
+
model: tier.model,
|
|
2742
|
+
note: tier.note,
|
|
2743
|
+
pull: pullHint(tier.model),
|
|
2744
|
+
discourageLocal: gib < 16
|
|
2745
|
+
};
|
|
2886
2746
|
}
|
|
2887
|
-
|
|
2888
|
-
|
|
2747
|
+
|
|
2748
|
+
// src/output.ts
|
|
2749
|
+
import { writeFileSync as writeFileSync8 } from "fs";
|
|
2750
|
+
import { join as join9, isAbsolute } from "path";
|
|
2751
|
+
function escapeHtml(s) {
|
|
2752
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2889
2753
|
}
|
|
2890
|
-
function
|
|
2891
|
-
|
|
2754
|
+
function renderInline(text) {
|
|
2755
|
+
const codes = [];
|
|
2756
|
+
let s = text.replace(/`([^`]+)`/g, (_m, code) => {
|
|
2757
|
+
codes.push(`<code>${escapeHtml(code)}</code>`);
|
|
2758
|
+
return `\0${codes.length - 1}\0`;
|
|
2759
|
+
});
|
|
2760
|
+
s = escapeHtml(s);
|
|
2761
|
+
s = s.replace(/\[([^\]]+)\]\((\S*?(?:\([^)\s]*\)[^)\s]*)*)\)/g, (_m, label, url) => {
|
|
2762
|
+
const trimmed = url.trim();
|
|
2763
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
|
|
2764
|
+
const safeScheme = !scheme || /^(https?|mailto)$/i.test(scheme[1]);
|
|
2765
|
+
if (!safeScheme) return label;
|
|
2766
|
+
const safe = trimmed.replace(/"/g, "%22");
|
|
2767
|
+
return `<a href="${safe}">${label}</a>`;
|
|
2768
|
+
});
|
|
2769
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
2770
|
+
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
2771
|
+
s = s.replace(/(\d+)/g, (_m, i) => codes[Number(i)] ?? "");
|
|
2772
|
+
return s;
|
|
2892
2773
|
}
|
|
2893
|
-
function
|
|
2894
|
-
const
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2774
|
+
function markdownToHtmlBody(md) {
|
|
2775
|
+
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
|
2776
|
+
const out = [];
|
|
2777
|
+
let i = 0;
|
|
2778
|
+
let listOpen = false;
|
|
2779
|
+
let paragraph = [];
|
|
2780
|
+
const flushParagraph = () => {
|
|
2781
|
+
if (paragraph.length) {
|
|
2782
|
+
out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
|
|
2783
|
+
paragraph = [];
|
|
2784
|
+
}
|
|
2785
|
+
};
|
|
2786
|
+
const closeList = () => {
|
|
2787
|
+
if (listOpen) {
|
|
2788
|
+
out.push("</ul>");
|
|
2789
|
+
listOpen = false;
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
while (i < lines.length) {
|
|
2793
|
+
const line = lines[i] ?? "";
|
|
2794
|
+
const fence = line.match(/^```(.*)$/);
|
|
2795
|
+
if (fence) {
|
|
2796
|
+
flushParagraph();
|
|
2797
|
+
closeList();
|
|
2798
|
+
const code = [];
|
|
2799
|
+
i++;
|
|
2800
|
+
while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
|
|
2801
|
+
code.push(lines[i] ?? "");
|
|
2802
|
+
i++;
|
|
2803
|
+
}
|
|
2804
|
+
i++;
|
|
2805
|
+
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
2806
|
+
continue;
|
|
2807
|
+
}
|
|
2808
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
2809
|
+
if (h) {
|
|
2810
|
+
flushParagraph();
|
|
2811
|
+
closeList();
|
|
2812
|
+
const level = (h[1] ?? "#").length;
|
|
2813
|
+
out.push(`<h${level}>${renderInline((h[2] ?? "").trim())}</h${level}>`);
|
|
2814
|
+
i++;
|
|
2815
|
+
continue;
|
|
2816
|
+
}
|
|
2817
|
+
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
2818
|
+
if (li) {
|
|
2819
|
+
flushParagraph();
|
|
2820
|
+
if (!listOpen) {
|
|
2821
|
+
out.push("<ul>");
|
|
2822
|
+
listOpen = true;
|
|
2823
|
+
}
|
|
2824
|
+
out.push(`<li>${renderInline((li[1] ?? "").trim())}</li>`);
|
|
2825
|
+
i++;
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2828
|
+
if (line.trim() === "") {
|
|
2829
|
+
flushParagraph();
|
|
2830
|
+
closeList();
|
|
2831
|
+
i++;
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2834
|
+
closeList();
|
|
2835
|
+
paragraph.push(line.trim());
|
|
2836
|
+
i++;
|
|
2900
2837
|
}
|
|
2838
|
+
flushParagraph();
|
|
2839
|
+
closeList();
|
|
2840
|
+
return out.join("\n");
|
|
2901
2841
|
}
|
|
2902
|
-
function
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
}
|
|
2842
|
+
function wrapHtmlPage(body, title = "Holt reply") {
|
|
2843
|
+
return `<!doctype html>
|
|
2844
|
+
<html lang="en">
|
|
2845
|
+
<head>
|
|
2846
|
+
<meta charset="utf-8">
|
|
2847
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2848
|
+
<title>${escapeHtml(title)}</title>
|
|
2849
|
+
<style>
|
|
2850
|
+
:root { --bg:#0f1115; --fg:#e7e9ee; --accent:#f0b91e; }
|
|
2851
|
+
* { box-sizing: border-box; }
|
|
2852
|
+
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
2853
|
+
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
|
|
2854
|
+
main { max-width: 760px; margin: 0 auto; }
|
|
2855
|
+
h1,h2,h3 { color:var(--accent); line-height:1.25; margin:1.4em 0 .5em; }
|
|
2856
|
+
h1 { font-size:1.6rem; } h2 { font-size:1.3rem; } h3 { font-size:1.1rem; }
|
|
2857
|
+
a { color:var(--accent); }
|
|
2858
|
+
code { background:#1b1e26; padding:.1em .35em; border-radius:4px; font-size:.9em; }
|
|
2859
|
+
pre { background:#1b1e26; padding:1rem; border-radius:8px; overflow:auto; }
|
|
2860
|
+
pre code { background:none; padding:0; }
|
|
2861
|
+
ul { padding-left:1.3rem; }
|
|
2862
|
+
p { margin:.7em 0; }
|
|
2863
|
+
</style>
|
|
2864
|
+
</head>
|
|
2865
|
+
<body>
|
|
2866
|
+
<main>
|
|
2867
|
+
${body}
|
|
2868
|
+
</main>
|
|
2869
|
+
</body>
|
|
2870
|
+
</html>
|
|
2871
|
+
`;
|
|
2919
2872
|
}
|
|
2920
|
-
function
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2873
|
+
function renderReply(reply, format) {
|
|
2874
|
+
if (format === "html") return wrapHtmlPage(markdownToHtmlBody(reply));
|
|
2875
|
+
return reply.endsWith("\n") ? reply : reply + "\n";
|
|
2876
|
+
}
|
|
2877
|
+
function timestamp() {
|
|
2878
|
+
const d = /* @__PURE__ */ new Date();
|
|
2879
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2880
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
2881
|
+
}
|
|
2882
|
+
function saveReply(reply, format, name) {
|
|
2883
|
+
const ext = format === "html" ? ".html" : ".md";
|
|
2884
|
+
let base = (name ?? "").trim();
|
|
2885
|
+
if (!base) base = `holt-reply-${timestamp()}`;
|
|
2886
|
+
if (isAbsolute(base) || /[\\/]/.test(base) || base === "." || base === "..") {
|
|
2887
|
+
throw new Error('name must be a plain file name in this folder (no "/", "\\", or "..").');
|
|
2925
2888
|
}
|
|
2926
|
-
|
|
2889
|
+
if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
|
|
2890
|
+
const path = join9(process.cwd(), base);
|
|
2891
|
+
writeFileSync8(path, renderReply(reply, format), "utf8");
|
|
2892
|
+
return path;
|
|
2927
2893
|
}
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2894
|
+
|
|
2895
|
+
// src/commands/chat.ts
|
|
2896
|
+
function statusLine(brainLabel, history) {
|
|
2897
|
+
const live = Math.min(history.length, MAX_REPLAY_TURNS);
|
|
2898
|
+
const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
|
|
2899
|
+
const pct = Math.round(frac * 100);
|
|
2900
|
+
const mem = memStats().turns;
|
|
2901
|
+
return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
|
|
2931
2902
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
"
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
"
|
|
2940
|
-
"",
|
|
2941
|
-
|
|
2942
|
-
"",
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
].join("\n");
|
|
2946
|
-
return fm;
|
|
2903
|
+
function help() {
|
|
2904
|
+
console.log(c.dim([
|
|
2905
|
+
" commands:",
|
|
2906
|
+
" /brain [name] switch brain (CLI or API). context is kept.",
|
|
2907
|
+
" /memory [query] memory stats, or preview what a query would recall",
|
|
2908
|
+
' /skill [name] [input] run a skill, or list them. "holt skill" manages them.',
|
|
2909
|
+
" /output [fmt] show or set output format: markdown | html",
|
|
2910
|
+
" /save [name] save the last reply to a file in this folder",
|
|
2911
|
+
" /setting configure brains, API brains, and your launch command",
|
|
2912
|
+
" /clear forget this session so far (saved memory stays)",
|
|
2913
|
+
" /help this list",
|
|
2914
|
+
" /exit leave"
|
|
2915
|
+
].join("\n")));
|
|
2947
2916
|
}
|
|
2948
|
-
function
|
|
2949
|
-
|
|
2950
|
-
|
|
2917
|
+
function resolveActive2(cfg, id) {
|
|
2918
|
+
if (BRAIN_IDS.includes(id)) {
|
|
2919
|
+
const b = cfg.brains[id];
|
|
2920
|
+
return { kind: "cli", id, label: b.label };
|
|
2921
|
+
}
|
|
2922
|
+
const api = findApiBrain(cfg, id);
|
|
2923
|
+
if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
|
|
2924
|
+
return null;
|
|
2951
2925
|
}
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2926
|
+
async function chat() {
|
|
2927
|
+
const { ask, close } = createReader();
|
|
2928
|
+
if (!await ensureTrusted(ask)) {
|
|
2929
|
+
close();
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
let cfg = loadConfig();
|
|
2933
|
+
if (!cfg || !cfg.defaultBrain) {
|
|
2934
|
+
const a = (await ask(c.dim("No Holt setup in this folder. Set it up now? [Y/n] ")) ?? "").trim().toLowerCase();
|
|
2935
|
+
close();
|
|
2936
|
+
if (a === "n" || a === "no") {
|
|
2937
|
+
console.log(c.dim(' Run "holt init" here when ready.\n'));
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
await init();
|
|
2941
|
+
console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
let active = resolveActive2(cfg, cfg.defaultBrain);
|
|
2945
|
+
if (!active) {
|
|
2946
|
+
const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
|
|
2947
|
+
active = fallback ? resolveActive2(cfg, fallback) : null;
|
|
2948
|
+
if (!active) {
|
|
2949
|
+
close();
|
|
2950
|
+
console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
const session = newSessionId();
|
|
2955
|
+
const history = [];
|
|
2956
|
+
let lastReply = "";
|
|
2957
|
+
const embedOk = await embeddingsAvailable();
|
|
2958
|
+
const stats = memStats();
|
|
2959
|
+
console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
|
|
2960
|
+
console.log(c.dim(
|
|
2961
|
+
`Memory: ${stats.turns} moments (${stats.facts} facts) from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
|
|
2962
|
+
));
|
|
2963
|
+
if (embedOk && stats.withEmbeddings < stats.turns) {
|
|
2964
|
+
console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
|
|
2965
|
+
}
|
|
2966
|
+
console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit"));
|
|
2967
|
+
console.log(statusLine(active.label, history) + "\n");
|
|
2968
|
+
while (true) {
|
|
2969
|
+
const raw = await ask(c.accent("\u203A "));
|
|
2970
|
+
if (raw === null) break;
|
|
2971
|
+
const line = raw.trim();
|
|
2972
|
+
if (!line) continue;
|
|
2973
|
+
let promptOverride = null;
|
|
2974
|
+
if (/^\/skills?\s+\S/.test(line)) {
|
|
2975
|
+
const inv = resolveSkillInvocation(line.replace(/^\/skills\b/, "/skill"));
|
|
2976
|
+
if (!inv) {
|
|
2977
|
+
console.log(c.dim(' no such skill. Try "holt skill list".'));
|
|
2978
|
+
continue;
|
|
2979
|
+
}
|
|
2980
|
+
promptOverride = inv.prompt;
|
|
2981
|
+
console.log(c.dim(` running skill "${inv.skillName}"...`));
|
|
2982
|
+
} else if (line.startsWith("/")) {
|
|
2983
|
+
const parts = line.slice(1).split(/\s+/);
|
|
2984
|
+
const cmd = (parts[0] || "").toLowerCase();
|
|
2985
|
+
const rest = parts.slice(1).join(" ");
|
|
2986
|
+
const arg = (parts[1] || "").toLowerCase();
|
|
2987
|
+
if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
|
|
2988
|
+
if (cmd === "help" || cmd === "h") {
|
|
2989
|
+
help();
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2992
|
+
if (cmd === "clear") {
|
|
2993
|
+
history.length = 0;
|
|
2994
|
+
console.log(c.dim(" session context cleared. Saved memory is untouched."));
|
|
2995
|
+
continue;
|
|
2996
|
+
}
|
|
2997
|
+
if (cmd === "memory" || cmd === "mem") {
|
|
2998
|
+
if (rest) {
|
|
2999
|
+
const hits = await recall(rest, session, 5);
|
|
3000
|
+
if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
|
|
3001
|
+
else for (const h of hits) console.log(c.dim(` ${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.slice(0, 110).replace(/\s+/g, " ")}`));
|
|
3002
|
+
} else {
|
|
3003
|
+
const s = memStats();
|
|
3004
|
+
console.log(c.dim(` ${s.turns} moments, ${s.facts} facts, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
|
|
3005
|
+
console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
|
|
3006
|
+
}
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
if (cmd === "output") {
|
|
3010
|
+
const want = arg;
|
|
3011
|
+
if (want === "markdown" || want === "md") {
|
|
3012
|
+
cfg.outputFormat = "markdown";
|
|
3013
|
+
saveConfig(cfg);
|
|
3014
|
+
console.log(c.green(" output format: markdown"));
|
|
3015
|
+
} else if (want === "html") {
|
|
3016
|
+
cfg.outputFormat = "html";
|
|
3017
|
+
saveConfig(cfg);
|
|
3018
|
+
console.log(c.green(" output format: html"));
|
|
3019
|
+
} else if (want) console.log(c.dim(" usage: /output markdown | html"));
|
|
3020
|
+
else console.log(c.dim(` output format: ${cfg.outputFormat} (change with /output markdown | html)`));
|
|
3021
|
+
continue;
|
|
3022
|
+
}
|
|
3023
|
+
if (cmd === "save") {
|
|
3024
|
+
if (!lastReply) {
|
|
3025
|
+
console.log(c.dim(" nothing to save yet. Ask something first."));
|
|
3026
|
+
continue;
|
|
3027
|
+
}
|
|
3028
|
+
try {
|
|
3029
|
+
const path = saveReply(lastReply, cfg.outputFormat, rest || void 0);
|
|
3030
|
+
console.log(c.green(` saved ${cfg.outputFormat} to ${path}`));
|
|
3031
|
+
} catch (e) {
|
|
3032
|
+
console.log(c.red(` could not save: ${e.message}`));
|
|
3033
|
+
}
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
if (cmd === "setting" || cmd === "settings") {
|
|
3037
|
+
cfg = await runSettings(ask);
|
|
3038
|
+
const next = cfg.defaultBrain ? resolveActive2(cfg, cfg.defaultBrain) : null;
|
|
3039
|
+
const stillValid = resolveActive2(cfg, active.id);
|
|
3040
|
+
active = stillValid ?? next ?? active;
|
|
3041
|
+
console.log(c.dim(` brain: ${active.label}`));
|
|
3042
|
+
continue;
|
|
3043
|
+
}
|
|
3044
|
+
if (cmd === "brain") {
|
|
3045
|
+
const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
|
|
3046
|
+
const apiIds = cfg.apiBrains.map((a) => a.id);
|
|
3047
|
+
const all = [...cliEnabled, ...apiIds];
|
|
3048
|
+
const rawArg = parts[1] || "";
|
|
3049
|
+
const match = all.find((id) => id.toLowerCase() === rawArg.toLowerCase());
|
|
3050
|
+
if (rawArg && match) {
|
|
3051
|
+
const next = resolveActive2(cfg, match);
|
|
3052
|
+
if (next) {
|
|
3053
|
+
active = next;
|
|
3054
|
+
const turns = Math.floor(history.length / 2);
|
|
3055
|
+
console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
|
|
3056
|
+
}
|
|
3057
|
+
} else if (rawArg) {
|
|
3058
|
+
console.log(c.dim(` "${rawArg}" is not available. Available: ${all.join(", ") || "none"}`));
|
|
3059
|
+
} else {
|
|
3060
|
+
const cfgRef = cfg;
|
|
3061
|
+
const activeId = active.id;
|
|
3062
|
+
const labels = all.map((id) => {
|
|
3063
|
+
const a = findApiBrain(cfgRef, id);
|
|
3064
|
+
const shown = a ? `${id} (api: ${a.provider}/${a.model})` : id;
|
|
3065
|
+
return id === activeId ? c.accent(shown + " (current)") : shown;
|
|
3066
|
+
});
|
|
3067
|
+
console.log(c.dim(" brains: " + labels.join(" ")));
|
|
3068
|
+
console.log(c.dim(" usage: /brain <name>"));
|
|
3069
|
+
}
|
|
3070
|
+
continue;
|
|
3071
|
+
}
|
|
3072
|
+
if (cmd === "skill" || cmd === "skills") {
|
|
3073
|
+
const names = listSkills().map((s) => s.name);
|
|
3074
|
+
console.log(c.dim(" skills: " + (names.join(" ") || "none") + " usage: /skill <name> [input]"));
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
console.log(c.dim(` unknown command: /${cmd} (try /help)`));
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
|
|
3081
|
+
console.log(c.red(` ${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Use /brain to switch or /setting.`));
|
|
3082
|
+
continue;
|
|
3083
|
+
}
|
|
3084
|
+
if (active.kind === "api" && !resolveApiKey(active.brain)) {
|
|
3085
|
+
console.log(c.red(` ${active.label} has no API key. Use /setting to add one, or /brain to switch.`));
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
const remembered = await recall(line, session, 4);
|
|
3089
|
+
const label = remembered.length ? `${active.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${active.label} is thinking...`;
|
|
3090
|
+
console.log(c.dim(` ${label}`) + "\n");
|
|
3091
|
+
const skillsBlock = skillsPromptBlock();
|
|
3092
|
+
const base = renderPrompt(history, line, remembered);
|
|
3093
|
+
const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
|
|
3094
|
+
let streamed = false;
|
|
3095
|
+
const onChunk = (chunk2) => {
|
|
3096
|
+
streamed = true;
|
|
3097
|
+
process.stdout.write(chunk2);
|
|
3098
|
+
};
|
|
3099
|
+
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
|
|
3100
|
+
if (res.ok) {
|
|
3101
|
+
if (!streamed) console.log(res.text);
|
|
3102
|
+
if (!res.text.endsWith("\n")) console.log("");
|
|
3103
|
+
console.log("");
|
|
3104
|
+
lastReply = res.text;
|
|
3105
|
+
history.push({ role: "user", content: line });
|
|
3106
|
+
history.push({ role: "assistant", content: res.text });
|
|
3107
|
+
const now = Date.now();
|
|
3108
|
+
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
|
|
3109
|
+
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
|
|
3110
|
+
console.log(statusLine(active.label, history) + "\n");
|
|
3111
|
+
} else {
|
|
3112
|
+
console.log(c.red("\n " + res.text + "\n"));
|
|
3113
|
+
}
|
|
2960
3114
|
}
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
for (let i = 0; i < n; i++) {
|
|
2969
|
-
const x = a[i];
|
|
2970
|
-
const y = b[i];
|
|
2971
|
-
dot += x * y;
|
|
2972
|
-
na += x * x;
|
|
2973
|
-
nb += y * y;
|
|
3115
|
+
if (cfg.memory?.extractFacts !== false) {
|
|
3116
|
+
try {
|
|
3117
|
+
const arg = active.kind === "cli" ? { kind: "cli", id: active.id } : { kind: "api", brain: active.brain };
|
|
3118
|
+
const n = await extractAndSaveFacts(arg, cfg, history, session);
|
|
3119
|
+
if (n > 0) console.log(c.dim(` distilled ${n} fact${n === 1 ? "" : "s"} from this session.`));
|
|
3120
|
+
} catch {
|
|
3121
|
+
}
|
|
2974
3122
|
}
|
|
2975
|
-
|
|
3123
|
+
if (cfg.wiki?.autoSync) {
|
|
3124
|
+
try {
|
|
3125
|
+
const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
|
|
3126
|
+
if (res.status === "ok" && res.changed) console.log(c.dim(" updated the wiki."));
|
|
3127
|
+
} catch {
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
close();
|
|
3131
|
+
console.log(c.dim("\nBye.\n"));
|
|
2976
3132
|
}
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
3133
|
+
|
|
3134
|
+
// src/commands/login.ts
|
|
3135
|
+
async function login(which) {
|
|
3136
|
+
const id = (which || "").toLowerCase();
|
|
3137
|
+
if (!BRAIN_IDS.includes(id)) {
|
|
3138
|
+
if (which) {
|
|
3139
|
+
console.error(`
|
|
3140
|
+
Unknown brain "${which}". Use one of: ${BRAIN_IDS.join(", ")}
|
|
3141
|
+
`);
|
|
3142
|
+
process.exitCode = 1;
|
|
3143
|
+
} else {
|
|
3144
|
+
console.log(c.dim(`
|
|
3145
|
+
Usage: holt login <${BRAIN_IDS.join("|")}>
|
|
3146
|
+
`));
|
|
3147
|
+
}
|
|
3148
|
+
return;
|
|
2984
3149
|
}
|
|
2985
|
-
|
|
3150
|
+
const s = BRAIN_SETUP[id];
|
|
3151
|
+
console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
|
|
3152
|
+
console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool.
|
|
3153
|
+
`));
|
|
3154
|
+
await runInteractive(s.login[0], s.login.slice(1));
|
|
2986
3155
|
}
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
3156
|
+
|
|
3157
|
+
// src/commands/memory.ts
|
|
3158
|
+
import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
|
|
3159
|
+
import { basename } from "path";
|
|
3160
|
+
async function memoryCmd(sub, rest = []) {
|
|
3161
|
+
const { ask, close } = createReader();
|
|
3162
|
+
if (!await ensureTrusted(ask)) {
|
|
3163
|
+
close();
|
|
3164
|
+
return;
|
|
2991
3165
|
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3166
|
+
const action = (sub || "").toLowerCase();
|
|
3167
|
+
if (action === "clear") {
|
|
3168
|
+
const s2 = memStats();
|
|
3169
|
+
if (s2.turns === 0) {
|
|
3170
|
+
console.log(c.dim("\n Memory is already empty.\n"));
|
|
3171
|
+
close();
|
|
3172
|
+
return;
|
|
2999
3173
|
}
|
|
3174
|
+
const a = (await ask(`
|
|
3175
|
+
Delete all ${s2.turns} remembered moments in this folder? [y/N] `) ?? "").trim().toLowerCase();
|
|
3176
|
+
if (a === "y" || a === "yes") {
|
|
3177
|
+
clearMemory();
|
|
3178
|
+
console.log(c.green(" Memory cleared.\n"));
|
|
3179
|
+
} else console.log(c.dim(" Kept.\n"));
|
|
3180
|
+
close();
|
|
3181
|
+
return;
|
|
3000
3182
|
}
|
|
3001
|
-
if (
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
const slug = sanitizeName(words2.join("-"));
|
|
3009
|
-
return slug || "notes";
|
|
3010
|
-
}
|
|
3011
|
-
var ROUTE_STOPWORDS = /* @__PURE__ */ new Set([
|
|
3012
|
-
"the",
|
|
3013
|
-
"and",
|
|
3014
|
-
"that",
|
|
3015
|
-
"this",
|
|
3016
|
-
"with",
|
|
3017
|
-
"from",
|
|
3018
|
-
"for",
|
|
3019
|
-
"was",
|
|
3020
|
-
"are",
|
|
3021
|
-
"has",
|
|
3022
|
-
"have",
|
|
3023
|
-
"will",
|
|
3024
|
-
"user",
|
|
3025
|
-
"their",
|
|
3026
|
-
"they",
|
|
3027
|
-
"about",
|
|
3028
|
-
"into",
|
|
3029
|
-
"per",
|
|
3030
|
-
"via"
|
|
3031
|
-
]);
|
|
3032
|
-
async function resolveMaintainer(cfg, resolveBrain) {
|
|
3033
|
-
if (cfg.wiki.maintainer === "local") {
|
|
3034
|
-
const model = cfg.wiki.localModel;
|
|
3035
|
-
const status2 = await localModelStatus(model);
|
|
3036
|
-
if (status2.reachable && status2.hasModel) {
|
|
3037
|
-
const m = { kind: "local", model, label: `local (${model})` };
|
|
3038
|
-
return { maintainer: m, report: { maintainer: m, fellBack: false } };
|
|
3183
|
+
if (action === "embed") {
|
|
3184
|
+
if (!await embeddingsAvailable()) {
|
|
3185
|
+
console.log(c.dim(`
|
|
3186
|
+
No local Ollama with ${EMBED_MODEL} reachable. Run "holt init" to set it up.
|
|
3187
|
+
`));
|
|
3188
|
+
close();
|
|
3189
|
+
return;
|
|
3039
3190
|
}
|
|
3040
|
-
const
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
report: { maintainer: brain2, fellBack: true, note: `${why} Falling back to ${brain2.label}.` }
|
|
3046
|
-
};
|
|
3191
|
+
const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
|
|
3192
|
+
if (missing === 0) {
|
|
3193
|
+
console.log(c.dim("\n All memories already have embeddings.\n"));
|
|
3194
|
+
close();
|
|
3195
|
+
return;
|
|
3047
3196
|
}
|
|
3048
|
-
|
|
3197
|
+
console.log("");
|
|
3198
|
+
const r = await backfillEmbeddings((done, total) => {
|
|
3199
|
+
process.stdout.write(`\r embedding ${done}/${total}...`);
|
|
3200
|
+
});
|
|
3201
|
+
console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} memories embedded.`) + "\n");
|
|
3202
|
+
close();
|
|
3203
|
+
return;
|
|
3049
3204
|
}
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
function buildPagePrompt(title, existingBody, facts, otherTitles) {
|
|
3061
|
-
const lines = [
|
|
3062
|
-
`You maintain a personal knowledge wiki. Write or update ONE page titled "${title}".`,
|
|
3063
|
-
"Output ONLY the page body in Markdown. No frontmatter, no code fences, no preamble.",
|
|
3064
|
-
"",
|
|
3065
|
-
"Rules:",
|
|
3066
|
-
"- Synthesize the facts into concise, self-contained prose (a few short paragraphs or tight bullets).",
|
|
3067
|
-
"- Merge new facts into the existing text; do not just append. Resolve overlaps. Keep it current.",
|
|
3068
|
-
"- Do not invent anything beyond the facts given.",
|
|
3069
|
-
'- End with a line "## Related" followed by [[Wiki Page]] links to other pages that connect.',
|
|
3070
|
-
"- Use [[double-bracket]] links for any other page you reference."
|
|
3071
|
-
];
|
|
3072
|
-
if (otherTitles.length) {
|
|
3073
|
-
lines.push("", "Other existing pages you may link to:", ...otherTitles.map((t) => `- ${t}`));
|
|
3205
|
+
if (action === "facts") {
|
|
3206
|
+
if (!existsSync9(factsMdPath())) {
|
|
3207
|
+
console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
|
|
3208
|
+
close();
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
const body = readFileSync10(factsMdPath(), "utf8").trim();
|
|
3212
|
+
console.log("\n" + body + "\n");
|
|
3213
|
+
close();
|
|
3214
|
+
return;
|
|
3074
3215
|
}
|
|
3075
|
-
if (
|
|
3076
|
-
|
|
3216
|
+
if (action === "search") {
|
|
3217
|
+
const q = rest.join(" ").trim();
|
|
3218
|
+
if (!q) {
|
|
3219
|
+
console.log(c.dim("\n Usage: holt memory search <query>\n"));
|
|
3220
|
+
close();
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
const hits = await recall(q, "__none__", 8);
|
|
3224
|
+
console.log("");
|
|
3225
|
+
if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
|
|
3226
|
+
else for (const h of hits) {
|
|
3227
|
+
const when = new Date(h.turn.ts).toISOString().slice(0, 10);
|
|
3228
|
+
const src = h.workspace ? c.dim(` [${basename(h.workspace)}]`) : "";
|
|
3229
|
+
console.log(
|
|
3230
|
+
` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role})${src} ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
console.log("");
|
|
3234
|
+
close();
|
|
3235
|
+
return;
|
|
3077
3236
|
}
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
"",
|
|
3091
|
-
"Derived knowledge for this folder. Pages are synthesized from `facts.md` and",
|
|
3092
|
-
"`turns.jsonl` and are regenerable with `holt wiki rebuild`. Open in Obsidian to",
|
|
3093
|
-
"browse the [[links]].",
|
|
3094
|
-
""
|
|
3095
|
-
];
|
|
3096
|
-
if (pages.length === 0) {
|
|
3097
|
-
lines.push("_No pages yet. Run `holt wiki sync` after chatting._", "");
|
|
3098
|
-
return lines.join("\n");
|
|
3237
|
+
if (action === "global") {
|
|
3238
|
+
globalCmd((rest[0] || "").toLowerCase(), rest.slice(1));
|
|
3239
|
+
close();
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
if (action && !["clear", "embed", "facts", "search", "global"].includes(action)) {
|
|
3243
|
+
console.error(`
|
|
3244
|
+
Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | global | clear
|
|
3245
|
+
`);
|
|
3246
|
+
process.exitCode = 1;
|
|
3247
|
+
close();
|
|
3248
|
+
return;
|
|
3099
3249
|
}
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3250
|
+
const s = memStats();
|
|
3251
|
+
const embedOk = await embeddingsAvailable();
|
|
3252
|
+
const sessions = new Set(loadTurns().map((t) => t.session)).size;
|
|
3253
|
+
console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
|
|
3254
|
+
console.log(` moments ${s.turns}`);
|
|
3255
|
+
console.log(` facts ${s.facts} (./.holt/memory/facts.md)`);
|
|
3256
|
+
console.log(` sessions ${sessions}`);
|
|
3257
|
+
console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
|
|
3258
|
+
console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
|
|
3259
|
+
console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
|
|
3260
|
+
if (embedOk && s.withEmbeddings < s.turns) {
|
|
3261
|
+
console.log(c.dim(`
|
|
3262
|
+
${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
|
|
3104
3263
|
}
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
function wikiRowId(slug) {
|
|
3114
|
-
return "wiki-" + sanitizeName(slug).slice(0, 24);
|
|
3115
|
-
}
|
|
3116
|
-
function dropWikiRows(slugs) {
|
|
3117
|
-
const keep = loadTurns().filter((t) => !(t.session === WIKI_SESSION && slugs.has(t.id.replace(/^wiki-/, ""))));
|
|
3118
|
-
return keep;
|
|
3264
|
+
const gEnabled = isGlobalEnabled(workspace());
|
|
3265
|
+
console.log(` global ${gEnabled ? c.green("on") + c.dim(" (contributes + reads shared facts)") : c.dim("off (this folder is isolated, the default)")}`);
|
|
3266
|
+
console.log(c.dim("\n holt memory search <query> find remembered moments"));
|
|
3267
|
+
console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
|
|
3268
|
+
console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
|
|
3269
|
+
console.log(c.dim(" holt memory global share high-value facts across your folders"));
|
|
3270
|
+
console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
|
|
3271
|
+
close();
|
|
3119
3272
|
}
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
const
|
|
3124
|
-
const
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
const content = `${pg.title}
|
|
3133
|
-
${pg.body}`.slice(0, 4e3);
|
|
3134
|
-
appendTurn({
|
|
3135
|
-
id: wikiRowId(pg.slug),
|
|
3136
|
-
ts: Date.now(),
|
|
3137
|
-
session: WIKI_SESSION,
|
|
3138
|
-
role: "fact",
|
|
3139
|
-
content,
|
|
3140
|
-
emb: await embed(content) ?? void 0
|
|
3141
|
-
});
|
|
3142
|
-
}
|
|
3143
|
-
} catch {
|
|
3273
|
+
function globalCmd(act, rest) {
|
|
3274
|
+
const ws = workspace();
|
|
3275
|
+
if (act === "on") {
|
|
3276
|
+
const already = isGlobalEnabled(ws);
|
|
3277
|
+
const added = enableGlobal(ws);
|
|
3278
|
+
const g = globalStats();
|
|
3279
|
+
if (already) console.log("\n" + c.green(" Global memory already on for this folder."));
|
|
3280
|
+
else console.log("\n" + c.green(" Global memory ON for this folder."));
|
|
3281
|
+
console.log(c.dim(` Backfilled ${added} fact${added === 1 ? "" : "s"} into the shared store.`));
|
|
3282
|
+
console.log(c.dim(` New facts here mirror automatically; recall now spans ${g.workspaces} contributing folder${g.workspaces === 1 ? "" : "s"}.
|
|
3283
|
+
`));
|
|
3284
|
+
return;
|
|
3144
3285
|
}
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
);
|
|
3155
|
-
|
|
3286
|
+
if (act === "off") {
|
|
3287
|
+
const purge = rest.includes("--purge");
|
|
3288
|
+
if (!isGlobalEnabled(ws) && !purge) {
|
|
3289
|
+
console.log("\n" + c.dim(" Global memory is already off for this folder.\n"));
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
const { purged } = disableGlobal(ws, purge);
|
|
3293
|
+
console.log("\n" + c.green(" Global memory OFF for this folder.") + c.dim(" (recall is local-only again)"));
|
|
3294
|
+
if (purge) console.log(c.dim(` Purged ${purged} of this folder's row${purged === 1 ? "" : "s"} from the shared store.`));
|
|
3295
|
+
else console.log(c.dim(" This folder's rows stay in the shared store. Re-run with --purge to remove them."));
|
|
3296
|
+
console.log("");
|
|
3297
|
+
return;
|
|
3156
3298
|
}
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3299
|
+
if (act === "" || act === "status") {
|
|
3300
|
+
const enabled = isGlobalEnabled(ws);
|
|
3301
|
+
const g = globalStats();
|
|
3302
|
+
const contributors = globalWorkspaces();
|
|
3303
|
+
console.log("\n" + c.accent("Holt global memory") + c.dim(" (opt-in, shared facts across your folders)"));
|
|
3304
|
+
console.log(` this folder ${enabled ? c.green("on") : c.dim("off (isolated, the default)")}`);
|
|
3305
|
+
console.log(` contributing ${g.workspaces} folder${g.workspaces === 1 ? "" : "s"}`);
|
|
3306
|
+
console.log(` shared facts ${g.facts}`);
|
|
3307
|
+
console.log(` size ${(g.bytes / 1024).toFixed(1)} KB (${globalMemPath()})`);
|
|
3308
|
+
console.log(` registry ${memoryScopesPath()}`);
|
|
3309
|
+
if (contributors.length) {
|
|
3310
|
+
console.log(c.dim("\n Folders sharing facts:"));
|
|
3311
|
+
for (const w of contributors) console.log(c.dim(` ${basename(w)} ${w === ws ? "(this folder)" : w}`));
|
|
3168
3312
|
}
|
|
3313
|
+
console.log(c.dim("\n holt memory global on share this folder's facts + read others"));
|
|
3314
|
+
console.log(c.dim(" holt memory global off stop sharing + reading (add --purge to delete rows)"));
|
|
3315
|
+
console.log(c.dim(" holt memory global status this view\n"));
|
|
3316
|
+
return;
|
|
3169
3317
|
}
|
|
3170
|
-
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
}
|
|
3175
|
-
var RAM_RECOMMENDATIONS = [
|
|
3176
|
-
{ minGiB: 48, model: "qwen2.5:32b", note: "plenty of headroom for a large local model (~20GB)." },
|
|
3177
|
-
{ minGiB: 24, model: "qwen2.5:14b", note: "a 14B model (~9GB) fits comfortably." },
|
|
3178
|
-
{ minGiB: 16, model: "qwen2.5:7b", note: "default. ~4.7GB; tight alongside the embed model + editor. An always-on machine is a better host. Alternative: llama3.1:8b." },
|
|
3179
|
-
{ minGiB: 0, model: "llama3.2:3b", note: "under 16GB, local is discouraged; if you insist, this ~2GB model is modest quality. The brain maintainer is the better default." }
|
|
3180
|
-
];
|
|
3181
|
-
function totalGiB() {
|
|
3182
|
-
return Math.round(totalmem() / 1024 ** 3 * 10) / 10;
|
|
3183
|
-
}
|
|
3184
|
-
function recommendLocalModel(gib = totalGiB()) {
|
|
3185
|
-
const tier = RAM_RECOMMENDATIONS.find((r) => gib >= r.minGiB) ?? RAM_RECOMMENDATIONS[RAM_RECOMMENDATIONS.length - 1];
|
|
3186
|
-
return {
|
|
3187
|
-
gib,
|
|
3188
|
-
model: tier.model,
|
|
3189
|
-
note: tier.note,
|
|
3190
|
-
pull: pullHint(tier.model),
|
|
3191
|
-
discourageLocal: gib < 16
|
|
3192
|
-
};
|
|
3318
|
+
console.error(`
|
|
3319
|
+
Unknown "memory global" action: "${act}". Use: on | off [--purge] | status
|
|
3320
|
+
`);
|
|
3321
|
+
process.exitCode = 1;
|
|
3193
3322
|
}
|
|
3194
3323
|
|
|
3195
3324
|
// src/commands/wiki.ts
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
if (BRAIN_IDS.includes(id)) {
|
|
3200
|
-
const b = cfg.brains[id];
|
|
3201
|
-
if (!isInstalled(b.command)) return null;
|
|
3202
|
-
return { kind: "cli", id, label: b.label };
|
|
3203
|
-
}
|
|
3204
|
-
const api = findApiBrain(cfg, id);
|
|
3205
|
-
if (api && resolveApiKey(api)) return { kind: "api", brain: api, label: `${id} (api: ${api.provider}/${api.model})` };
|
|
3206
|
-
return null;
|
|
3207
|
-
}
|
|
3325
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
|
|
3326
|
+
import { join as join10 } from "path";
|
|
3327
|
+
import { spawn as spawn3 } from "child_process";
|
|
3208
3328
|
function titleToSlug(title) {
|
|
3209
3329
|
return proposeSlug(title);
|
|
3210
3330
|
}
|
|
3211
|
-
async function foldFacts(cfg, maintainer, facts) {
|
|
3212
|
-
const summary = { created: 0, updated: 0, factsIntegrated: 0 };
|
|
3213
|
-
if (facts.length === 0) return summary;
|
|
3214
|
-
let pages = loadPages();
|
|
3215
|
-
const targets = await pageEmbeddings(pages);
|
|
3216
|
-
const groups = /* @__PURE__ */ new Map();
|
|
3217
|
-
for (const f of facts) {
|
|
3218
|
-
const decision = await routeFact(f.content, targets);
|
|
3219
|
-
let slug = decision.slug;
|
|
3220
|
-
let isNew = decision.kind === "new";
|
|
3221
|
-
const existingGroup = groups.get(slug);
|
|
3222
|
-
if (existingGroup) {
|
|
3223
|
-
existingGroup.facts.push(f);
|
|
3224
|
-
} else {
|
|
3225
|
-
if (isNew && listPageSlugs().includes(slug)) isNew = false;
|
|
3226
|
-
groups.set(slug, { slug, isNew, facts: [f] });
|
|
3227
|
-
}
|
|
3228
|
-
}
|
|
3229
|
-
const knownTitles = new Set(pages.map((p) => p.title));
|
|
3230
|
-
for (const group of groups.values()) {
|
|
3231
|
-
knownTitles.add(readPage(group.slug)?.title ?? deriveTitle(group.facts[0].content, group.slug));
|
|
3232
|
-
}
|
|
3233
|
-
const changed = [];
|
|
3234
|
-
for (const group of groups.values()) {
|
|
3235
|
-
const existing = readPage(group.slug);
|
|
3236
|
-
const title = existing?.title ?? deriveTitle(group.facts[0].content, group.slug);
|
|
3237
|
-
const prompt = buildPagePrompt(
|
|
3238
|
-
title,
|
|
3239
|
-
existing?.body ?? "",
|
|
3240
|
-
group.facts.map((f) => f.content),
|
|
3241
|
-
[...knownTitles].filter((t) => t !== title)
|
|
3242
|
-
);
|
|
3243
|
-
const res = await runMaintainer(maintainer, cfg, prompt);
|
|
3244
|
-
if (!res.ok || !res.text.trim()) {
|
|
3245
|
-
continue;
|
|
3246
|
-
}
|
|
3247
|
-
const body = stripFrontmatterEcho(res.text);
|
|
3248
|
-
const prevSources = existing?.sources ?? [];
|
|
3249
|
-
const page = {
|
|
3250
|
-
slug: group.slug,
|
|
3251
|
-
title,
|
|
3252
|
-
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
3253
|
-
sources: dedupe([...prevSources, ...group.facts.map((f) => f.id)]),
|
|
3254
|
-
body
|
|
3255
|
-
};
|
|
3256
|
-
writePage(page);
|
|
3257
|
-
changed.push(page);
|
|
3258
|
-
if (existing) summary.updated++;
|
|
3259
|
-
else summary.created++;
|
|
3260
|
-
summary.factsIntegrated += group.facts.length;
|
|
3261
|
-
}
|
|
3262
|
-
pages = loadPages();
|
|
3263
|
-
writeIndex(pages);
|
|
3264
|
-
await indexPagesForRecall(changed);
|
|
3265
|
-
return summary;
|
|
3266
|
-
}
|
|
3267
|
-
function dedupe(a) {
|
|
3268
|
-
return [...new Set(a)];
|
|
3269
|
-
}
|
|
3270
|
-
function deriveTitle(content, slug) {
|
|
3271
|
-
const fromSlug = slug.split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
3272
|
-
return fromSlug || content.slice(0, 40);
|
|
3273
|
-
}
|
|
3274
|
-
function stripFrontmatterEcho(text) {
|
|
3275
|
-
let s = text.trim();
|
|
3276
|
-
s = s.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
3277
|
-
if (s.startsWith("---")) {
|
|
3278
|
-
const end = s.indexOf("\n---", 3);
|
|
3279
|
-
if (end >= 0) s = s.slice(end + 4).trim();
|
|
3280
|
-
}
|
|
3281
|
-
return s;
|
|
3282
|
-
}
|
|
3283
3331
|
async function syncCmd(cfg) {
|
|
3284
|
-
const
|
|
3285
|
-
|
|
3286
|
-
const fresh = all.filter((f) => f.ts > state.lastSyncTs);
|
|
3287
|
-
if (fresh.length === 0) {
|
|
3332
|
+
const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
|
|
3333
|
+
if (res.status === "nothing") {
|
|
3288
3334
|
console.log(c.dim("\n No new facts since the last sync. Nothing to do.\n"));
|
|
3289
3335
|
return;
|
|
3290
3336
|
}
|
|
3291
|
-
|
|
3292
|
-
|
|
3337
|
+
if (res.status === "no-maintainer") {
|
|
3338
|
+
if (res.report?.note) console.log(c.dim("\n " + res.report.note));
|
|
3293
3339
|
console.log(c.red("\n No maintainer available."));
|
|
3294
3340
|
console.log(c.dim(" Configure a brain (holt init) or set wiki.maintainer to local with a pulled model.\n"));
|
|
3295
3341
|
return;
|
|
3296
3342
|
}
|
|
3297
|
-
|
|
3298
|
-
console.log("\n" +
|
|
3299
|
-
|
|
3300
|
-
const newest = Math.max(state.lastSyncTs, ...fresh.map((f) => f.ts));
|
|
3301
|
-
saveSyncMarker(newest);
|
|
3343
|
+
const maintainer = res.maintainer;
|
|
3344
|
+
if (res.report?.note) console.log(c.dim("\n " + res.report.note));
|
|
3345
|
+
console.log("\n" + c.dim(` Integrated ${res.freshFacts} new fact${res.freshFacts === 1 ? "" : "s"} via ${maintainer.label}...`));
|
|
3302
3346
|
console.log("\n" + c.accent("Wiki synced") + c.dim(" (this folder)"));
|
|
3303
|
-
console.log(` facts integrated ${
|
|
3304
|
-
console.log(` pages created ${
|
|
3305
|
-
console.log(` pages updated ${
|
|
3306
|
-
console.log(` maintainer ${maintainer.label}${report?.fellBack ? c.dim(" (fell back)") : ""}`);
|
|
3347
|
+
console.log(` facts integrated ${res.factsIntegrated}`);
|
|
3348
|
+
console.log(` pages created ${res.created}`);
|
|
3349
|
+
console.log(` pages updated ${res.updated}`);
|
|
3350
|
+
console.log(` maintainer ${maintainer.label}${res.report?.fellBack ? c.dim(" (fell back)") : ""}`);
|
|
3307
3351
|
console.log(c.dim(`
|
|
3308
3352
|
Wiki: ${wikiDir()} (open in Obsidian to browse [[links]])
|
|
3309
3353
|
`));
|
|
@@ -3330,7 +3374,7 @@ async function rebuildCmd(cfg, ask) {
|
|
|
3330
3374
|
clearWikiRecallRows();
|
|
3331
3375
|
console.log(c.dim(`
|
|
3332
3376
|
Rebuilding from ${facts.length} facts via ${maintainer.label}...`));
|
|
3333
|
-
const summary = await foldFacts(cfg, maintainer, facts);
|
|
3377
|
+
const { summary } = await foldFacts(cfg, maintainer, facts);
|
|
3334
3378
|
saveSyncMarker(Math.max(0, ...facts.map((f) => f.ts)));
|
|
3335
3379
|
console.log("\n" + c.accent("Wiki rebuilt"));
|
|
3336
3380
|
console.log(` pages ${summary.created}`);
|
|
@@ -3355,6 +3399,50 @@ function buildLintPrompt(pages) {
|
|
|
3355
3399
|
}
|
|
3356
3400
|
return lines.join("\n");
|
|
3357
3401
|
}
|
|
3402
|
+
var FIX_BEGIN = "<<<HOLT-PAGE ";
|
|
3403
|
+
var FIX_END = "<<<HOLT-END>>>";
|
|
3404
|
+
function buildFixPrompt(pages) {
|
|
3405
|
+
const lines = [
|
|
3406
|
+
"You maintain a personal knowledge wiki. Fix quality issues across the pages",
|
|
3407
|
+
"below: resolve CONTRADICTIONS (keep the most recent/consistent statement),",
|
|
3408
|
+
"merge DUPLICATE passages, and add obvious missing [[wikilinks]]. Do not invent",
|
|
3409
|
+
"new facts; only reconcile and de-duplicate what is already written.",
|
|
3410
|
+
"",
|
|
3411
|
+
"Return EVERY page, even unchanged ones, using EXACTLY this format and nothing",
|
|
3412
|
+
"else (no prose, no code fences, no frontmatter):",
|
|
3413
|
+
"",
|
|
3414
|
+
`${FIX_BEGIN}<slug>>>>`,
|
|
3415
|
+
'<corrected page body in Markdown, ending with a "## Related" section of [[links]]>',
|
|
3416
|
+
FIX_END,
|
|
3417
|
+
"",
|
|
3418
|
+
"Pages:"
|
|
3419
|
+
];
|
|
3420
|
+
for (const pg of pages) {
|
|
3421
|
+
lines.push("", `${FIX_BEGIN}${pg.slug}>>>`, `### ${pg.title}`, pg.body.trim(), FIX_END);
|
|
3422
|
+
}
|
|
3423
|
+
return lines.join("\n");
|
|
3424
|
+
}
|
|
3425
|
+
function parseFixReply(raw) {
|
|
3426
|
+
const out = /* @__PURE__ */ new Map();
|
|
3427
|
+
const text = raw || "";
|
|
3428
|
+
let i = 0;
|
|
3429
|
+
while (true) {
|
|
3430
|
+
const begin = text.indexOf(FIX_BEGIN, i);
|
|
3431
|
+
if (begin < 0) break;
|
|
3432
|
+
const slugStart = begin + FIX_BEGIN.length;
|
|
3433
|
+
const slugEnd = text.indexOf(">>>", slugStart);
|
|
3434
|
+
if (slugEnd < 0) break;
|
|
3435
|
+
const slug = text.slice(slugStart, slugEnd).trim();
|
|
3436
|
+
const bodyStart = slugEnd + 3;
|
|
3437
|
+
const end = text.indexOf(FIX_END, bodyStart);
|
|
3438
|
+
if (end < 0) break;
|
|
3439
|
+
let body = text.slice(bodyStart, end).trim();
|
|
3440
|
+
body = body.replace(/^###[^\n]*\n+/, "").trim();
|
|
3441
|
+
if (slug && body) out.set(slug, body);
|
|
3442
|
+
i = end + FIX_END.length;
|
|
3443
|
+
}
|
|
3444
|
+
return out;
|
|
3445
|
+
}
|
|
3358
3446
|
async function lintCmd(cfg, fix) {
|
|
3359
3447
|
const pages = loadPages();
|
|
3360
3448
|
if (pages.length === 0) {
|
|
@@ -3366,20 +3454,57 @@ async function lintCmd(cfg, fix) {
|
|
|
3366
3454
|
console.log(c.red("\n No maintainer available. Configure a brain or a local model first.\n"));
|
|
3367
3455
|
return;
|
|
3368
3456
|
}
|
|
3369
|
-
if (report?.note) console.log(c.dim("\n " + report.note));
|
|
3370
|
-
if (fix) {
|
|
3371
|
-
console.log(c.dim(
|
|
3372
|
-
|
|
3373
|
-
|
|
3457
|
+
if (report?.note) console.log(c.dim("\n " + report.note));
|
|
3458
|
+
if (!fix) {
|
|
3459
|
+
console.log(c.dim(`
|
|
3460
|
+
Auditing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
|
|
3461
|
+
`));
|
|
3462
|
+
const res2 = await runMaintainer(maintainer, cfg, buildLintPrompt(pages));
|
|
3463
|
+
if (!res2.ok || !res2.text.trim()) {
|
|
3464
|
+
console.log(c.dim(" Lint could not produce a report (maintainer returned nothing).\n"));
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
console.log(res2.text.trim() + "\n");
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
console.log(c.dim("\n --fix will rewrite affected pages. Put .holt/wiki under git or back it up first;"));
|
|
3471
|
+
console.log(c.dim(' a bad fix is fully recoverable with "holt wiki rebuild".'));
|
|
3374
3472
|
console.log(c.dim(`
|
|
3375
|
-
Auditing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
|
|
3473
|
+
Auditing + fixing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
|
|
3376
3474
|
`));
|
|
3377
|
-
const res = await runMaintainer(maintainer, cfg,
|
|
3475
|
+
const res = await runMaintainer(maintainer, cfg, buildFixPrompt(pages));
|
|
3378
3476
|
if (!res.ok || !res.text.trim()) {
|
|
3379
|
-
console.log(c.dim(" Lint could not produce
|
|
3477
|
+
console.log(c.dim(" Lint --fix could not produce corrections (maintainer returned nothing). Files untouched.\n"));
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
const corrected = parseFixReply(res.text);
|
|
3481
|
+
if (corrected.size === 0) {
|
|
3482
|
+
console.log(c.dim(" No parseable corrections returned. Files untouched.\n"));
|
|
3380
3483
|
return;
|
|
3381
3484
|
}
|
|
3382
|
-
|
|
3485
|
+
const byPage = new Map(pages.map((p) => [p.slug, p]));
|
|
3486
|
+
const applied = [];
|
|
3487
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3488
|
+
for (const [slug, body] of corrected) {
|
|
3489
|
+
const page = byPage.get(slug);
|
|
3490
|
+
if (!page) continue;
|
|
3491
|
+
if (body.trim() === page.body.trim()) continue;
|
|
3492
|
+
try {
|
|
3493
|
+
writePage({ ...page, body: body.trim(), updated: today });
|
|
3494
|
+
applied.push(page.title);
|
|
3495
|
+
} catch {
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
if (applied.length === 0) {
|
|
3499
|
+
console.log(c.dim(" Lint found nothing to change. Pages already clean.\n"));
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
console.log("\n" + c.accent("Wiki lint applied") + c.dim(" (this folder)"));
|
|
3503
|
+
console.log(` pages rewritten ${applied.length}`);
|
|
3504
|
+
for (const t of applied) console.log(c.dim(` - ${t}`));
|
|
3505
|
+
console.log(c.dim(`
|
|
3506
|
+
Recover any page with "holt wiki rebuild". Wiki: ${wikiDir()}
|
|
3507
|
+
`));
|
|
3383
3508
|
}
|
|
3384
3509
|
function listCmd() {
|
|
3385
3510
|
const slugs = listPageSlugs();
|
|
@@ -3420,6 +3545,7 @@ async function statusCmd(cfg) {
|
|
|
3420
3545
|
console.log("\n" + c.accent("Holt wiki") + c.dim(" (this folder)"));
|
|
3421
3546
|
console.log(` maintainer ${cfg.wiki.maintainer}`);
|
|
3422
3547
|
console.log(` localModel ${cfg.wiki.localModel}`);
|
|
3548
|
+
console.log(` auto-sync ${cfg.wiki.autoSync ? c.green("on") : c.dim("off")}${cfg.wiki.autoSync ? "" : c.dim(" (holt wiki auto on)")}`);
|
|
3423
3549
|
console.log(` pages ${stats.pages}`);
|
|
3424
3550
|
console.log(` size ${(stats.bytes / 1024).toFixed(1)} KB (./.holt/wiki/)`);
|
|
3425
3551
|
console.log(` last sync ${lastSync}`);
|
|
@@ -3434,11 +3560,33 @@ async function statusCmd(cfg) {
|
|
|
3434
3560
|
if (rec.discourageLocal) console.log(c.dim(" Local maintenance is discouraged at this RAM; the brain maintainer is recommended."));
|
|
3435
3561
|
console.log(c.dim(` Pull it with: ${rec.pull}`));
|
|
3436
3562
|
console.log(c.dim("\n holt wiki sync fold new facts into pages"));
|
|
3563
|
+
console.log(c.dim(" holt wiki auto [on|off] auto-sync the wiki when a session ends"));
|
|
3437
3564
|
console.log(c.dim(" holt wiki rebuild regenerate the whole wiki from facts"));
|
|
3438
|
-
console.log(c.dim(" holt wiki lint
|
|
3565
|
+
console.log(c.dim(" holt wiki lint [--fix] audit (and with --fix, apply) contradiction/duplicate fixes"));
|
|
3439
3566
|
console.log(c.dim(" holt wiki list / show <p> browse pages"));
|
|
3440
3567
|
console.log(c.dim(" holt wiki open open in your default app (Obsidian reads it natively)\n"));
|
|
3441
3568
|
}
|
|
3569
|
+
function autoCmd(cfg, arg) {
|
|
3570
|
+
const want = (arg || "").toLowerCase();
|
|
3571
|
+
if (want === "") {
|
|
3572
|
+
const on2 = cfg.wiki.autoSync === true;
|
|
3573
|
+
console.log("\n" + c.accent("Wiki auto-sync") + c.dim(" (this folder)"));
|
|
3574
|
+
console.log(` ${on2 ? c.green("on") : c.dim("off")}`);
|
|
3575
|
+
console.log(c.dim(" usage: holt wiki auto on | off"));
|
|
3576
|
+
console.log(c.dim(" When on, the wiki folds new facts in automatically after a chat session"));
|
|
3577
|
+
console.log(c.dim(' or when the Claude Code Stop hook captures facts. No manual "holt wiki sync".\n'));
|
|
3578
|
+
return;
|
|
3579
|
+
}
|
|
3580
|
+
if (want !== "on" && want !== "off") {
|
|
3581
|
+
console.log(c.dim("\n usage: holt wiki auto on | off\n"));
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
cfg.wiki.autoSync = want === "on";
|
|
3585
|
+
saveConfig(cfg);
|
|
3586
|
+
console.log(
|
|
3587
|
+
want === "on" ? c.green("\n wiki auto-sync: on") + c.dim(" (syncs at session end)\n") : c.dim("\n wiki auto-sync: off\n")
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3442
3590
|
function setupCmd(cfg) {
|
|
3443
3591
|
const rec = recommendLocalModel();
|
|
3444
3592
|
console.log("\n" + c.accent("Local wiki maintainer: model recommendation"));
|
|
@@ -3505,6 +3653,9 @@ async function wikiCmd(sub, rest = []) {
|
|
|
3505
3653
|
case "sync":
|
|
3506
3654
|
await syncCmd(cfg);
|
|
3507
3655
|
break;
|
|
3656
|
+
case "auto":
|
|
3657
|
+
autoCmd(cfg, rest[0] ?? "");
|
|
3658
|
+
break;
|
|
3508
3659
|
case "rebuild":
|
|
3509
3660
|
await rebuildCmd(cfg, ask);
|
|
3510
3661
|
break;
|
|
@@ -3530,7 +3681,7 @@ async function wikiCmd(sub, rest = []) {
|
|
|
3530
3681
|
break;
|
|
3531
3682
|
default:
|
|
3532
3683
|
console.error(`
|
|
3533
|
-
Unknown wiki subcommand: "${sub}". Use: sync | rebuild | lint | list | show <page> | open | status | setup
|
|
3684
|
+
Unknown wiki subcommand: "${sub}". Use: sync | auto | rebuild | lint | list | show <page> | open | status | setup
|
|
3534
3685
|
`);
|
|
3535
3686
|
process.exitCode = 1;
|
|
3536
3687
|
}
|
|
@@ -3541,19 +3692,171 @@ async function wikiCmd(sub, rest = []) {
|
|
|
3541
3692
|
|
|
3542
3693
|
// src/commands/skill.ts
|
|
3543
3694
|
import {
|
|
3544
|
-
mkdirSync as
|
|
3545
|
-
writeFileSync as
|
|
3546
|
-
readFileSync as
|
|
3547
|
-
existsSync as
|
|
3695
|
+
mkdirSync as mkdirSync9,
|
|
3696
|
+
writeFileSync as writeFileSync10,
|
|
3697
|
+
readFileSync as readFileSync13,
|
|
3698
|
+
existsSync as existsSync12,
|
|
3548
3699
|
readdirSync as readdirSync3,
|
|
3549
|
-
statSync as
|
|
3700
|
+
statSync as statSync5,
|
|
3550
3701
|
rmSync as rmSync5,
|
|
3551
3702
|
cpSync,
|
|
3552
3703
|
mkdtempSync
|
|
3553
3704
|
} from "fs";
|
|
3554
|
-
import { join as
|
|
3705
|
+
import { join as join12, resolve as resolve3 } from "path";
|
|
3555
3706
|
import { tmpdir } from "os";
|
|
3556
3707
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
3708
|
+
|
|
3709
|
+
// src/registry.ts
|
|
3710
|
+
import { readFileSync as readFileSync12, writeFileSync as writeFileSync9, existsSync as existsSync11, mkdirSync as mkdirSync8, statSync as statSync4 } from "fs";
|
|
3711
|
+
import { join as join11, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
3712
|
+
import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url";
|
|
3713
|
+
var DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/holt-os/registry/main/registry.json";
|
|
3714
|
+
var REGISTRY_REPO_URL = "https://github.com/holt-os/registry";
|
|
3715
|
+
var CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
3716
|
+
function registryCachePath() {
|
|
3717
|
+
return join11(GLOBAL_DIR, "registry-cache.json");
|
|
3718
|
+
}
|
|
3719
|
+
function registryUrl() {
|
|
3720
|
+
const env = (process.env.HOLT_REGISTRY_URL || "").trim();
|
|
3721
|
+
return env || DEFAULT_REGISTRY_URL;
|
|
3722
|
+
}
|
|
3723
|
+
function coerceRegistry(value) {
|
|
3724
|
+
if (!value || typeof value !== "object") return null;
|
|
3725
|
+
const obj = value;
|
|
3726
|
+
const skillsRaw = Array.isArray(obj.skills) ? obj.skills : [];
|
|
3727
|
+
const skills = [];
|
|
3728
|
+
for (const row2 of skillsRaw) {
|
|
3729
|
+
if (!row2 || typeof row2 !== "object") continue;
|
|
3730
|
+
const r = row2;
|
|
3731
|
+
const name = typeof r.name === "string" ? r.name.trim() : "";
|
|
3732
|
+
const source = typeof r.source === "string" ? r.source.trim() : "";
|
|
3733
|
+
if (!name || !source) continue;
|
|
3734
|
+
const description = typeof r.description === "string" ? r.description.trim() : "";
|
|
3735
|
+
const author = typeof r.author === "string" ? r.author.trim() : "";
|
|
3736
|
+
const tags = Array.isArray(r.tags) ? r.tags.filter((t) => typeof t === "string").map((t) => t.trim()).filter(Boolean) : void 0;
|
|
3737
|
+
const entry = { name, description, source, author };
|
|
3738
|
+
if (tags && tags.length) entry.tags = tags;
|
|
3739
|
+
skills.push(entry);
|
|
3740
|
+
}
|
|
3741
|
+
const version = typeof obj.version === "number" ? obj.version : 1;
|
|
3742
|
+
return { version, skills };
|
|
3743
|
+
}
|
|
3744
|
+
function parseRegistry(text) {
|
|
3745
|
+
let parsed;
|
|
3746
|
+
try {
|
|
3747
|
+
parsed = JSON.parse(text);
|
|
3748
|
+
} catch {
|
|
3749
|
+
return null;
|
|
3750
|
+
}
|
|
3751
|
+
return coerceRegistry(parsed);
|
|
3752
|
+
}
|
|
3753
|
+
function readCache() {
|
|
3754
|
+
try {
|
|
3755
|
+
const path = registryCachePath();
|
|
3756
|
+
if (!existsSync11(path)) return null;
|
|
3757
|
+
const raw = JSON.parse(readFileSync12(path, "utf8"));
|
|
3758
|
+
if (!raw || typeof raw.fetchedAt !== "number" || typeof raw.url !== "string") return null;
|
|
3759
|
+
const registry = coerceRegistry(raw.registry);
|
|
3760
|
+
if (!registry) return null;
|
|
3761
|
+
return { fetchedAt: raw.fetchedAt, url: raw.url, registry };
|
|
3762
|
+
} catch {
|
|
3763
|
+
return null;
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
function writeCache(url, registry) {
|
|
3767
|
+
try {
|
|
3768
|
+
mkdirSync8(GLOBAL_DIR, { recursive: true });
|
|
3769
|
+
const body = { fetchedAt: Date.now(), url, registry };
|
|
3770
|
+
writeFileSync9(registryCachePath(), JSON.stringify(body, null, 2) + "\n", "utf8");
|
|
3771
|
+
} catch {
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
function localPathFor(loc) {
|
|
3775
|
+
if (loc.startsWith("file://")) {
|
|
3776
|
+
try {
|
|
3777
|
+
return fileURLToPath2(loc);
|
|
3778
|
+
} catch {
|
|
3779
|
+
return null;
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
if (/^https?:\/\//i.test(loc)) return null;
|
|
3783
|
+
return isAbsolute2(loc) ? loc : resolve2(loc);
|
|
3784
|
+
}
|
|
3785
|
+
var UNREACHABLE_MSG = "no registry reachable; set HOLT_REGISTRY_URL or the community registry is not live yet";
|
|
3786
|
+
function loadLocal(path) {
|
|
3787
|
+
try {
|
|
3788
|
+
if (!existsSync11(path)) {
|
|
3789
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3790
|
+
}
|
|
3791
|
+
const registry = parseRegistry(readFileSync12(path, "utf8"));
|
|
3792
|
+
if (!registry) {
|
|
3793
|
+
return { registry: null, fromCache: false, error: "registry file is not valid JSON" };
|
|
3794
|
+
}
|
|
3795
|
+
return { registry, fromCache: false };
|
|
3796
|
+
} catch {
|
|
3797
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
async function loadRemote(url) {
|
|
3801
|
+
try {
|
|
3802
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
|
|
3803
|
+
if (!res.ok) {
|
|
3804
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3805
|
+
}
|
|
3806
|
+
const registry = parseRegistry(await res.text());
|
|
3807
|
+
if (!registry) {
|
|
3808
|
+
return { registry: null, fromCache: false, error: "registry response is not valid JSON" };
|
|
3809
|
+
}
|
|
3810
|
+
return { registry, fromCache: false };
|
|
3811
|
+
} catch {
|
|
3812
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
async function loadRegistry(opts = {}) {
|
|
3816
|
+
const url = registryUrl();
|
|
3817
|
+
const cached = readCache();
|
|
3818
|
+
const fresh = cached && cached.url === url && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
|
|
3819
|
+
if (!opts.refresh && fresh && cached) {
|
|
3820
|
+
return { registry: cached.registry, fromCache: true };
|
|
3821
|
+
}
|
|
3822
|
+
const local = localPathFor(url);
|
|
3823
|
+
const result = local ? loadLocal(local) : await loadRemote(url);
|
|
3824
|
+
if (result.registry) {
|
|
3825
|
+
writeCache(url, result.registry);
|
|
3826
|
+
return result;
|
|
3827
|
+
}
|
|
3828
|
+
if (cached && cached.url === url) {
|
|
3829
|
+
return { registry: cached.registry, fromCache: true, error: result.error };
|
|
3830
|
+
}
|
|
3831
|
+
return result;
|
|
3832
|
+
}
|
|
3833
|
+
function searchSkills(skills, query) {
|
|
3834
|
+
const q = query.trim().toLowerCase();
|
|
3835
|
+
if (!q) return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
|
3836
|
+
const rank = (s) => {
|
|
3837
|
+
if (s.name.toLowerCase().includes(q)) return 0;
|
|
3838
|
+
if (s.description.toLowerCase().includes(q)) return 1;
|
|
3839
|
+
if ((s.tags || []).some((t) => t.toLowerCase().includes(q))) return 2;
|
|
3840
|
+
return 3;
|
|
3841
|
+
};
|
|
3842
|
+
return skills.map((s) => ({ s, r: rank(s) })).filter((x) => x.r < 3).sort((a, b) => a.r !== b.r ? a.r - b.r : a.s.name.localeCompare(b.s.name)).map((x) => x.s);
|
|
3843
|
+
}
|
|
3844
|
+
function resolveByName(skills, name) {
|
|
3845
|
+
const want = name.trim().toLowerCase();
|
|
3846
|
+
return skills.find((s) => s.name.toLowerCase() === want) || null;
|
|
3847
|
+
}
|
|
3848
|
+
function buildPublishEntry(input) {
|
|
3849
|
+
const entry = {
|
|
3850
|
+
name: input.name,
|
|
3851
|
+
description: input.description,
|
|
3852
|
+
source: input.source,
|
|
3853
|
+
author: input.author
|
|
3854
|
+
};
|
|
3855
|
+
if (input.tags && input.tags.length) entry.tags = input.tags;
|
|
3856
|
+
return JSON.stringify(entry, null, 2).split("\n").map((l) => " " + l).join("\n");
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// src/commands/skill.ts
|
|
3557
3860
|
function usage() {
|
|
3558
3861
|
console.log(c.dim([
|
|
3559
3862
|
"",
|
|
@@ -3562,11 +3865,14 @@ function usage() {
|
|
|
3562
3865
|
" holt skill list list installed skills",
|
|
3563
3866
|
" holt skill show <name> print a skill",
|
|
3564
3867
|
" holt skill create <name> [--global] scaffold a new skill",
|
|
3565
|
-
" holt skill
|
|
3868
|
+
" holt skill search <query> find skills in the registry",
|
|
3869
|
+
" holt skill add <src|name> [--global] install from a git URL, path, or registry name",
|
|
3870
|
+
" holt skill publish [<name>] prepare a skill for the registry (prints a PR entry)",
|
|
3566
3871
|
" holt skill remove <name> delete a skill",
|
|
3567
3872
|
"",
|
|
3568
3873
|
" --global installs into ~/.holt/skills (available in every folder).",
|
|
3569
3874
|
" Without it, skills live in this folder at ./.holt/skills.",
|
|
3875
|
+
" search/add-by-name use the registry (HOLT_REGISTRY_URL or the community index).",
|
|
3570
3876
|
""
|
|
3571
3877
|
].join("\n")));
|
|
3572
3878
|
}
|
|
@@ -3597,12 +3903,12 @@ function isGitUrl(s) {
|
|
|
3597
3903
|
return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
|
|
3598
3904
|
}
|
|
3599
3905
|
function findSkillDir(root) {
|
|
3600
|
-
if (
|
|
3906
|
+
if (existsSync12(join12(root, "SKILL.md"))) return root;
|
|
3601
3907
|
let subs = [];
|
|
3602
3908
|
try {
|
|
3603
3909
|
subs = readdirSync3(root).filter((f) => {
|
|
3604
3910
|
try {
|
|
3605
|
-
return
|
|
3911
|
+
return statSync5(join12(root, f)).isDirectory();
|
|
3606
3912
|
} catch {
|
|
3607
3913
|
return false;
|
|
3608
3914
|
}
|
|
@@ -3610,8 +3916,8 @@ function findSkillDir(root) {
|
|
|
3610
3916
|
} catch {
|
|
3611
3917
|
return null;
|
|
3612
3918
|
}
|
|
3613
|
-
const withSkill = subs.filter((f) =>
|
|
3614
|
-
if (withSkill.length === 1) return
|
|
3919
|
+
const withSkill = subs.filter((f) => existsSync12(join12(root, f, "SKILL.md")));
|
|
3920
|
+
if (withSkill.length === 1) return join12(root, withSkill[0]);
|
|
3615
3921
|
return null;
|
|
3616
3922
|
}
|
|
3617
3923
|
function cmdList() {
|
|
@@ -3622,14 +3928,15 @@ function cmdList() {
|
|
|
3622
3928
|
}
|
|
3623
3929
|
const nameW = Math.max(4, ...skills.map((s) => s.name.length));
|
|
3624
3930
|
const scopeW = Math.max(5, ...skills.map((s) => s.scope.length));
|
|
3625
|
-
console.log("\n" + c.accent("Skills") + c.dim(" (workspace shadows global on name clash)"));
|
|
3931
|
+
console.log("\n" + c.accent("Skills") + c.dim(" (workspace shadows global shadows builtin on name clash)"));
|
|
3626
3932
|
console.log(
|
|
3627
3933
|
" " + c.dim("name".padEnd(nameW)) + " " + c.dim("scope".padEnd(scopeW)) + " " + c.dim("description")
|
|
3628
3934
|
);
|
|
3629
3935
|
for (const s of skills) {
|
|
3630
3936
|
const desc = s.description.length > 68 ? s.description.slice(0, 67) + "\u2026" : s.description;
|
|
3937
|
+
const scopeCol = s.scope === "workspace" ? c.cyan(s.scope.padEnd(scopeW)) : s.scope === "builtin" ? c.accent(s.scope.padEnd(scopeW)) : c.dim(s.scope.padEnd(scopeW));
|
|
3631
3938
|
console.log(
|
|
3632
|
-
" " + c.bold(s.name.padEnd(nameW)) + " " +
|
|
3939
|
+
" " + c.bold(s.name.padEnd(nameW)) + " " + scopeCol + " " + (desc || c.dim("(no description)"))
|
|
3633
3940
|
);
|
|
3634
3941
|
}
|
|
3635
3942
|
console.log(c.dim("\n holt skill show <name> print a skill"));
|
|
@@ -3651,7 +3958,7 @@ function cmdShow(name) {
|
|
|
3651
3958
|
const { skill, body } = loaded;
|
|
3652
3959
|
console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
|
|
3653
3960
|
if (skill.description) console.log(" " + skill.description);
|
|
3654
|
-
console.log(c.dim(" " +
|
|
3961
|
+
console.log(c.dim(" " + join12(skill.dir, "SKILL.md")));
|
|
3655
3962
|
console.log("");
|
|
3656
3963
|
console.log(c.dim(body));
|
|
3657
3964
|
console.log("");
|
|
@@ -3663,60 +3970,55 @@ function cmdCreate(name, global) {
|
|
|
3663
3970
|
return;
|
|
3664
3971
|
}
|
|
3665
3972
|
const scope = global ? "global" : "workspace";
|
|
3666
|
-
const dir =
|
|
3667
|
-
if (
|
|
3973
|
+
const dir = join12(skillsRoot(scope), clean);
|
|
3974
|
+
if (existsSync12(dir)) {
|
|
3668
3975
|
console.error(c.red(`
|
|
3669
3976
|
A ${scope} skill named "${clean}" already exists.`));
|
|
3670
3977
|
console.error(c.dim(" " + dir + "\n"));
|
|
3671
3978
|
process.exitCode = 1;
|
|
3672
3979
|
return;
|
|
3673
3980
|
}
|
|
3674
|
-
|
|
3675
|
-
|
|
3981
|
+
mkdirSync9(dir, { recursive: true });
|
|
3982
|
+
writeFileSync10(join12(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
|
|
3676
3983
|
console.log(c.green(`
|
|
3677
3984
|
Created ${scope} skill "${clean}".`));
|
|
3678
|
-
console.log(c.dim(" " +
|
|
3985
|
+
console.log(c.dim(" " + join12(dir, "SKILL.md")));
|
|
3679
3986
|
console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
|
|
3680
3987
|
}
|
|
3681
|
-
function
|
|
3682
|
-
if (!source) {
|
|
3683
|
-
console.log(c.dim("\n Usage: holt skill add <git-url|path> [--global]\n"));
|
|
3684
|
-
return;
|
|
3685
|
-
}
|
|
3686
|
-
const scope = global ? "global" : "workspace";
|
|
3988
|
+
function installFromSource(source, scope) {
|
|
3687
3989
|
let fetched = "";
|
|
3688
3990
|
let tempDir = "";
|
|
3689
3991
|
try {
|
|
3690
3992
|
if (isGitUrl(source)) {
|
|
3691
|
-
tempDir = mkdtempSync(
|
|
3993
|
+
tempDir = mkdtempSync(join12(tmpdir(), "holt-skill-"));
|
|
3692
3994
|
console.log(c.dim(`
|
|
3693
3995
|
Cloning ${source} ...`));
|
|
3694
3996
|
const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
|
|
3695
3997
|
if (res.status !== 0) {
|
|
3696
3998
|
console.error(c.red(" Clone failed. Check the URL and that git is installed.\n"));
|
|
3697
3999
|
process.exitCode = 1;
|
|
3698
|
-
return;
|
|
4000
|
+
return false;
|
|
3699
4001
|
}
|
|
3700
4002
|
fetched = tempDir;
|
|
3701
4003
|
} else {
|
|
3702
|
-
fetched =
|
|
3703
|
-
if (!
|
|
4004
|
+
fetched = resolve3(source);
|
|
4005
|
+
if (!existsSync12(fetched)) {
|
|
3704
4006
|
console.error(c.red(`
|
|
3705
4007
|
No such path: ${fetched}
|
|
3706
4008
|
`));
|
|
3707
4009
|
process.exitCode = 1;
|
|
3708
|
-
return;
|
|
4010
|
+
return false;
|
|
3709
4011
|
}
|
|
3710
4012
|
}
|
|
3711
4013
|
const skillDir = findSkillDir(fetched);
|
|
3712
4014
|
if (!skillDir) {
|
|
3713
4015
|
console.error(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
|
|
3714
4016
|
process.exitCode = 1;
|
|
3715
|
-
return;
|
|
4017
|
+
return false;
|
|
3716
4018
|
}
|
|
3717
4019
|
let skillName = "";
|
|
3718
4020
|
try {
|
|
3719
|
-
const { data } = parseFrontmatter(
|
|
4021
|
+
const { data } = parseFrontmatter(readFileSync13(join12(skillDir, "SKILL.md"), "utf8"));
|
|
3720
4022
|
skillName = sanitizeName(data.name || "");
|
|
3721
4023
|
} catch {
|
|
3722
4024
|
skillName = "";
|
|
@@ -3725,25 +4027,26 @@ function cmdAdd(source, global) {
|
|
|
3725
4027
|
if (!skillName) {
|
|
3726
4028
|
console.error(c.red("\n Could not determine a valid skill name.\n"));
|
|
3727
4029
|
process.exitCode = 1;
|
|
3728
|
-
return;
|
|
4030
|
+
return false;
|
|
3729
4031
|
}
|
|
3730
|
-
const dest =
|
|
3731
|
-
if (
|
|
4032
|
+
const dest = join12(skillsRoot(scope), skillName);
|
|
4033
|
+
if (existsSync12(dest)) {
|
|
3732
4034
|
console.error(c.red(`
|
|
3733
4035
|
A ${scope} skill named "${skillName}" already exists.`));
|
|
3734
4036
|
console.error(c.dim(" " + dest + "\n"));
|
|
3735
4037
|
process.exitCode = 1;
|
|
3736
|
-
return;
|
|
4038
|
+
return false;
|
|
3737
4039
|
}
|
|
3738
|
-
|
|
4040
|
+
mkdirSync9(skillsRoot(scope), { recursive: true });
|
|
3739
4041
|
cpSync(skillDir, dest, { recursive: true });
|
|
3740
|
-
const nestedGit =
|
|
3741
|
-
if (
|
|
4042
|
+
const nestedGit = join12(dest, ".git");
|
|
4043
|
+
if (existsSync12(nestedGit)) rmSync5(nestedGit, { recursive: true, force: true });
|
|
3742
4044
|
console.log(c.green(`
|
|
3743
4045
|
Installed ${scope} skill "${skillName}".`));
|
|
3744
|
-
console.log(c.dim(" " +
|
|
4046
|
+
console.log(c.dim(" " + join12(dest, "SKILL.md") + "\n"));
|
|
4047
|
+
return true;
|
|
3745
4048
|
} finally {
|
|
3746
|
-
if (tempDir &&
|
|
4049
|
+
if (tempDir && existsSync12(tempDir)) {
|
|
3747
4050
|
try {
|
|
3748
4051
|
rmSync5(tempDir, { recursive: true, force: true });
|
|
3749
4052
|
} catch {
|
|
@@ -3751,16 +4054,148 @@ function cmdAdd(source, global) {
|
|
|
3751
4054
|
}
|
|
3752
4055
|
}
|
|
3753
4056
|
}
|
|
4057
|
+
async function cmdAdd(source, global, refresh) {
|
|
4058
|
+
if (!source) {
|
|
4059
|
+
console.log(c.dim("\n Usage: holt skill add <git-url|path|name> [--global]\n"));
|
|
4060
|
+
return;
|
|
4061
|
+
}
|
|
4062
|
+
const scope = global ? "global" : "workspace";
|
|
4063
|
+
if (isGitUrl(source) || existsSync12(resolve3(source))) {
|
|
4064
|
+
installFromSource(source, scope);
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
4067
|
+
const { registry, error } = await loadRegistry({ refresh });
|
|
4068
|
+
if (!registry) {
|
|
4069
|
+
console.error(c.red(`
|
|
4070
|
+
"${source}" is not a git URL or a local path, and the registry is unavailable.`));
|
|
4071
|
+
console.error(c.dim(" " + (error || "no registry reachable") + "\n"));
|
|
4072
|
+
process.exitCode = 1;
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
const hit = resolveByName(registry.skills, source);
|
|
4076
|
+
if (!hit) {
|
|
4077
|
+
console.error(c.red(`
|
|
4078
|
+
No skill named "${source}" in the registry.`));
|
|
4079
|
+
console.error(c.dim(' Try "holt skill search ' + source + '" to find one.\n'));
|
|
4080
|
+
process.exitCode = 1;
|
|
4081
|
+
return;
|
|
4082
|
+
}
|
|
4083
|
+
console.log(c.dim(`
|
|
4084
|
+
Resolved "${hit.name}" -> ${hit.source}`));
|
|
4085
|
+
installFromSource(hit.source, scope);
|
|
4086
|
+
}
|
|
4087
|
+
async function cmdSearch(query, refresh) {
|
|
4088
|
+
const { registry, error, fromCache } = await loadRegistry({ refresh });
|
|
4089
|
+
if (!registry) {
|
|
4090
|
+
console.error(c.red("\n Registry unavailable."));
|
|
4091
|
+
console.error(c.dim(" " + (error || "no registry reachable") + "\n"));
|
|
4092
|
+
process.exitCode = 1;
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
const hits = searchSkills(registry.skills, query);
|
|
4096
|
+
if (hits.length === 0) {
|
|
4097
|
+
const what = query.trim() ? `matching "${query.trim()}"` : "in the registry";
|
|
4098
|
+
console.log(c.dim(`
|
|
4099
|
+
No skills ${what}.
|
|
4100
|
+
`));
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
4103
|
+
const nameW = Math.max(4, ...hits.map((s) => s.name.length));
|
|
4104
|
+
const authorW = Math.max(6, ...hits.map((s) => (s.author || "").length));
|
|
4105
|
+
console.log(
|
|
4106
|
+
"\n" + c.accent("Registry") + c.dim(" " + registryUrl() + (fromCache ? " (cached)" : ""))
|
|
4107
|
+
);
|
|
4108
|
+
console.log(
|
|
4109
|
+
" " + c.dim("name".padEnd(nameW)) + " " + c.dim("author".padEnd(authorW)) + " " + c.dim("description")
|
|
4110
|
+
);
|
|
4111
|
+
for (const s of hits) {
|
|
4112
|
+
const desc = s.description.length > 56 ? s.description.slice(0, 55) + "\u2026" : s.description;
|
|
4113
|
+
console.log(
|
|
4114
|
+
" " + c.bold(s.name.padEnd(nameW)) + " " + c.dim((s.author || "").padEnd(authorW)) + " " + (desc || c.dim("(no description)"))
|
|
4115
|
+
);
|
|
4116
|
+
console.log(" " + " ".repeat(nameW) + " " + " ".repeat(authorW) + " " + c.dim(s.source));
|
|
4117
|
+
}
|
|
4118
|
+
console.log(c.dim('\n Install one with "holt skill add <name>".\n'));
|
|
4119
|
+
}
|
|
4120
|
+
function cmdPublish(name) {
|
|
4121
|
+
let skillName = "";
|
|
4122
|
+
let description = "";
|
|
4123
|
+
let sourceHint = "";
|
|
4124
|
+
if (name) {
|
|
4125
|
+
const loaded = loadSkill(name);
|
|
4126
|
+
if (!loaded) {
|
|
4127
|
+
console.error(c.red(`
|
|
4128
|
+
No skill named "${sanitizeName(name)}". Try "holt skill list".
|
|
4129
|
+
`));
|
|
4130
|
+
process.exitCode = 1;
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
let raw = "";
|
|
4134
|
+
try {
|
|
4135
|
+
raw = readFileSync13(join12(loaded.skill.dir, "SKILL.md"), "utf8");
|
|
4136
|
+
} catch {
|
|
4137
|
+
console.error(c.red("\n Could not read the skill's SKILL.md.\n"));
|
|
4138
|
+
process.exitCode = 1;
|
|
4139
|
+
return;
|
|
4140
|
+
}
|
|
4141
|
+
const { data } = parseFrontmatter(raw);
|
|
4142
|
+
skillName = sanitizeName(data.name || loaded.skill.name);
|
|
4143
|
+
description = (data.description || loaded.skill.description || "").trim();
|
|
4144
|
+
} else {
|
|
4145
|
+
const file = join12(process.cwd(), "SKILL.md");
|
|
4146
|
+
if (!existsSync12(file)) {
|
|
4147
|
+
console.error(c.red("\n No SKILL.md in the current directory, and no skill name given."));
|
|
4148
|
+
console.error(c.dim(" Usage: holt skill publish [<name>]\n"));
|
|
4149
|
+
process.exitCode = 1;
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
const { data } = parseFrontmatter(readFileSync13(file, "utf8"));
|
|
4153
|
+
skillName = sanitizeName(data.name || process.cwd().split(/[\\/]/).pop() || "");
|
|
4154
|
+
description = (data.description || "").trim();
|
|
4155
|
+
sourceHint = process.cwd();
|
|
4156
|
+
}
|
|
4157
|
+
if (!skillName || !description) {
|
|
4158
|
+
console.error(c.red("\n This skill is missing a name or description in its SKILL.md frontmatter."));
|
|
4159
|
+
console.error(c.dim(" Both are required to publish. Add: name: <name> and description: <one line>\n"));
|
|
4160
|
+
process.exitCode = 1;
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
const entry = buildPublishEntry({
|
|
4164
|
+
name: skillName,
|
|
4165
|
+
description,
|
|
4166
|
+
source: sourceHint || `https://github.com/<you>/${skillName}`,
|
|
4167
|
+
author: "<your name or handle>",
|
|
4168
|
+
tags: []
|
|
4169
|
+
});
|
|
4170
|
+
console.log("\n" + c.accent(`Publish "${skillName}" to the Holt skill registry`));
|
|
4171
|
+
console.log(c.dim(" The registry is a git-based JSON index. Publishing = opening a PR that adds your entry.\n"));
|
|
4172
|
+
console.log(c.dim(" 1. Push this skill to a public git repo (or note a subpath spec Holt can clone)."));
|
|
4173
|
+
console.log(c.dim(' 2. Add this entry to the "skills" array in registry.json:'));
|
|
4174
|
+
console.log("");
|
|
4175
|
+
console.log(entry);
|
|
4176
|
+
console.log("");
|
|
4177
|
+
console.log(c.dim(' Replace the "source" (your public git URL or path) and "author" fields.'));
|
|
4178
|
+
console.log(c.dim(" 3. Open a pull request against the registry repo:"));
|
|
4179
|
+
console.log(c.dim(" " + REGISTRY_REPO_URL));
|
|
4180
|
+
console.log(c.dim('\n No push happens here. Once merged, anyone can "holt skill add ' + skillName + '".\n'));
|
|
4181
|
+
}
|
|
3754
4182
|
async function cmdRemove(name, ask) {
|
|
3755
4183
|
const clean = sanitizeName(name || "");
|
|
3756
4184
|
if (!clean) {
|
|
3757
4185
|
console.log(c.dim("\n Usage: holt skill remove <name>\n"));
|
|
3758
4186
|
return;
|
|
3759
4187
|
}
|
|
3760
|
-
const wsDir =
|
|
3761
|
-
const globalDir =
|
|
3762
|
-
const target =
|
|
4188
|
+
const wsDir = join12(skillsRoot("workspace"), clean);
|
|
4189
|
+
const globalDir = join12(skillsRoot("global"), clean);
|
|
4190
|
+
const target = existsSync12(wsDir) ? wsDir : existsSync12(globalDir) ? globalDir : "";
|
|
3763
4191
|
if (!target) {
|
|
4192
|
+
if (existsSync12(join12(skillsRoot("builtin"), clean))) {
|
|
4193
|
+
console.error(c.red(`
|
|
4194
|
+
"${clean}" is a built-in skill and cannot be removed; it ships inside Holt.`));
|
|
4195
|
+
console.error(c.dim(" Override it instead: create a workspace or global skill with the same name.\n"));
|
|
4196
|
+
process.exitCode = 1;
|
|
4197
|
+
return;
|
|
4198
|
+
}
|
|
3764
4199
|
console.error(c.dim(`
|
|
3765
4200
|
No skill named "${clean}" found.
|
|
3766
4201
|
`));
|
|
@@ -3784,7 +4219,8 @@ async function skillCmd(sub, rest = []) {
|
|
|
3784
4219
|
return;
|
|
3785
4220
|
}
|
|
3786
4221
|
const global = rest.includes("--global");
|
|
3787
|
-
const
|
|
4222
|
+
const refresh = rest.includes("--refresh");
|
|
4223
|
+
const args = rest.filter((a) => a !== "--global" && a !== "--refresh");
|
|
3788
4224
|
const action = (sub || "").toLowerCase();
|
|
3789
4225
|
try {
|
|
3790
4226
|
switch (action) {
|
|
@@ -3800,9 +4236,16 @@ async function skillCmd(sub, rest = []) {
|
|
|
3800
4236
|
case "new":
|
|
3801
4237
|
cmdCreate(args[0], global);
|
|
3802
4238
|
break;
|
|
4239
|
+
case "search":
|
|
4240
|
+
case "find":
|
|
4241
|
+
await cmdSearch(args.join(" "), refresh);
|
|
4242
|
+
break;
|
|
3803
4243
|
case "add":
|
|
3804
4244
|
case "install":
|
|
3805
|
-
cmdAdd(args[0], global);
|
|
4245
|
+
await cmdAdd(args[0], global, refresh);
|
|
4246
|
+
break;
|
|
4247
|
+
case "publish":
|
|
4248
|
+
cmdPublish(args[0]);
|
|
3806
4249
|
break;
|
|
3807
4250
|
case "remove":
|
|
3808
4251
|
case "rm":
|
|
@@ -3819,10 +4262,74 @@ async function skillCmd(sub, rest = []) {
|
|
|
3819
4262
|
}
|
|
3820
4263
|
|
|
3821
4264
|
// src/commands/graph.ts
|
|
3822
|
-
import { writeFileSync as
|
|
3823
|
-
import { join as
|
|
4265
|
+
import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync10, existsSync as existsSync13 } from "fs";
|
|
4266
|
+
import { join as join14, dirname as dirname4, resolve as resolve4 } from "path";
|
|
3824
4267
|
import { spawn as spawn4 } from "child_process";
|
|
3825
4268
|
|
|
4269
|
+
// src/communities.ts
|
|
4270
|
+
var MAX_ITERS = 30;
|
|
4271
|
+
function detectCommunities(input) {
|
|
4272
|
+
const { nodeIds, edges } = input;
|
|
4273
|
+
const order = nodeIds.slice();
|
|
4274
|
+
const idx = /* @__PURE__ */ new Map();
|
|
4275
|
+
order.forEach((id, i) => idx.set(id, i));
|
|
4276
|
+
const adj = order.map(() => /* @__PURE__ */ new Map());
|
|
4277
|
+
for (const e of edges) {
|
|
4278
|
+
const a = idx.get(e.source);
|
|
4279
|
+
const b = idx.get(e.target);
|
|
4280
|
+
if (a === void 0 || b === void 0 || a === b) continue;
|
|
4281
|
+
const am = adj[a];
|
|
4282
|
+
const bm = adj[b];
|
|
4283
|
+
am.set(b, (am.get(b) || 0) + 1);
|
|
4284
|
+
bm.set(a, (bm.get(a) || 0) + 1);
|
|
4285
|
+
}
|
|
4286
|
+
const label = order.map((_, i) => i);
|
|
4287
|
+
for (let iter = 0; iter < MAX_ITERS; iter++) {
|
|
4288
|
+
let changed = false;
|
|
4289
|
+
for (let i = 0; i < order.length; i++) {
|
|
4290
|
+
const neigh = adj[i];
|
|
4291
|
+
if (neigh.size === 0) continue;
|
|
4292
|
+
const tally = /* @__PURE__ */ new Map();
|
|
4293
|
+
for (const [j, w] of neigh) {
|
|
4294
|
+
const lj = label[j];
|
|
4295
|
+
tally.set(lj, (tally.get(lj) || 0) + w);
|
|
4296
|
+
}
|
|
4297
|
+
let best = label[i];
|
|
4298
|
+
let bestW = -1;
|
|
4299
|
+
for (const [lab, w] of tally) {
|
|
4300
|
+
if (w > bestW || w === bestW && lab < best) {
|
|
4301
|
+
best = lab;
|
|
4302
|
+
bestW = w;
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
if (best !== label[i]) {
|
|
4306
|
+
label[i] = best;
|
|
4307
|
+
changed = true;
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
if (!changed) break;
|
|
4311
|
+
}
|
|
4312
|
+
const remap = /* @__PURE__ */ new Map();
|
|
4313
|
+
const community = /* @__PURE__ */ new Map();
|
|
4314
|
+
const members = /* @__PURE__ */ new Map();
|
|
4315
|
+
for (let i = 0; i < order.length; i++) {
|
|
4316
|
+
const raw = label[i];
|
|
4317
|
+
let cid = remap.get(raw);
|
|
4318
|
+
if (cid === void 0) {
|
|
4319
|
+
cid = String(remap.size);
|
|
4320
|
+
remap.set(raw, cid);
|
|
4321
|
+
}
|
|
4322
|
+
const id = order[i];
|
|
4323
|
+
community.set(id, cid);
|
|
4324
|
+
const arr = members.get(cid);
|
|
4325
|
+
if (arr) arr.push(id);
|
|
4326
|
+
else members.set(cid, [id]);
|
|
4327
|
+
}
|
|
4328
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
4329
|
+
for (const [cid, arr] of members) sizes.set(cid, arr.length);
|
|
4330
|
+
return { community, members, sizes, count: members.size };
|
|
4331
|
+
}
|
|
4332
|
+
|
|
3826
4333
|
// src/graphview.ts
|
|
3827
4334
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
3828
4335
|
"the",
|
|
@@ -4017,6 +4524,49 @@ function mergeGraphs(a, b) {
|
|
|
4017
4524
|
}
|
|
4018
4525
|
return { nodes, edges: [...a.edges, ...b.edges] };
|
|
4019
4526
|
}
|
|
4527
|
+
function buildCodeGraph(files, edges) {
|
|
4528
|
+
const nodes = [];
|
|
4529
|
+
const ids = new Set(files.map((f) => f.id));
|
|
4530
|
+
for (const f of files) {
|
|
4531
|
+
const snippet = f.content.replace(/\s+/g, " ").trim().slice(0, 180);
|
|
4532
|
+
nodes.push({
|
|
4533
|
+
id: f.id,
|
|
4534
|
+
kind: f.kind === "code" ? "file" : "doc",
|
|
4535
|
+
label: shortPath(f.id),
|
|
4536
|
+
path: f.id,
|
|
4537
|
+
ext: f.ext,
|
|
4538
|
+
topDir: f.topDir,
|
|
4539
|
+
snippet
|
|
4540
|
+
});
|
|
4541
|
+
}
|
|
4542
|
+
const out = [];
|
|
4543
|
+
for (const e of edges) {
|
|
4544
|
+
if (!ids.has(e.source) || !ids.has(e.target)) continue;
|
|
4545
|
+
out.push({ source: e.source, target: e.target, kind: e.kind, width: e.kind === "dep" ? 1.5 : 1.5 });
|
|
4546
|
+
}
|
|
4547
|
+
return { nodes, edges: out };
|
|
4548
|
+
}
|
|
4549
|
+
function shortPath(p) {
|
|
4550
|
+
const parts = p.split("/");
|
|
4551
|
+
return parts.length <= 2 ? p : parts.slice(-2).join("/");
|
|
4552
|
+
}
|
|
4553
|
+
function assignCommunities(graph2) {
|
|
4554
|
+
const nodeIds = graph2.nodes.map((n) => n.id);
|
|
4555
|
+
const idSet = new Set(nodeIds);
|
|
4556
|
+
const edges = graph2.edges.filter((e) => idSet.has(e.source) && idSet.has(e.target)).map((e) => ({ source: e.source, target: e.target }));
|
|
4557
|
+
const { community } = detectCommunities({ nodeIds, edges });
|
|
4558
|
+
const degree = /* @__PURE__ */ new Map();
|
|
4559
|
+
for (const id of nodeIds) degree.set(id, 0);
|
|
4560
|
+
for (const e of edges) {
|
|
4561
|
+
degree.set(e.source, (degree.get(e.source) || 0) + 1);
|
|
4562
|
+
degree.set(e.target, (degree.get(e.target) || 0) + 1);
|
|
4563
|
+
}
|
|
4564
|
+
for (const n of graph2.nodes) {
|
|
4565
|
+
n.community = community.get(n.id);
|
|
4566
|
+
n.degree = degree.get(n.id) || 0;
|
|
4567
|
+
}
|
|
4568
|
+
return graph2;
|
|
4569
|
+
}
|
|
4020
4570
|
function escapeForScript(json) {
|
|
4021
4571
|
return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\u0021--");
|
|
4022
4572
|
}
|
|
@@ -4123,7 +4673,9 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4123
4673
|
<div class="row"><span class="swatch" style="background:var(--cyan)"></span>assistant</div>
|
|
4124
4674
|
<div class="row"><span class="swatch" style="background:var(--violet)"></span>concept</div>
|
|
4125
4675
|
<div class="row"><span class="swatch" style="background:#5bd66f"></span>wiki page</div>
|
|
4126
|
-
<div class="row"><span class="swatch" style="background
|
|
4676
|
+
<div class="row"><span class="swatch" style="background:#e2704a"></span>code file</div>
|
|
4677
|
+
<div class="row"><span class="swatch" style="background:#4a90d9"></span>doc file</div>
|
|
4678
|
+
<div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session / community</div>
|
|
4127
4679
|
</div>
|
|
4128
4680
|
<div id="hint">drag to pan · wheel to zoom · click a node · Esc to clear</div>
|
|
4129
4681
|
<div id="empty">This graph has no nodes yet.</div>
|
|
@@ -4173,10 +4725,17 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4173
4725
|
});
|
|
4174
4726
|
|
|
4175
4727
|
function radiusOf(n) {
|
|
4176
|
-
var base = n.kind === "concept" ? 3 : n.kind === "wiki" ? 6 : 5;
|
|
4728
|
+
var base = n.kind === "concept" ? 3 : n.kind === "wiki" ? 6 : (n.kind === "file" || n.kind === "doc") ? 5 : 5;
|
|
4177
4729
|
return base + Math.min(9, Math.sqrt(n._deg) * 1.6);
|
|
4178
4730
|
}
|
|
4179
4731
|
|
|
4732
|
+
// ---- community tint: a stable hue per community id ----
|
|
4733
|
+
function communityHue(cid) {
|
|
4734
|
+
var k = parseInt(cid, 10);
|
|
4735
|
+
if (isNaN(k)) k = 0;
|
|
4736
|
+
return (k * 137.508) % 360; // golden-angle spread, same trick as sessions
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4180
4739
|
// ---- initial layout: spread on a spiral so physics has somewhere to start ----
|
|
4181
4740
|
nodes.forEach(function (n, i) {
|
|
4182
4741
|
var ang = i * 2.399963;
|
|
@@ -4188,7 +4747,7 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4188
4747
|
|
|
4189
4748
|
// ---- physics ----
|
|
4190
4749
|
var REPULSION = 5200, SPRING = 0.02, GRAVITY = 0.015, DAMP = 0.86;
|
|
4191
|
-
var restLen = { sequential: 60, semantic: 45, concept: 70 };
|
|
4750
|
+
var restLen = { sequential: 60, semantic: 45, concept: 70, dep: 55, link: 55, wikilink: 55 };
|
|
4192
4751
|
|
|
4193
4752
|
function step() {
|
|
4194
4753
|
var i, j, n, m, dx, dy, d2, d, f;
|
|
@@ -4254,12 +4813,14 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4254
4813
|
function roleColor(n) {
|
|
4255
4814
|
if (n.kind === "wiki") return "#5bd66f";
|
|
4256
4815
|
if (n.kind === "concept") return "#9a8cff";
|
|
4816
|
+
if (n.kind === "file") return "#e2704a";
|
|
4817
|
+
if (n.kind === "doc") return "#4a90d9";
|
|
4257
4818
|
return n.role === "user" ? "#f0b91e" : "#35d0d6";
|
|
4258
4819
|
}
|
|
4259
4820
|
|
|
4260
4821
|
function matchesQuery(n) {
|
|
4261
4822
|
if (!query) return false;
|
|
4262
|
-
var hay = (n.label || "") + " " + (n.content || "");
|
|
4823
|
+
var hay = (n.label || "") + " " + (n.content || "") + " " + (n.path || "") + " " + (n.snippet || "");
|
|
4263
4824
|
return hay.toLowerCase().indexOf(query) >= 0;
|
|
4264
4825
|
}
|
|
4265
4826
|
|
|
@@ -4279,7 +4840,7 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4279
4840
|
}
|
|
4280
4841
|
ctx.globalAlpha = dim;
|
|
4281
4842
|
ctx.lineWidth = Math.max(0.4, lk.width * view.scale * 0.5);
|
|
4282
|
-
ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : lk.kind === "wikilink" ? "#5bd66f" : "#4a5262";
|
|
4843
|
+
ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : lk.kind === "wikilink" ? "#5bd66f" : lk.kind === "dep" ? "#e2704a" : lk.kind === "link" ? "#4a90d9" : "#4a5262";
|
|
4283
4844
|
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
4284
4845
|
}
|
|
4285
4846
|
ctx.globalAlpha = 1;
|
|
@@ -4293,12 +4854,17 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4293
4854
|
var faded = (highlightSet && !highlightSet.has(n.id)) || (query && !matchesQuery(n));
|
|
4294
4855
|
ctx.globalAlpha = faded ? 0.18 : 1;
|
|
4295
4856
|
|
|
4296
|
-
// session ring
|
|
4857
|
+
// session ring (turns) or community ring (files/docs)
|
|
4297
4858
|
if (n.kind === "turn") {
|
|
4298
4859
|
ctx.beginPath();
|
|
4299
4860
|
ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
|
|
4300
4861
|
ctx.strokeStyle = "hsl(" + sessionHue(n.session) + ",70%,58%)";
|
|
4301
4862
|
ctx.lineWidth = 1.6; ctx.stroke();
|
|
4863
|
+
} else if ((n.kind === "file" || n.kind === "doc") && n.community != null) {
|
|
4864
|
+
ctx.beginPath();
|
|
4865
|
+
ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
|
|
4866
|
+
ctx.strokeStyle = "hsl(" + communityHue(n.community) + ",70%,58%)";
|
|
4867
|
+
ctx.lineWidth = 2; ctx.stroke();
|
|
4302
4868
|
}
|
|
4303
4869
|
|
|
4304
4870
|
ctx.beginPath();
|
|
@@ -4310,10 +4876,13 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4310
4876
|
ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.stroke();
|
|
4311
4877
|
}
|
|
4312
4878
|
|
|
4313
|
-
// concept + wiki labels always; turn labels when zoomed in
|
|
4314
|
-
|
|
4879
|
+
// concept + wiki labels always; file/doc when zoomed a little; turn labels when zoomed in
|
|
4880
|
+
var showLabel = n.kind === "concept" || n.kind === "wiki"
|
|
4881
|
+
|| ((n.kind === "file" || n.kind === "doc") && view.scale > 0.9)
|
|
4882
|
+
|| view.scale > 1.4;
|
|
4883
|
+
if (showLabel) {
|
|
4315
4884
|
ctx.globalAlpha = faded ? 0.25 : 0.9;
|
|
4316
|
-
ctx.fillStyle = n.kind === "wiki" ? "#5bd66f" : "#e7e9ee";
|
|
4885
|
+
ctx.fillStyle = n.kind === "wiki" ? "#5bd66f" : n.kind === "file" ? "#e2704a" : n.kind === "doc" ? "#4a90d9" : "#e7e9ee";
|
|
4317
4886
|
ctx.font = (n.kind === "wiki" ? 12 : n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
|
|
4318
4887
|
ctx.fillText(n.label, p.x + r + 4, p.y + 3);
|
|
4319
4888
|
}
|
|
@@ -4380,6 +4949,8 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4380
4949
|
? ("concept · in " + (hov.freq || 0) + " turns")
|
|
4381
4950
|
: hov.kind === "wiki"
|
|
4382
4951
|
? "wiki page"
|
|
4952
|
+
: (hov.kind === "file" || hov.kind === "doc")
|
|
4953
|
+
? (esc(hov.path || "") + " · " + hov._deg + " links" + (hov.community != null ? " · community " + esc(String(hov.community)) : ""))
|
|
4383
4954
|
: (esc(hov.session || "") + (when ? " · " + when : ""));
|
|
4384
4955
|
tooltip.innerHTML = "<div>" + esc(hov.label) + "</div><div class='t-meta'>" + meta + "</div>";
|
|
4385
4956
|
tooltip.style.display = "block";
|
|
@@ -4426,6 +4997,9 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4426
4997
|
if (n.kind === "concept" || n.kind === "wiki") {
|
|
4427
4998
|
highlightSet = neighbors(n.id); // light up its connected nodes
|
|
4428
4999
|
closePanel();
|
|
5000
|
+
} else if (n.kind === "file" || n.kind === "doc") {
|
|
5001
|
+
highlightSet = neighbors(n.id); // light up dependencies/links
|
|
5002
|
+
openPanel(n);
|
|
4429
5003
|
} else {
|
|
4430
5004
|
highlightSet = null;
|
|
4431
5005
|
openPanel(n);
|
|
@@ -4439,6 +5013,18 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4439
5013
|
|
|
4440
5014
|
// ---- side panel ----
|
|
4441
5015
|
function openPanel(n) {
|
|
5016
|
+
if (n.kind === "file" || n.kind === "doc") {
|
|
5017
|
+
document.getElementById("p-session").textContent = (n.kind === "file" ? "code" : "doc") + (n.ext ? " (." + n.ext + ")" : "");
|
|
5018
|
+
document.getElementById("p-dot").style.background = roleColor(n);
|
|
5019
|
+
document.getElementById("p-role").textContent = n.path || n.label || "";
|
|
5020
|
+
document.getElementById("p-role").style.color = roleColor(n);
|
|
5021
|
+
// textContent keeps any <script>/<img onerror> in the path or snippet inert.
|
|
5022
|
+
document.getElementById("p-content").textContent = n.snippet || "";
|
|
5023
|
+
document.getElementById("p-time").textContent =
|
|
5024
|
+
(n.community != null ? "community " + n.community + " | " : "") + (n._deg || 0) + " links";
|
|
5025
|
+
panel.classList.add("open");
|
|
5026
|
+
return;
|
|
5027
|
+
}
|
|
4442
5028
|
document.getElementById("p-session").textContent = n.session || "";
|
|
4443
5029
|
document.getElementById("p-dot").style.background = "hsl(" + sessionHue(n.session) + ",70%,58%)";
|
|
4444
5030
|
document.getElementById("p-role").textContent = n.role || "";
|
|
@@ -4473,14 +5059,376 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4473
5059
|
`;
|
|
4474
5060
|
}
|
|
4475
5061
|
|
|
5062
|
+
// src/ingest.ts
|
|
5063
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync14, statSync as statSync6 } from "fs";
|
|
5064
|
+
import { join as join13, relative, sep, extname, dirname as dirname3, basename as basename2 } from "path";
|
|
5065
|
+
var CODE_EXT = /* @__PURE__ */ new Set([
|
|
5066
|
+
"js",
|
|
5067
|
+
"jsx",
|
|
5068
|
+
"ts",
|
|
5069
|
+
"tsx",
|
|
5070
|
+
"mjs",
|
|
5071
|
+
"cjs",
|
|
5072
|
+
"py",
|
|
5073
|
+
"go",
|
|
5074
|
+
"rb",
|
|
5075
|
+
"rs",
|
|
5076
|
+
"java",
|
|
5077
|
+
"kt",
|
|
5078
|
+
"kts",
|
|
5079
|
+
"swift",
|
|
5080
|
+
"c",
|
|
5081
|
+
"h",
|
|
5082
|
+
"cc",
|
|
5083
|
+
"cpp",
|
|
5084
|
+
"hpp",
|
|
5085
|
+
"cs",
|
|
5086
|
+
"php",
|
|
5087
|
+
"scala",
|
|
5088
|
+
"sh",
|
|
5089
|
+
"bash"
|
|
5090
|
+
]);
|
|
5091
|
+
var DOC_EXT = /* @__PURE__ */ new Set(["md", "mdx", "txt", "rst"]);
|
|
5092
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
5093
|
+
"node_modules",
|
|
5094
|
+
".git",
|
|
5095
|
+
".holt",
|
|
5096
|
+
"dist",
|
|
5097
|
+
"build",
|
|
5098
|
+
".next",
|
|
5099
|
+
"coverage",
|
|
5100
|
+
"out",
|
|
5101
|
+
"target",
|
|
5102
|
+
"vendor",
|
|
5103
|
+
"__pycache__",
|
|
5104
|
+
".venv",
|
|
5105
|
+
"venv",
|
|
5106
|
+
".cache",
|
|
5107
|
+
"tmp",
|
|
5108
|
+
".idea",
|
|
5109
|
+
".vscode"
|
|
5110
|
+
]);
|
|
5111
|
+
var MAX_FILES = 2e3;
|
|
5112
|
+
var MAX_FILE_BYTES = 400 * 1024;
|
|
5113
|
+
var MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
5114
|
+
function toPosix(p) {
|
|
5115
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
5116
|
+
}
|
|
5117
|
+
function ingestFolder(root, want = { code: true, docs: true }) {
|
|
5118
|
+
const files = [];
|
|
5119
|
+
const skipped = { dirs: 0, tooBig: 0, unreadable: 0, hitFileCap: false, hitByteCap: false };
|
|
5120
|
+
let totalBytes = 0;
|
|
5121
|
+
const stack = [root];
|
|
5122
|
+
while (stack.length) {
|
|
5123
|
+
const dir = stack.pop();
|
|
5124
|
+
let entries;
|
|
5125
|
+
try {
|
|
5126
|
+
entries = readdirSync4(dir).sort((a, b) => a.localeCompare(b));
|
|
5127
|
+
} catch {
|
|
5128
|
+
skipped.unreadable++;
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
const subdirs = [];
|
|
5132
|
+
for (const name of entries) {
|
|
5133
|
+
if (files.length >= MAX_FILES) {
|
|
5134
|
+
skipped.hitFileCap = true;
|
|
5135
|
+
break;
|
|
5136
|
+
}
|
|
5137
|
+
const full = join13(dir, name);
|
|
5138
|
+
let st;
|
|
5139
|
+
try {
|
|
5140
|
+
st = statSync6(full);
|
|
5141
|
+
} catch {
|
|
5142
|
+
skipped.unreadable++;
|
|
5143
|
+
continue;
|
|
5144
|
+
}
|
|
5145
|
+
if (st.isDirectory()) {
|
|
5146
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name)) {
|
|
5147
|
+
skipped.dirs++;
|
|
5148
|
+
continue;
|
|
5149
|
+
}
|
|
5150
|
+
subdirs.push(full);
|
|
5151
|
+
continue;
|
|
5152
|
+
}
|
|
5153
|
+
if (!st.isFile()) continue;
|
|
5154
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
5155
|
+
const isCode = CODE_EXT.has(ext);
|
|
5156
|
+
const isDoc = DOC_EXT.has(ext);
|
|
5157
|
+
if (!isCode && !isDoc) continue;
|
|
5158
|
+
if (isCode && !want.code) continue;
|
|
5159
|
+
if (isDoc && !want.docs) continue;
|
|
5160
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
5161
|
+
skipped.tooBig++;
|
|
5162
|
+
continue;
|
|
5163
|
+
}
|
|
5164
|
+
if (totalBytes + st.size > MAX_TOTAL_BYTES) {
|
|
5165
|
+
skipped.hitByteCap = true;
|
|
5166
|
+
continue;
|
|
5167
|
+
}
|
|
5168
|
+
let content;
|
|
5169
|
+
try {
|
|
5170
|
+
content = readFileSync14(full, "utf8");
|
|
5171
|
+
} catch {
|
|
5172
|
+
skipped.unreadable++;
|
|
5173
|
+
continue;
|
|
5174
|
+
}
|
|
5175
|
+
totalBytes += st.size;
|
|
5176
|
+
const rel = toPosix(relative(root, full)) || name;
|
|
5177
|
+
const firstSlash = rel.indexOf("/");
|
|
5178
|
+
const topDir = firstSlash < 0 ? "." : rel.slice(0, firstSlash);
|
|
5179
|
+
files.push({
|
|
5180
|
+
id: rel,
|
|
5181
|
+
path: rel,
|
|
5182
|
+
kind: isCode ? "code" : "doc",
|
|
5183
|
+
ext,
|
|
5184
|
+
topDir,
|
|
5185
|
+
content
|
|
5186
|
+
});
|
|
5187
|
+
}
|
|
5188
|
+
if (files.length >= MAX_FILES) {
|
|
5189
|
+
skipped.hitFileCap = true;
|
|
5190
|
+
}
|
|
5191
|
+
for (let i = subdirs.length - 1; i >= 0; i--) stack.push(subdirs[i]);
|
|
5192
|
+
}
|
|
5193
|
+
const byId = new Set(files.map((f) => f.id));
|
|
5194
|
+
const edges = extractEdges(files, byId);
|
|
5195
|
+
return { files, edges, skipped };
|
|
5196
|
+
}
|
|
5197
|
+
function extractEdges(files, byId) {
|
|
5198
|
+
const edges = [];
|
|
5199
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5200
|
+
const push = (source, target, kind) => {
|
|
5201
|
+
if (source === target) return;
|
|
5202
|
+
const key = source + "\0" + target + "\0" + kind;
|
|
5203
|
+
if (seen.has(key)) return;
|
|
5204
|
+
seen.add(key);
|
|
5205
|
+
edges.push({ source, target, kind });
|
|
5206
|
+
};
|
|
5207
|
+
for (const f of files) {
|
|
5208
|
+
if (f.kind === "code") {
|
|
5209
|
+
for (const spec of codeSpecifiers(f)) {
|
|
5210
|
+
const resolved = resolveCodeSpec(f, spec, byId);
|
|
5211
|
+
if (resolved) push(f.id, resolved, "dep");
|
|
5212
|
+
}
|
|
5213
|
+
} else {
|
|
5214
|
+
for (const spec of docLinks(f.content)) {
|
|
5215
|
+
const resolved = resolveDocLink(f, spec, byId, files);
|
|
5216
|
+
if (resolved) push(f.id, resolved, "link");
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
return edges;
|
|
5221
|
+
}
|
|
5222
|
+
var JS_FROM_RE = /(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
|
|
5223
|
+
var JS_BARE_IMPORT_RE = /\bimport\s*['"]([^'"]+)['"]/g;
|
|
5224
|
+
var JS_REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
5225
|
+
var JS_DYN_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
5226
|
+
var PY_IMPORT_RE = /^[ \t]*import\s+([A-Za-z0-9_.]+)/gm;
|
|
5227
|
+
var PY_FROM_RE = /^[ \t]*from\s+(\.*[A-Za-z0-9_.]*)\s+import\b/gm;
|
|
5228
|
+
function matchAll(re, text) {
|
|
5229
|
+
const out = [];
|
|
5230
|
+
re.lastIndex = 0;
|
|
5231
|
+
let m;
|
|
5232
|
+
while ((m = re.exec(text)) !== null) {
|
|
5233
|
+
if (m[1]) out.push(m[1]);
|
|
5234
|
+
}
|
|
5235
|
+
return out;
|
|
5236
|
+
}
|
|
5237
|
+
function codeSpecifiers(f) {
|
|
5238
|
+
if (f.ext === "py") {
|
|
5239
|
+
return [...matchAll(PY_IMPORT_RE, f.content), ...matchAll(PY_FROM_RE, f.content)];
|
|
5240
|
+
}
|
|
5241
|
+
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(f.ext)) {
|
|
5242
|
+
return [
|
|
5243
|
+
...matchAll(JS_FROM_RE, f.content),
|
|
5244
|
+
...matchAll(JS_BARE_IMPORT_RE, f.content),
|
|
5245
|
+
...matchAll(JS_REQUIRE_RE, f.content),
|
|
5246
|
+
...matchAll(JS_DYN_IMPORT_RE, f.content)
|
|
5247
|
+
];
|
|
5248
|
+
}
|
|
5249
|
+
return [];
|
|
5250
|
+
}
|
|
5251
|
+
var JS_TRY_EXT = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts"];
|
|
5252
|
+
var JS_INDEX = ["/index.ts", "/index.tsx", "/index.js", "/index.jsx", "/index.mjs"];
|
|
5253
|
+
function joinPosix(fromDir, spec) {
|
|
5254
|
+
const parts = (fromDir === "." ? [] : fromDir.split("/")).concat(spec.split("/"));
|
|
5255
|
+
const out = [];
|
|
5256
|
+
for (const part of parts) {
|
|
5257
|
+
if (part === "" || part === ".") continue;
|
|
5258
|
+
if (part === "..") {
|
|
5259
|
+
out.pop();
|
|
5260
|
+
continue;
|
|
5261
|
+
}
|
|
5262
|
+
out.push(part);
|
|
5263
|
+
}
|
|
5264
|
+
return out.join("/");
|
|
5265
|
+
}
|
|
5266
|
+
function resolveCodeSpec(f, spec, byId) {
|
|
5267
|
+
if (f.ext === "py") return resolvePySpec(f, spec, byId);
|
|
5268
|
+
if (!(spec.startsWith("./") || spec.startsWith("../"))) return null;
|
|
5269
|
+
const fromDir = toPosixDir(f.id);
|
|
5270
|
+
const base = joinPosix(fromDir, spec);
|
|
5271
|
+
for (const ext of JS_TRY_EXT) {
|
|
5272
|
+
const cand = base + ext;
|
|
5273
|
+
if (cand && byId.has(cand)) return cand;
|
|
5274
|
+
}
|
|
5275
|
+
for (const idx of JS_INDEX) {
|
|
5276
|
+
const cand = base + idx;
|
|
5277
|
+
if (byId.has(cand)) return cand;
|
|
5278
|
+
}
|
|
5279
|
+
return null;
|
|
5280
|
+
}
|
|
5281
|
+
function resolvePySpec(f, spec, byId) {
|
|
5282
|
+
let mod = spec;
|
|
5283
|
+
let up = 0;
|
|
5284
|
+
while (mod.startsWith(".")) {
|
|
5285
|
+
up++;
|
|
5286
|
+
mod = mod.slice(1);
|
|
5287
|
+
}
|
|
5288
|
+
const segs = mod ? mod.split(".") : [];
|
|
5289
|
+
let baseDir = toPosixDir(f.id);
|
|
5290
|
+
if (up > 0) {
|
|
5291
|
+
for (let i = 1; i < up; i++) baseDir = toPosixDir(baseDir + "/x");
|
|
5292
|
+
}
|
|
5293
|
+
const relBase = up > 0 ? joinPosix(baseDir, segs.join("/")) : segs.join("/");
|
|
5294
|
+
const candidates = up > 0 ? [relBase + ".py", relBase + "/__init__.py"] : [
|
|
5295
|
+
relBase + ".py",
|
|
5296
|
+
relBase + "/__init__.py",
|
|
5297
|
+
// absolute import may still be relative to the file's own dir
|
|
5298
|
+
joinPosix(toPosixDir(f.id), segs.join("/")) + ".py"
|
|
5299
|
+
];
|
|
5300
|
+
for (const cand of candidates) if (cand && byId.has(cand)) return cand;
|
|
5301
|
+
return null;
|
|
5302
|
+
}
|
|
5303
|
+
function toPosixDir(id) {
|
|
5304
|
+
const d = toPosix(dirname3(id));
|
|
5305
|
+
return d === "" || d === "." ? "." : d;
|
|
5306
|
+
}
|
|
5307
|
+
var MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
5308
|
+
var WIKILINK_RE2 = /\[\[([^\]]+)\]\]/g;
|
|
5309
|
+
function docLinks(content) {
|
|
5310
|
+
const out = [];
|
|
5311
|
+
for (const raw of matchAll(MD_LINK_RE, content)) out.push(raw);
|
|
5312
|
+
WIKILINK_RE2.lastIndex = 0;
|
|
5313
|
+
let m;
|
|
5314
|
+
while ((m = WIKILINK_RE2.exec(content)) !== null) {
|
|
5315
|
+
const target = (m[1] || "").split("|")[0];
|
|
5316
|
+
if (target && target.trim()) out.push(target.trim());
|
|
5317
|
+
}
|
|
5318
|
+
return out;
|
|
5319
|
+
}
|
|
5320
|
+
function resolveDocLink(f, spec, byId, files) {
|
|
5321
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec) || spec.startsWith("#") || spec.startsWith("mailto:")) return null;
|
|
5322
|
+
const clean = spec.split("#")[0].split("?")[0].trim();
|
|
5323
|
+
if (!clean) return null;
|
|
5324
|
+
if (clean.includes("/") || clean.includes(".")) {
|
|
5325
|
+
const fromDir = toPosixDir(f.id);
|
|
5326
|
+
const base = joinPosix(fromDir, clean);
|
|
5327
|
+
for (const ext of ["", ".md", ".mdx", ".txt", ".rst"]) {
|
|
5328
|
+
const cand = base + ext;
|
|
5329
|
+
if (cand && byId.has(cand)) return cand;
|
|
5330
|
+
}
|
|
5331
|
+
}
|
|
5332
|
+
const wantName = clean.toLowerCase();
|
|
5333
|
+
for (const cand of files) {
|
|
5334
|
+
if (cand.kind !== "doc") continue;
|
|
5335
|
+
const stem = basename2(cand.id).replace(/\.[^.]+$/, "").toLowerCase();
|
|
5336
|
+
if (stem === wantName) return cand.id;
|
|
5337
|
+
}
|
|
5338
|
+
return null;
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
// src/graphreport.ts
|
|
5342
|
+
var TOP_GOD_NODES = 12;
|
|
5343
|
+
var REP_PER_COMMUNITY = 6;
|
|
5344
|
+
function degrees(graph2) {
|
|
5345
|
+
const idSet = new Set(graph2.nodes.map((n) => n.id));
|
|
5346
|
+
const deg = /* @__PURE__ */ new Map();
|
|
5347
|
+
for (const n of graph2.nodes) deg.set(n.id, 0);
|
|
5348
|
+
for (const e of graph2.edges) {
|
|
5349
|
+
if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
|
|
5350
|
+
deg.set(e.source, (deg.get(e.source) || 0) + 1);
|
|
5351
|
+
deg.set(e.target, (deg.get(e.target) || 0) + 1);
|
|
5352
|
+
}
|
|
5353
|
+
return deg;
|
|
5354
|
+
}
|
|
5355
|
+
function reportLabel(n) {
|
|
5356
|
+
return n.path || n.label || n.id;
|
|
5357
|
+
}
|
|
5358
|
+
function renderReport(graph2, meta) {
|
|
5359
|
+
const date = new Date(meta.generatedAt ?? Date.now()).toISOString().slice(0, 10);
|
|
5360
|
+
const lines = [];
|
|
5361
|
+
lines.push(`# Graph Report - ${meta.workspace} (${date})`);
|
|
5362
|
+
lines.push("");
|
|
5363
|
+
if (graph2.nodes.length === 0) {
|
|
5364
|
+
lines.push("## Summary");
|
|
5365
|
+
lines.push("- 0 nodes, 0 edges, 0 communities.");
|
|
5366
|
+
lines.push("- Nothing to ingest here yet. Run `holt graph --code` or `holt graph --docs`");
|
|
5367
|
+
lines.push(" in a folder with source or docs, then `holt graph report`.");
|
|
5368
|
+
lines.push("");
|
|
5369
|
+
return lines.join("\n") + "\n";
|
|
5370
|
+
}
|
|
5371
|
+
const deg = degrees(graph2);
|
|
5372
|
+
const idSet = new Set(graph2.nodes.map((n) => n.id));
|
|
5373
|
+
const validEdges = graph2.edges.filter((e) => idSet.has(e.source) && idSet.has(e.target));
|
|
5374
|
+
const commMembers = /* @__PURE__ */ new Map();
|
|
5375
|
+
for (const n of graph2.nodes) {
|
|
5376
|
+
const cid = n.community ?? "0";
|
|
5377
|
+
const arr = commMembers.get(cid);
|
|
5378
|
+
if (arr) arr.push(n);
|
|
5379
|
+
else commMembers.set(cid, [n]);
|
|
5380
|
+
}
|
|
5381
|
+
const communityCount = commMembers.size;
|
|
5382
|
+
const fileCount = graph2.nodes.filter((n) => n.kind === "file").length;
|
|
5383
|
+
const docCount = graph2.nodes.filter((n) => n.kind === "doc").length;
|
|
5384
|
+
lines.push("## Summary");
|
|
5385
|
+
lines.push(`- ${graph2.nodes.length} nodes, ${validEdges.length} edges, ${communityCount} communities detected`);
|
|
5386
|
+
lines.push(`- Ingested: ${fileCount} code files, ${docCount} docs`);
|
|
5387
|
+
lines.push("- Community algorithm: label propagation (deterministic, zero dependency)");
|
|
5388
|
+
lines.push("");
|
|
5389
|
+
lines.push("## God Nodes (most connected - your core files)");
|
|
5390
|
+
const ranked = graph2.nodes.slice().sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || reportLabel(a).localeCompare(reportLabel(b))).filter((n) => (deg.get(n.id) || 0) > 0).slice(0, TOP_GOD_NODES);
|
|
5391
|
+
if (ranked.length === 0) {
|
|
5392
|
+
lines.push("- None: no nodes have any edges yet (nothing links to anything).");
|
|
5393
|
+
} else {
|
|
5394
|
+
ranked.forEach((n, i) => {
|
|
5395
|
+
const d = deg.get(n.id) || 0;
|
|
5396
|
+
const cid = n.community ?? "0";
|
|
5397
|
+
lines.push(`${i + 1}. \`${reportLabel(n)}\` - ${d} edges (community ${cid})`);
|
|
5398
|
+
});
|
|
5399
|
+
}
|
|
5400
|
+
lines.push("");
|
|
5401
|
+
lines.push("## Communities");
|
|
5402
|
+
const sortedComms = [...commMembers.entries()].sort(
|
|
5403
|
+
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])
|
|
5404
|
+
);
|
|
5405
|
+
for (const [cid, members] of sortedComms) {
|
|
5406
|
+
lines.push("");
|
|
5407
|
+
lines.push(`### Community ${cid} (${members.length} nodes)`);
|
|
5408
|
+
const reps = members.slice().sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || reportLabel(a).localeCompare(reportLabel(b))).slice(0, REP_PER_COMMUNITY).map((n) => reportLabel(n));
|
|
5409
|
+
const extra = members.length - reps.length;
|
|
5410
|
+
const shown = reps.join(", ");
|
|
5411
|
+
lines.push(`Representative: ${shown}${extra > 0 ? ` (+${extra} more)` : ""}`);
|
|
5412
|
+
}
|
|
5413
|
+
lines.push("");
|
|
5414
|
+
return lines.join("\n") + "\n";
|
|
5415
|
+
}
|
|
5416
|
+
|
|
4476
5417
|
// src/commands/graph.ts
|
|
4477
5418
|
function parseArgs(args) {
|
|
4478
|
-
const opts = { open: true };
|
|
5419
|
+
const opts = { open: true, code: false, docs: false, report: false };
|
|
4479
5420
|
for (let i = 0; i < args.length; i++) {
|
|
4480
5421
|
const a = args[i];
|
|
4481
|
-
if (a === "
|
|
5422
|
+
if (a === "report") opts.report = true;
|
|
5423
|
+
else if (a === "--no-open") opts.open = false;
|
|
4482
5424
|
else if (a === "--wiki") opts.wiki = true;
|
|
4483
5425
|
else if (a === "--no-wiki") opts.wiki = false;
|
|
5426
|
+
else if (a === "--code") opts.code = true;
|
|
5427
|
+
else if (a === "--docs") opts.docs = true;
|
|
5428
|
+
else if (a === "--all") {
|
|
5429
|
+
opts.code = true;
|
|
5430
|
+
opts.docs = true;
|
|
5431
|
+
} else if (a === "--report") opts.report = true;
|
|
4484
5432
|
else if (a === "--out") opts.out = args[++i];
|
|
4485
5433
|
else if (a && a.startsWith("--out=")) opts.out = a.slice("--out=".length);
|
|
4486
5434
|
}
|
|
@@ -4518,15 +5466,55 @@ async function graph(args = []) {
|
|
|
4518
5466
|
}
|
|
4519
5467
|
close();
|
|
4520
5468
|
const opts = parseArgs(args);
|
|
5469
|
+
if (opts.report && !opts.code && !opts.docs) {
|
|
5470
|
+
opts.code = true;
|
|
5471
|
+
opts.docs = true;
|
|
5472
|
+
}
|
|
5473
|
+
const ingestCode = opts.code;
|
|
5474
|
+
const ingestDocs = opts.docs;
|
|
4521
5475
|
const turns = loadTurns();
|
|
4522
|
-
const includeWiki = opts.wiki !== void 0 ? opts.wiki :
|
|
5476
|
+
const includeWiki = opts.wiki !== void 0 ? opts.wiki : existsSync13(wikiDir());
|
|
4523
5477
|
const wikiPages = includeWiki ? wikiGraphPages() : [];
|
|
4524
|
-
|
|
5478
|
+
let fileNodeCount = 0;
|
|
5479
|
+
let docNodeCount = 0;
|
|
5480
|
+
let codeGraph = null;
|
|
5481
|
+
if (ingestCode || ingestDocs) {
|
|
5482
|
+
const res = ingestFolder(workspace(), { code: ingestCode, docs: ingestDocs });
|
|
5483
|
+
codeGraph = buildCodeGraph(res.files, res.edges);
|
|
5484
|
+
fileNodeCount = codeGraph.nodes.filter((n) => n.kind === "file").length;
|
|
5485
|
+
docNodeCount = codeGraph.nodes.filter((n) => n.kind === "doc").length;
|
|
5486
|
+
const skipNotes = [];
|
|
5487
|
+
if (res.skipped.dirs) skipNotes.push(`${res.skipped.dirs} dirs`);
|
|
5488
|
+
if (res.skipped.tooBig) skipNotes.push(`${res.skipped.tooBig} too-big`);
|
|
5489
|
+
if (res.skipped.unreadable) skipNotes.push(`${res.skipped.unreadable} unreadable`);
|
|
5490
|
+
if (res.skipped.hitFileCap) skipNotes.push("file cap hit");
|
|
5491
|
+
if (res.skipped.hitByteCap) skipNotes.push("byte cap hit");
|
|
5492
|
+
if (skipNotes.length) console.log(c.dim(` ingest skipped: ${skipNotes.join(", ")}`));
|
|
5493
|
+
}
|
|
5494
|
+
if (turns.length === 0 && wikiPages.length === 0 && !codeGraph) {
|
|
4525
5495
|
console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
|
|
5496
|
+
console.log(c.dim(` Or ingest this folder's code/docs: "holt graph --code" / "holt graph --docs".
|
|
5497
|
+
`));
|
|
4526
5498
|
return;
|
|
4527
5499
|
}
|
|
4528
5500
|
let g = buildGraph(turns);
|
|
4529
5501
|
if (wikiPages.length) g = mergeGraphs(g, buildWikiGraph(wikiPages));
|
|
5502
|
+
if (codeGraph) g = mergeGraphs(g, codeGraph);
|
|
5503
|
+
if (codeGraph) assignCommunities(g);
|
|
5504
|
+
if (opts.report) {
|
|
5505
|
+
const md = renderReport(g, { workspace: workspace() });
|
|
5506
|
+
const reportPath = opts.out ? resolve4(opts.out) : join14(workspace(), "GRAPH_REPORT.md");
|
|
5507
|
+
mkdirSync10(dirname4(reportPath), { recursive: true });
|
|
5508
|
+
writeFileSync11(reportPath, md, "utf8");
|
|
5509
|
+
const communities = new Set(g.nodes.map((n) => n.community ?? "0")).size;
|
|
5510
|
+
console.log("\n" + c.accent("Graph report written") + c.dim(" (this folder)"));
|
|
5511
|
+
console.log(` nodes ${g.nodes.length} (${fileNodeCount} files, ${docNodeCount} docs)`);
|
|
5512
|
+
console.log(` edges ${g.edges.length}`);
|
|
5513
|
+
console.log(` communities ${g.nodes.length ? communities : 0}`);
|
|
5514
|
+
console.log(` file ${reportPath}
|
|
5515
|
+
`);
|
|
5516
|
+
return;
|
|
5517
|
+
}
|
|
4530
5518
|
const conceptCount = g.nodes.filter((n) => n.kind === "concept").length;
|
|
4531
5519
|
const wikiCount = g.nodes.filter((n) => n.kind === "wiki").length;
|
|
4532
5520
|
const sessions = new Set(turns.map((t) => t.session)).size;
|
|
@@ -4537,12 +5525,14 @@ async function graph(args = []) {
|
|
|
4537
5525
|
concepts: conceptCount,
|
|
4538
5526
|
edges: g.edges.length
|
|
4539
5527
|
});
|
|
4540
|
-
const outPath = opts.out ?
|
|
4541
|
-
|
|
4542
|
-
|
|
5528
|
+
const outPath = opts.out ? resolve4(opts.out) : join14(wsHoltDir(), "graph.html");
|
|
5529
|
+
mkdirSync10(dirname4(outPath), { recursive: true });
|
|
5530
|
+
writeFileSync11(outPath, html, "utf8");
|
|
4543
5531
|
console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
|
|
4544
5532
|
const parts = [`${turns.length} turns`, `${conceptCount} concepts`];
|
|
4545
5533
|
if (wikiCount) parts.push(`${wikiCount} wiki pages`);
|
|
5534
|
+
if (fileNodeCount) parts.push(`${fileNodeCount} code files`);
|
|
5535
|
+
if (docNodeCount) parts.push(`${docNodeCount} docs`);
|
|
4546
5536
|
console.log(` nodes ${g.nodes.length} (${parts.join(", ")})`);
|
|
4547
5537
|
console.log(` edges ${g.edges.length}`);
|
|
4548
5538
|
console.log(` file ${outPath}`);
|
|
@@ -4649,7 +5639,7 @@ function recoverId(line) {
|
|
|
4649
5639
|
return null;
|
|
4650
5640
|
}
|
|
4651
5641
|
function runMcpServer(opts) {
|
|
4652
|
-
return new Promise((
|
|
5642
|
+
return new Promise((resolve8) => {
|
|
4653
5643
|
const stdin = process.stdin;
|
|
4654
5644
|
let buffer = "";
|
|
4655
5645
|
let chain = Promise.resolve();
|
|
@@ -4701,11 +5691,11 @@ function runMcpServer(opts) {
|
|
|
4701
5691
|
processLine(buffer);
|
|
4702
5692
|
buffer = "";
|
|
4703
5693
|
}
|
|
4704
|
-
chain.then(() =>
|
|
5694
|
+
chain.then(() => resolve8()).catch(() => resolve8());
|
|
4705
5695
|
});
|
|
4706
5696
|
stdin.on("error", (e) => {
|
|
4707
5697
|
console.error(`[mcp] stdin error: ${e.message}`);
|
|
4708
|
-
chain.then(() =>
|
|
5698
|
+
chain.then(() => resolve8()).catch(() => resolve8());
|
|
4709
5699
|
});
|
|
4710
5700
|
stdin.resume();
|
|
4711
5701
|
});
|
|
@@ -4825,7 +5815,7 @@ function holtTools() {
|
|
|
4825
5815
|
}
|
|
4826
5816
|
|
|
4827
5817
|
// src/version.ts
|
|
4828
|
-
var VERSION = "0.
|
|
5818
|
+
var VERSION = "0.11.0";
|
|
4829
5819
|
|
|
4830
5820
|
// src/commands/mcp.ts
|
|
4831
5821
|
function printSetup() {
|
|
@@ -4871,8 +5861,8 @@ async function mcp(sub, rest = []) {
|
|
|
4871
5861
|
}
|
|
4872
5862
|
|
|
4873
5863
|
// src/commands/hook.ts
|
|
4874
|
-
import { readFileSync as
|
|
4875
|
-
import { join as
|
|
5864
|
+
import { readFileSync as readFileSync15, writeFileSync as writeFileSync12, existsSync as existsSync14, mkdirSync as mkdirSync11, copyFileSync, realpathSync as realpathSync2 } from "fs";
|
|
5865
|
+
import { join as join15, dirname as dirname5 } from "path";
|
|
4876
5866
|
import { homedir as homedir3 } from "os";
|
|
4877
5867
|
var EVENT = {
|
|
4878
5868
|
inject: "UserPromptSubmit",
|
|
@@ -4883,26 +5873,26 @@ var HOOK_CMD = {
|
|
|
4883
5873
|
capture: "holt hook capture"
|
|
4884
5874
|
};
|
|
4885
5875
|
function globalSettingsPath() {
|
|
4886
|
-
return
|
|
5876
|
+
return join15(homedir3(), ".claude", "settings.json");
|
|
4887
5877
|
}
|
|
4888
5878
|
function projectSettingsPath() {
|
|
4889
|
-
return
|
|
5879
|
+
return join15(process.cwd(), ".claude", "settings.json");
|
|
4890
5880
|
}
|
|
4891
5881
|
function readSettings(path) {
|
|
4892
|
-
if (!
|
|
5882
|
+
if (!existsSync14(path)) return {};
|
|
4893
5883
|
try {
|
|
4894
|
-
const parsed = JSON.parse(
|
|
5884
|
+
const parsed = JSON.parse(readFileSync15(path, "utf8"));
|
|
4895
5885
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
4896
5886
|
} catch {
|
|
4897
5887
|
throw new Error(`could not parse ${path} (not valid JSON) - leaving it untouched`);
|
|
4898
5888
|
}
|
|
4899
5889
|
}
|
|
4900
5890
|
function writeSettings(path, settings) {
|
|
4901
|
-
|
|
4902
|
-
|
|
5891
|
+
mkdirSync11(dirname5(path), { recursive: true });
|
|
5892
|
+
writeFileSync12(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
4903
5893
|
}
|
|
4904
5894
|
function backup(path) {
|
|
4905
|
-
if (!
|
|
5895
|
+
if (!existsSync14(path)) return null;
|
|
4906
5896
|
const dest = `${path}.holt-bak`;
|
|
4907
5897
|
try {
|
|
4908
5898
|
copyFileSync(path, dest);
|
|
@@ -4992,7 +5982,7 @@ function remove(rest) {
|
|
|
4992
5982
|
const paths = project ? [projectSettingsPath()] : [globalSettingsPath(), projectSettingsPath()];
|
|
4993
5983
|
let touchedAny = false;
|
|
4994
5984
|
for (const path of paths) {
|
|
4995
|
-
if (!
|
|
5985
|
+
if (!existsSync14(path)) continue;
|
|
4996
5986
|
let settings;
|
|
4997
5987
|
try {
|
|
4998
5988
|
settings = readSettings(path);
|
|
@@ -5022,7 +6012,7 @@ function status(rest) {
|
|
|
5022
6012
|
];
|
|
5023
6013
|
console.log(c.accent(" Holt ambient hooks"));
|
|
5024
6014
|
for (const e of entries) {
|
|
5025
|
-
if (!
|
|
6015
|
+
if (!existsSync14(e.path)) {
|
|
5026
6016
|
console.log(c.dim(` ${e.label.padEnd(8)} ${e.path} (no settings file)`));
|
|
5027
6017
|
continue;
|
|
5028
6018
|
}
|
|
@@ -5042,13 +6032,13 @@ function status(rest) {
|
|
|
5042
6032
|
console.log(c.dim(" Runtime hooks only act in trusted Holt folders that already have .holt/memory."));
|
|
5043
6033
|
}
|
|
5044
6034
|
function readStdin(timeoutMs) {
|
|
5045
|
-
return new Promise((
|
|
6035
|
+
return new Promise((resolve8) => {
|
|
5046
6036
|
let input = "";
|
|
5047
6037
|
let done = false;
|
|
5048
6038
|
const finish = () => {
|
|
5049
6039
|
if (done) return;
|
|
5050
6040
|
done = true;
|
|
5051
|
-
|
|
6041
|
+
resolve8(input);
|
|
5052
6042
|
};
|
|
5053
6043
|
try {
|
|
5054
6044
|
process.stdin.setEncoding("utf8");
|
|
@@ -5072,7 +6062,7 @@ function parseHookInput(raw) {
|
|
|
5072
6062
|
}
|
|
5073
6063
|
}
|
|
5074
6064
|
function enterFolder(cwd) {
|
|
5075
|
-
if (!cwd || !
|
|
6065
|
+
if (!cwd || !existsSync14(cwd)) return null;
|
|
5076
6066
|
try {
|
|
5077
6067
|
const real = realpathSync2(cwd);
|
|
5078
6068
|
process.chdir(real);
|
|
@@ -5083,7 +6073,7 @@ function enterFolder(cwd) {
|
|
|
5083
6073
|
}
|
|
5084
6074
|
function guardTrusted(real) {
|
|
5085
6075
|
if (!isTrusted(real)) return false;
|
|
5086
|
-
if (!
|
|
6076
|
+
if (!existsSync14(memDir())) return false;
|
|
5087
6077
|
return true;
|
|
5088
6078
|
}
|
|
5089
6079
|
async function inject() {
|
|
@@ -5110,7 +6100,7 @@ function readTranscript(path) {
|
|
|
5110
6100
|
const out = [];
|
|
5111
6101
|
let raw;
|
|
5112
6102
|
try {
|
|
5113
|
-
raw =
|
|
6103
|
+
raw = readFileSync15(path, "utf8");
|
|
5114
6104
|
} catch {
|
|
5115
6105
|
return out;
|
|
5116
6106
|
}
|
|
@@ -5174,6 +6164,15 @@ async function capture() {
|
|
|
5174
6164
|
const n = await extractAndSaveFacts(brain, cfg, history, session);
|
|
5175
6165
|
if (n > 0) process.stderr.write(`holt hook capture: saved ${n} fact${n === 1 ? "" : "s"} to ${memDir()}
|
|
5176
6166
|
`);
|
|
6167
|
+
if (cfg.wiki?.autoSync) {
|
|
6168
|
+
const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
|
|
6169
|
+
if (res.status === "ok" && res.changed) {
|
|
6170
|
+
process.stderr.write(
|
|
6171
|
+
`holt hook capture: updated the wiki (${res.created} created, ${res.updated} updated) in ${wikiDir()}
|
|
6172
|
+
`
|
|
6173
|
+
);
|
|
6174
|
+
}
|
|
6175
|
+
}
|
|
5177
6176
|
} catch (e) {
|
|
5178
6177
|
process.stderr.write("holt hook capture: " + (e instanceof Error ? e.message : String(e)) + "\n");
|
|
5179
6178
|
}
|
|
@@ -5231,8 +6230,8 @@ async function hook(sub, rest = []) {
|
|
|
5231
6230
|
}
|
|
5232
6231
|
|
|
5233
6232
|
// src/commands/run.ts
|
|
5234
|
-
import { writeFileSync as
|
|
5235
|
-
import { resolve as
|
|
6233
|
+
import { writeFileSync as writeFileSync13 } from "fs";
|
|
6234
|
+
import { resolve as resolve5 } from "path";
|
|
5236
6235
|
var USAGE3 = [
|
|
5237
6236
|
" holt run <task> [options]",
|
|
5238
6237
|
"",
|
|
@@ -5321,9 +6320,9 @@ async function run(args) {
|
|
|
5321
6320
|
process.stdout.write("\n");
|
|
5322
6321
|
}
|
|
5323
6322
|
if (p.out) {
|
|
5324
|
-
const target =
|
|
6323
|
+
const target = resolve5(process.cwd(), p.out);
|
|
5325
6324
|
try {
|
|
5326
|
-
|
|
6325
|
+
writeFileSync13(target, result.text, "utf8");
|
|
5327
6326
|
process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
5328
6327
|
} catch (e) {
|
|
5329
6328
|
process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
|
|
@@ -5334,38 +6333,38 @@ async function run(args) {
|
|
|
5334
6333
|
|
|
5335
6334
|
// src/commands/schedule.ts
|
|
5336
6335
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
5337
|
-
import { writeFileSync as
|
|
5338
|
-
import { dirname as
|
|
6336
|
+
import { writeFileSync as writeFileSync15, mkdirSync as mkdirSync13, rmSync as rmSync6, existsSync as existsSync15 } from "fs";
|
|
6337
|
+
import { dirname as dirname6 } from "path";
|
|
5339
6338
|
|
|
5340
6339
|
// src/scheduler.ts
|
|
5341
6340
|
import { homedir as homedir4 } from "os";
|
|
5342
|
-
import { join as
|
|
5343
|
-
import { readFileSync as
|
|
6341
|
+
import { join as join16 } from "path";
|
|
6342
|
+
import { readFileSync as readFileSync16, writeFileSync as writeFileSync14, mkdirSync as mkdirSync12 } from "fs";
|
|
5344
6343
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
5345
6344
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5346
6345
|
function schedulesPath() {
|
|
5347
|
-
return
|
|
6346
|
+
return join16(GLOBAL_DIR, "schedules.json");
|
|
5348
6347
|
}
|
|
5349
6348
|
function logsDir() {
|
|
5350
|
-
return
|
|
6349
|
+
return join16(GLOBAL_DIR, "logs");
|
|
5351
6350
|
}
|
|
5352
6351
|
function logPath(id) {
|
|
5353
|
-
return
|
|
6352
|
+
return join16(logsDir(), `${id}.log`);
|
|
5354
6353
|
}
|
|
5355
6354
|
function plistPath(id) {
|
|
5356
|
-
return
|
|
6355
|
+
return join16(homedir4(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
|
|
5357
6356
|
}
|
|
5358
6357
|
function loadJobs() {
|
|
5359
6358
|
try {
|
|
5360
|
-
const raw = JSON.parse(
|
|
6359
|
+
const raw = JSON.parse(readFileSync16(schedulesPath(), "utf8"));
|
|
5361
6360
|
return Array.isArray(raw) ? raw : [];
|
|
5362
6361
|
} catch {
|
|
5363
6362
|
return [];
|
|
5364
6363
|
}
|
|
5365
6364
|
}
|
|
5366
6365
|
function saveJobs(jobs) {
|
|
5367
|
-
|
|
5368
|
-
|
|
6366
|
+
mkdirSync12(GLOBAL_DIR, { recursive: true });
|
|
6367
|
+
writeFileSync14(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
|
|
5369
6368
|
}
|
|
5370
6369
|
function addJob(job) {
|
|
5371
6370
|
const jobs = loadJobs();
|
|
@@ -5473,7 +6472,7 @@ function appendCronLine(crontab, line) {
|
|
|
5473
6472
|
return (base ? base + "\n" : "") + line + "\n";
|
|
5474
6473
|
}
|
|
5475
6474
|
function ensureLogsDir() {
|
|
5476
|
-
|
|
6475
|
+
mkdirSync12(logsDir(), { recursive: true });
|
|
5477
6476
|
}
|
|
5478
6477
|
|
|
5479
6478
|
// src/commands/schedule.ts
|
|
@@ -5499,8 +6498,8 @@ function parseAdd(rest) {
|
|
|
5499
6498
|
}
|
|
5500
6499
|
function installDarwin(job, holtPath) {
|
|
5501
6500
|
const path = plistPath(job.id);
|
|
5502
|
-
|
|
5503
|
-
|
|
6501
|
+
mkdirSync13(dirname6(path), { recursive: true });
|
|
6502
|
+
writeFileSync15(path, buildLaunchdPlist(job, holtPath), "utf8");
|
|
5504
6503
|
const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
|
|
5505
6504
|
if (res.status === 0) {
|
|
5506
6505
|
console.log(c.green(" Loaded into launchd."));
|
|
@@ -5588,7 +6587,7 @@ function listCmd2() {
|
|
|
5588
6587
|
}
|
|
5589
6588
|
function removeDarwin(id) {
|
|
5590
6589
|
const path = plistPath(id);
|
|
5591
|
-
if (
|
|
6590
|
+
if (existsSync15(path)) {
|
|
5592
6591
|
spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
|
|
5593
6592
|
try {
|
|
5594
6593
|
rmSync6(path);
|
|
@@ -5651,29 +6650,29 @@ async function schedule(sub, rest = []) {
|
|
|
5651
6650
|
|
|
5652
6651
|
// src/commands/routine.ts
|
|
5653
6652
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
5654
|
-
import { writeFileSync as
|
|
5655
|
-
import { dirname as
|
|
6653
|
+
import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync15, rmSync as rmSync7, existsSync as existsSync16 } from "fs";
|
|
6654
|
+
import { dirname as dirname7, resolve as resolve6 } from "path";
|
|
5656
6655
|
|
|
5657
6656
|
// src/routines.ts
|
|
5658
|
-
import { readFileSync as
|
|
5659
|
-
import { join as
|
|
6657
|
+
import { readFileSync as readFileSync17, writeFileSync as writeFileSync16, mkdirSync as mkdirSync14 } from "fs";
|
|
6658
|
+
import { join as join17 } from "path";
|
|
5660
6659
|
function sanitizeRoutineName(name) {
|
|
5661
6660
|
return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
5662
6661
|
}
|
|
5663
6662
|
function routinesPath() {
|
|
5664
|
-
return
|
|
6663
|
+
return join17(GLOBAL_DIR, "routines.json");
|
|
5665
6664
|
}
|
|
5666
6665
|
function loadRoutines() {
|
|
5667
6666
|
try {
|
|
5668
|
-
const raw = JSON.parse(
|
|
6667
|
+
const raw = JSON.parse(readFileSync17(routinesPath(), "utf8"));
|
|
5669
6668
|
return Array.isArray(raw) ? raw : [];
|
|
5670
6669
|
} catch {
|
|
5671
6670
|
return [];
|
|
5672
6671
|
}
|
|
5673
6672
|
}
|
|
5674
6673
|
function saveRoutines(routines) {
|
|
5675
|
-
|
|
5676
|
-
|
|
6674
|
+
mkdirSync14(GLOBAL_DIR, { recursive: true });
|
|
6675
|
+
writeFileSync16(routinesPath(), JSON.stringify(routines, null, 2) + "\n", "utf8");
|
|
5677
6676
|
}
|
|
5678
6677
|
function findRoutine(name) {
|
|
5679
6678
|
const want = sanitizeRoutineName(name);
|
|
@@ -5733,8 +6732,8 @@ function routineJobId(name) {
|
|
|
5733
6732
|
}
|
|
5734
6733
|
function installDarwin2(job, holtPath) {
|
|
5735
6734
|
const path = plistPath(job.id);
|
|
5736
|
-
|
|
5737
|
-
|
|
6735
|
+
mkdirSync15(dirname7(path), { recursive: true });
|
|
6736
|
+
writeFileSync17(path, buildLaunchdPlist(job, holtPath), "utf8");
|
|
5738
6737
|
const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
|
|
5739
6738
|
if (res.status === 0) {
|
|
5740
6739
|
console.log(c.green(" Loaded into launchd."));
|
|
@@ -5778,7 +6777,7 @@ function installSchedule(r) {
|
|
|
5778
6777
|
}
|
|
5779
6778
|
function removeScheduleDarwin(id) {
|
|
5780
6779
|
const path = plistPath(id);
|
|
5781
|
-
if (
|
|
6780
|
+
if (existsSync16(path)) {
|
|
5782
6781
|
spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
|
|
5783
6782
|
try {
|
|
5784
6783
|
rmSync7(path);
|
|
@@ -5972,9 +6971,9 @@ async function runCmd(rest) {
|
|
|
5972
6971
|
if (!quiet && !streamed) process.stdout.write(result.text);
|
|
5973
6972
|
if (!quiet && (streamed || result.text) && !result.text.endsWith("\n")) process.stdout.write("\n");
|
|
5974
6973
|
if (outTarget) {
|
|
5975
|
-
const target =
|
|
6974
|
+
const target = resolve6(routine2.workspace, outTarget);
|
|
5976
6975
|
try {
|
|
5977
|
-
|
|
6976
|
+
writeFileSync17(target, result.text, "utf8");
|
|
5978
6977
|
if (!quiet) process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
5979
6978
|
} catch (e) {
|
|
5980
6979
|
process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
|
|
@@ -6112,7 +7111,7 @@ async function routine(sub, rest = []) {
|
|
|
6112
7111
|
}
|
|
6113
7112
|
|
|
6114
7113
|
// src/commands/notify.ts
|
|
6115
|
-
import { readFileSync as
|
|
7114
|
+
import { readFileSync as readFileSync18 } from "fs";
|
|
6116
7115
|
async function notify(args) {
|
|
6117
7116
|
const nonFlag = args.filter((a) => a !== "-" && !a.startsWith("--"));
|
|
6118
7117
|
const wantsStdin = args.includes("-") || !process.stdin.isTTY;
|
|
@@ -6145,7 +7144,7 @@ async function notify(args) {
|
|
|
6145
7144
|
}
|
|
6146
7145
|
function readStdin2() {
|
|
6147
7146
|
try {
|
|
6148
|
-
return
|
|
7147
|
+
return readFileSync18(0, "utf8");
|
|
6149
7148
|
} catch {
|
|
6150
7149
|
return "";
|
|
6151
7150
|
}
|
|
@@ -6356,8 +7355,8 @@ async function doctor() {
|
|
|
6356
7355
|
}
|
|
6357
7356
|
|
|
6358
7357
|
// src/commands/write.ts
|
|
6359
|
-
import { writeFileSync as
|
|
6360
|
-
import { resolve as
|
|
7358
|
+
import { writeFileSync as writeFileSync18 } from "fs";
|
|
7359
|
+
import { resolve as resolve7 } from "path";
|
|
6361
7360
|
|
|
6362
7361
|
// src/antiai.ts
|
|
6363
7362
|
var EM_DASH = String.fromCharCode(8212);
|
|
@@ -6535,9 +7534,9 @@ async function write(args) {
|
|
|
6535
7534
|
draft = stripEmDash(draft).trimEnd();
|
|
6536
7535
|
process.stdout.write(draft + "\n");
|
|
6537
7536
|
if (p.out) {
|
|
6538
|
-
const target =
|
|
7537
|
+
const target = resolve7(process.cwd(), p.out);
|
|
6539
7538
|
try {
|
|
6540
|
-
|
|
7539
|
+
writeFileSync18(target, draft + "\n", "utf8");
|
|
6541
7540
|
process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
6542
7541
|
} catch (e) {
|
|
6543
7542
|
process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
|