@holt-os/holt 0.9.0 → 0.11.0

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