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