@holt-os/holt 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +240 -7
  2. package/dist/cli.js +3993 -289
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -41,11 +41,11 @@ function createReader() {
41
41
  closed = true;
42
42
  while (waiters.length) waiters.shift()(null);
43
43
  });
44
- const ask = (q) => new Promise((resolve4) => {
44
+ const ask = (q) => new Promise((resolve8) => {
45
45
  if (q) process.stdout.write(q);
46
- if (buffer.length) resolve4(buffer.shift());
47
- else if (closed) resolve4(null);
48
- else waiters.push(resolve4);
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
  }
@@ -98,6 +98,7 @@ function ensureWsDir() {
98
98
  }
99
99
 
100
100
  // src/config.ts
101
+ var WIKI_DEFAULT_LOCAL_MODEL = "qwen2.5:7b";
101
102
  var BRAIN_DEFS = {
102
103
  claude: { label: "Claude Code", command: "claude", args: ["-p"] },
103
104
  codex: { label: "Codex (OpenAI)", command: "codex", args: ["exec"] },
@@ -126,7 +127,15 @@ function defaultConfig() {
126
127
  const d = BRAIN_DEFS[id];
127
128
  brains[id] = { id, label: d.label, command: d.command, args: [...d.args], enabled: false };
128
129
  }
129
- return { version: 4, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown", memory: { extractFacts: true } };
130
+ return {
131
+ version: 5,
132
+ defaultBrain: null,
133
+ brains,
134
+ apiBrains: [],
135
+ outputFormat: "markdown",
136
+ memory: { extractFacts: true },
137
+ wiki: { maintainer: "brain", localModel: WIKI_DEFAULT_LOCAL_MODEL }
138
+ };
130
139
  }
131
140
  function loadConfig() {
132
141
  const path = wsConfigPath();
@@ -136,15 +145,22 @@ function loadConfig() {
136
145
  const base = defaultConfig();
137
146
  const brains = cfg.brains ?? {};
138
147
  for (const id of BRAIN_IDS) if (!brains[id]) brains[id] = base.brains[id];
148
+ const wikiIn = cfg.wiki ?? {};
149
+ const wiki = {
150
+ maintainer: wikiIn.maintainer === "local" ? "local" : "brain",
151
+ localModel: typeof wikiIn.localModel === "string" && wikiIn.localModel.trim() ? wikiIn.localModel.trim() : WIKI_DEFAULT_LOCAL_MODEL,
152
+ ...typeof wikiIn.autoSync === "boolean" ? { autoSync: wikiIn.autoSync } : {}
153
+ };
139
154
  return {
140
- version: 4,
155
+ version: 5,
141
156
  defaultBrain: cfg.defaultBrain ?? null,
142
157
  brains,
143
158
  apiBrains: Array.isArray(cfg.apiBrains) ? cfg.apiBrains : [],
144
159
  outputFormat: cfg.outputFormat === "html" ? "html" : "markdown",
145
160
  memory: {
146
161
  extractFacts: cfg.memory && typeof cfg.memory.extractFacts === "boolean" ? cfg.memory.extractFacts : true
147
- }
162
+ },
163
+ wiki
148
164
  };
149
165
  } catch {
150
166
  return null;
@@ -227,12 +243,12 @@ function renderPrompt(history, message, memory = []) {
227
243
  return parts.join("\n");
228
244
  }
229
245
  function runBrain(brain, prompt, onChunk) {
230
- return new Promise((resolve4) => {
246
+ return new Promise((resolve8) => {
231
247
  let child;
232
248
  try {
233
249
  child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
234
250
  } catch (e) {
235
- resolve4({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
251
+ resolve8({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
236
252
  return;
237
253
  }
238
254
  let out = "";
@@ -245,11 +261,11 @@ function runBrain(brain, prompt, onChunk) {
245
261
  child.stderr.on("data", (d) => {
246
262
  err2 += d.toString();
247
263
  });
248
- child.on("error", (e) => resolve4({ 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}` }));
249
265
  child.on("close", (code) => {
250
266
  const text = out.trim();
251
- if (code === 0 && text) resolve4({ ok: true, text });
252
- else resolve4({ 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}` });
253
269
  });
254
270
  });
255
271
  }
@@ -419,16 +435,16 @@ function currentAlias() {
419
435
  // src/install.ts
420
436
  import { spawn as spawn2 } from "child_process";
421
437
  function runInteractive(cmd, args) {
422
- return new Promise((resolve4) => {
438
+ return new Promise((resolve8) => {
423
439
  let child;
424
440
  try {
425
441
  child = spawn2(cmd, args, { stdio: "inherit" });
426
442
  } catch {
427
- resolve4(-1);
443
+ resolve8(-1);
428
444
  return;
429
445
  }
430
- child.on("error", () => resolve4(-1));
431
- child.on("close", (code) => resolve4(code ?? -1));
446
+ child.on("error", () => resolve8(-1));
447
+ child.on("close", (code) => resolve8(code ?? -1));
432
448
  });
433
449
  }
434
450
 
@@ -498,6 +514,13 @@ function loadTurns() {
498
514
  function appendTurn(t) {
499
515
  mkdirSync4(memDir(), { recursive: true });
500
516
  appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
517
+ if (t.role === "fact") {
518
+ try {
519
+ const ws = workspace();
520
+ if (isGlobalEnabled(ws)) appendGlobalFact(t, ws);
521
+ } catch {
522
+ }
523
+ }
501
524
  }
502
525
  function clearMemory() {
503
526
  if (existsSync4(memPath())) rmSync2(memPath());
@@ -556,6 +579,117 @@ function memStats() {
556
579
  const bytes = existsSync4(memPath()) ? statSync(memPath()).size : 0;
557
580
  return { turns: turns.length, facts, sessions, withEmbeddings, bytes };
558
581
  }
582
+ function globalMemDir() {
583
+ return join4(GLOBAL_DIR, "global");
584
+ }
585
+ function globalMemPath() {
586
+ return join4(globalMemDir(), "turns.jsonl");
587
+ }
588
+ function memoryScopesPath() {
589
+ return join4(GLOBAL_DIR, "memory-scopes.json");
590
+ }
591
+ function readScopes() {
592
+ try {
593
+ const raw = JSON.parse(readFileSync4(memoryScopesPath(), "utf8"));
594
+ return { enabled: Array.isArray(raw.enabled) ? raw.enabled.filter((s) => typeof s === "string") : [] };
595
+ } catch {
596
+ return { enabled: [] };
597
+ }
598
+ }
599
+ function writeScopes(s) {
600
+ mkdirSync4(GLOBAL_DIR, { recursive: true });
601
+ writeFileSync4(memoryScopesPath(), JSON.stringify({ enabled: s.enabled }, null, 2) + "\n", "utf8");
602
+ }
603
+ function isGlobalEnabled(ws = workspace()) {
604
+ try {
605
+ return readScopes().enabled.includes(ws);
606
+ } catch {
607
+ return false;
608
+ }
609
+ }
610
+ function globalWorkspaces() {
611
+ return readScopes().enabled.slice();
612
+ }
613
+ function loadGlobalFacts() {
614
+ try {
615
+ if (!existsSync4(globalMemPath())) return [];
616
+ const out = [];
617
+ for (const line of readFileSync4(globalMemPath(), "utf8").split("\n")) {
618
+ if (!line.trim()) continue;
619
+ try {
620
+ const row2 = JSON.parse(line);
621
+ if (row2 && typeof row2.content === "string" && typeof row2.workspace === "string") out.push(row2);
622
+ } catch {
623
+ }
624
+ }
625
+ return out;
626
+ } catch {
627
+ return [];
628
+ }
629
+ }
630
+ function appendGlobalFact(t, ws) {
631
+ try {
632
+ const norm2 = normalizeFact(t.content);
633
+ for (const g of loadGlobalFacts()) {
634
+ if (g.workspace === ws && normalizeFact(g.content) === norm2) return false;
635
+ }
636
+ mkdirSync4(globalMemDir(), { recursive: true });
637
+ const row2 = {
638
+ id: t.id,
639
+ ts: t.ts,
640
+ session: t.session,
641
+ role: "fact",
642
+ content: t.content,
643
+ workspace: ws,
644
+ ...Array.isArray(t.emb) ? { emb: t.emb } : {}
645
+ };
646
+ appendFileSync(globalMemPath(), JSON.stringify(row2) + "\n", "utf8");
647
+ return true;
648
+ } catch {
649
+ return false;
650
+ }
651
+ }
652
+ function enableGlobal(ws = workspace()) {
653
+ const s = readScopes();
654
+ if (!s.enabled.includes(ws)) {
655
+ s.enabled.push(ws);
656
+ writeScopes(s);
657
+ }
658
+ let added = 0;
659
+ for (const t of loadTurns()) {
660
+ if (t.role === "fact" && appendGlobalFact(t, ws)) added++;
661
+ }
662
+ return added;
663
+ }
664
+ function disableGlobal(ws = workspace(), purge = false) {
665
+ const s = readScopes();
666
+ const next = s.enabled.filter((w) => w !== ws);
667
+ if (next.length !== s.enabled.length) writeScopes({ enabled: next });
668
+ if (!purge) return { purged: 0 };
669
+ try {
670
+ const all = loadGlobalFacts();
671
+ const kept = all.filter((g) => g.workspace !== ws);
672
+ const purged = all.length - kept.length;
673
+ if (purged > 0) {
674
+ mkdirSync4(globalMemDir(), { recursive: true });
675
+ writeFileSync4(globalMemPath(), kept.map((g) => JSON.stringify(g)).join("\n") + (kept.length ? "\n" : ""), "utf8");
676
+ }
677
+ return { purged };
678
+ } catch {
679
+ return { purged: 0 };
680
+ }
681
+ }
682
+ function globalStats() {
683
+ const rows = loadGlobalFacts();
684
+ const workspaces = new Set(rows.map((r) => r.workspace)).size;
685
+ let bytes = 0;
686
+ try {
687
+ bytes = existsSync4(globalMemPath()) ? statSync(globalMemPath()).size : 0;
688
+ } catch {
689
+ bytes = 0;
690
+ }
691
+ return { facts: rows.length, workspaces, bytes };
692
+ }
559
693
  function cosine(a, b) {
560
694
  let dot = 0;
561
695
  let na = 0;
@@ -583,21 +717,39 @@ function keywordScore(q, text) {
583
717
  return hit / q.size;
584
718
  }
585
719
  var FACT_BOOST = 1.15;
720
+ function scoreRow(content, emb, role, qEmb, qTok) {
721
+ const useEmb = qEmb && Array.isArray(emb);
722
+ let score = useEmb ? cosine(qEmb, emb) : keywordScore(qTok, content);
723
+ if (score > (useEmb ? 0.35 : 0.15)) {
724
+ if (role === "fact") score *= FACT_BOOST;
725
+ return score;
726
+ }
727
+ return null;
728
+ }
586
729
  async function recall(query, currentSession, k = 4) {
730
+ const ws = workspace();
587
731
  const past = loadTurns().filter((t) => t.session !== currentSession);
588
- if (past.length === 0) return [];
589
732
  const qEmb = await embed(query);
590
733
  const qTok = tokens(query);
591
734
  const scored = [];
592
735
  for (const turn of past) {
593
- let score = 0;
594
- if (qEmb && Array.isArray(turn.emb)) score = cosine(qEmb, turn.emb);
595
- else score = keywordScore(qTok, turn.content);
596
- if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) {
597
- if (turn.role === "fact") score *= FACT_BOOST;
598
- scored.push({ turn, score });
736
+ const score = scoreRow(turn.content, turn.emb, turn.role, qEmb, qTok);
737
+ if (score !== null) scored.push({ turn, score });
738
+ }
739
+ if (isGlobalEnabled(ws)) {
740
+ for (const g of loadGlobalFacts()) {
741
+ if (g.workspace === ws) continue;
742
+ const score = scoreRow(g.content, g.emb, g.role, qEmb, qTok);
743
+ if (score !== null) {
744
+ scored.push({
745
+ turn: { id: g.id, ts: g.ts, session: g.session, role: g.role, content: g.content, emb: g.emb },
746
+ score,
747
+ workspace: g.workspace
748
+ });
749
+ }
599
750
  }
600
751
  }
752
+ if (scored.length === 0) return [];
601
753
  scored.sort((a, b) => b.score - a.score);
602
754
  return scored.slice(0, k);
603
755
  }
@@ -1360,6 +1512,465 @@ async function setting() {
1360
1512
  console.log("");
1361
1513
  }
1362
1514
 
1515
+ // src/commands/voice.ts
1516
+ import { readFileSync as readFileSync8 } from "fs";
1517
+ import { resolve } from "path";
1518
+
1519
+ // src/voice.ts
1520
+ import { createHash } from "crypto";
1521
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6, existsSync as existsSync7, rmSync as rmSync3 } from "fs";
1522
+ import { join as join7 } from "path";
1523
+ var VOICE_VERSION = 1;
1524
+ function voicePath() {
1525
+ return join7(GLOBAL_DIR, "voice.json");
1526
+ }
1527
+ function loadVoice() {
1528
+ try {
1529
+ const raw = readFileSync7(voicePath(), "utf8");
1530
+ const p = JSON.parse(raw);
1531
+ return {
1532
+ version: VOICE_VERSION,
1533
+ depth: p.depth === "detailed" ? "detailed" : p.depth === "quick" ? "quick" : void 0,
1534
+ answers: Array.isArray(p.answers) ? p.answers : [],
1535
+ samples: Array.isArray(p.samples) ? p.samples : [],
1536
+ style: p.style ?? void 0,
1537
+ synthesizedAt: typeof p.synthesizedAt === "number" ? p.synthesizedAt : void 0,
1538
+ synthesisNote: typeof p.synthesisNote === "string" ? p.synthesisNote : void 0,
1539
+ updatedAt: typeof p.updatedAt === "number" ? p.updatedAt : Date.now()
1540
+ };
1541
+ } catch {
1542
+ return null;
1543
+ }
1544
+ }
1545
+ function emptyVoice() {
1546
+ return { version: VOICE_VERSION, answers: [], samples: [], updatedAt: Date.now() };
1547
+ }
1548
+ function saveVoice(v) {
1549
+ try {
1550
+ mkdirSync6(GLOBAL_DIR, { recursive: true });
1551
+ v.version = VOICE_VERSION;
1552
+ v.updatedAt = Date.now();
1553
+ writeFileSync6(voicePath(), JSON.stringify(v, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1554
+ return true;
1555
+ } catch {
1556
+ return false;
1557
+ }
1558
+ }
1559
+ function clearVoice() {
1560
+ try {
1561
+ if (!existsSync7(voicePath())) return false;
1562
+ writeFileSync6(voicePath(), "{}\n", "utf8");
1563
+ rmSync3(voicePath(), { force: true });
1564
+ return true;
1565
+ } catch {
1566
+ return false;
1567
+ }
1568
+ }
1569
+ var MAX_EXCERPT = 1200;
1570
+ function hashText(text) {
1571
+ return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
1572
+ }
1573
+ function makeSample(source, text, keepExcerpt) {
1574
+ const clean = text.replace(/\s+$/g, "");
1575
+ return {
1576
+ source,
1577
+ hash: hashText(clean),
1578
+ length: clean.length,
1579
+ excerpt: keepExcerpt ? clean.slice(0, MAX_EXCERPT) : void 0,
1580
+ storedFull: keepExcerpt,
1581
+ addedAt: Date.now()
1582
+ };
1583
+ }
1584
+ function resolveDefaultBrain(cfg) {
1585
+ if (!cfg || !cfg.defaultBrain) return null;
1586
+ const id = cfg.defaultBrain;
1587
+ if (BRAIN_IDS.includes(id)) {
1588
+ const b = cfg.brains[id];
1589
+ if (b && b.enabled && isInstalled(b.command)) return { kind: "cli", id };
1590
+ return null;
1591
+ }
1592
+ const api = findApiBrain(cfg, id);
1593
+ if (api && resolveApiKey(api)) return { kind: "api", brain: api };
1594
+ return null;
1595
+ }
1596
+ async function callBrain(active, cfg, prompt) {
1597
+ try {
1598
+ const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt) : await runApiBrain(active.brain, prompt);
1599
+ return { ok: res.ok, text: res.text };
1600
+ } catch (e) {
1601
+ return { ok: false, text: e.message };
1602
+ }
1603
+ }
1604
+ function buildSynthesisPrompt(v) {
1605
+ const answers = v.answers.filter((a) => a.answer.trim()).map((a) => `- ${a.question}
1606
+ ANSWER: ${a.answer.trim()}`).join("\n");
1607
+ const samples = v.samples.map((s, i) => {
1608
+ if (s.excerpt) return `Sample ${i + 1} (${s.source}):
1609
+ ${s.excerpt}`;
1610
+ return `Sample ${i + 1} (${s.source}): [not stored, ${s.length} chars]`;
1611
+ }).join("\n\n");
1612
+ return [
1613
+ "You are analyzing how a specific person writes so their assistant can draft in their voice.",
1614
+ "From the interview answers and any writing samples below, produce a STYLE PROFILE.",
1615
+ "Focus ONLY on writing and communication style. Do not infer or invent personal facts",
1616
+ "(no name, job, location, or life details), and never add fields beyond the schema.",
1617
+ "",
1618
+ "Output ONLY a JSON object, no prose and no code fences, with these keys:",
1619
+ "{",
1620
+ ' "tone": string, // e.g. "casual, dry"',
1621
+ ' "formality": number, // 1 very casual .. 5 very formal',
1622
+ ' "avgSentenceLength": string, // e.g. "short" or "8-14 words"',
1623
+ ' "person": string, // "first" | "third" | "mixed"',
1624
+ ' "emoji": string, // "none" | "rare" | "one per post" ...',
1625
+ ' "formatting": string, // paragraph, header, and list habits',
1626
+ ' "signatureMoves": string[], // recurring devices they like',
1627
+ ' "bannedWords": string[], // words or phrases to avoid',
1628
+ ' "targetAudiences": string[], // who they write for',
1629
+ ' "soundsLike": string, // one line: what to sound like',
1630
+ ' "doesNotSoundLike": string // one line: what to avoid sounding like',
1631
+ "}",
1632
+ "If a value is unknown, use an empty string or empty array. Never guess personal info.",
1633
+ "",
1634
+ answers ? "INTERVIEW ANSWERS:\n" + answers : "INTERVIEW ANSWERS: (none)",
1635
+ "",
1636
+ samples ? "WRITING SAMPLES:\n" + samples : "WRITING SAMPLES: (none)",
1637
+ "",
1638
+ "JSON object:"
1639
+ ].join("\n");
1640
+ }
1641
+ function parseStyleProfile(raw) {
1642
+ let s = (raw || "").trim();
1643
+ s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
1644
+ const start = s.indexOf("{");
1645
+ const end = s.lastIndexOf("}");
1646
+ if (start < 0 || end <= start) return null;
1647
+ let obj;
1648
+ try {
1649
+ obj = JSON.parse(s.slice(start, end + 1));
1650
+ } catch {
1651
+ return null;
1652
+ }
1653
+ if (!obj || typeof obj !== "object") return null;
1654
+ const o = obj;
1655
+ const str = (x) => typeof x === "string" && x.trim() ? x.trim() : void 0;
1656
+ const arr = (x) => {
1657
+ if (!Array.isArray(x)) return void 0;
1658
+ const out = x.filter((i) => typeof i === "string" && i.trim().length > 0).map((i) => i.trim());
1659
+ return out.length ? out : void 0;
1660
+ };
1661
+ const num = (x) => {
1662
+ const n = typeof x === "number" ? x : typeof x === "string" ? Number(x) : NaN;
1663
+ return Number.isFinite(n) ? Math.max(1, Math.min(5, Math.round(n))) : void 0;
1664
+ };
1665
+ const profile = {
1666
+ tone: str(o["tone"]),
1667
+ formality: num(o["formality"]),
1668
+ avgSentenceLength: str(o["avgSentenceLength"]),
1669
+ person: str(o["person"]),
1670
+ emoji: str(o["emoji"]),
1671
+ formatting: str(o["formatting"]),
1672
+ signatureMoves: arr(o["signatureMoves"]),
1673
+ bannedWords: arr(o["bannedWords"]),
1674
+ targetAudiences: arr(o["targetAudiences"]),
1675
+ soundsLike: str(o["soundsLike"]),
1676
+ doesNotSoundLike: str(o["doesNotSoundLike"])
1677
+ };
1678
+ const any = Object.values(profile).some((v) => v !== void 0);
1679
+ return any ? profile : null;
1680
+ }
1681
+ async function synthesizeVoice(v) {
1682
+ const cfg = loadConfig();
1683
+ const active = resolveDefaultBrain(cfg);
1684
+ if (!active || !cfg) {
1685
+ v.synthesisNote = 'No brain configured yet. Raw answers and samples are saved; run "holt voice" again once a brain is set to build the style profile.';
1686
+ return v;
1687
+ }
1688
+ const prompt = buildSynthesisPrompt(v);
1689
+ const res = await callBrain(active, cfg, prompt);
1690
+ if (!res.ok) {
1691
+ v.synthesisNote = "Could not reach the brain to synthesize a profile. Raw answers and samples are saved.";
1692
+ return v;
1693
+ }
1694
+ const style = parseStyleProfile(res.text);
1695
+ if (!style) {
1696
+ v.synthesisNote = "The brain reply could not be parsed into a style profile. Raw answers and samples are saved.";
1697
+ return v;
1698
+ }
1699
+ v.style = style;
1700
+ v.synthesizedAt = Date.now();
1701
+ v.synthesisNote = void 0;
1702
+ return v;
1703
+ }
1704
+ function voicePromptBlock(v) {
1705
+ if (!v) return "";
1706
+ const s = v.style;
1707
+ const lines = [];
1708
+ if (s) {
1709
+ lines.push("VOICE PROFILE (write in this voice):");
1710
+ if (s.tone) lines.push(`- Tone: ${s.tone}`);
1711
+ if (typeof s.formality === "number") lines.push(`- Formality: ${s.formality}/5 (1 casual, 5 formal)`);
1712
+ if (s.avgSentenceLength) lines.push(`- Sentence length: ${s.avgSentenceLength}`);
1713
+ if (s.person) lines.push(`- Person: ${s.person}`);
1714
+ if (s.emoji) lines.push(`- Emoji: ${s.emoji}`);
1715
+ if (s.formatting) lines.push(`- Formatting habits: ${s.formatting}`);
1716
+ if (s.signatureMoves?.length) lines.push(`- Signature moves: ${s.signatureMoves.join("; ")}`);
1717
+ if (s.bannedWords?.length) lines.push(`- Never use: ${s.bannedWords.join(", ")}`);
1718
+ if (s.targetAudiences?.length) lines.push(`- Audience: ${s.targetAudiences.join(", ")}`);
1719
+ if (s.soundsLike) lines.push(`- Should sound like: ${s.soundsLike}`);
1720
+ if (s.doesNotSoundLike) lines.push(`- Should NOT sound like: ${s.doesNotSoundLike}`);
1721
+ }
1722
+ const withExcerpt = v.samples.filter((x) => x.excerpt);
1723
+ if (withExcerpt.length) {
1724
+ lines.push("", "WRITING SAMPLES from this person (match this rhythm, do not copy content):");
1725
+ for (const smp of withExcerpt.slice(0, 2)) {
1726
+ lines.push('"""', (smp.excerpt ?? "").slice(0, 600), '"""');
1727
+ }
1728
+ }
1729
+ if (!s) {
1730
+ const ans = v.answers.filter((a) => a.answer.trim());
1731
+ if (ans.length) {
1732
+ lines.push("", "The person described their writing style as:");
1733
+ for (const a of ans.slice(0, 8)) lines.push(`- ${a.question} ${a.answer.trim()}`);
1734
+ }
1735
+ }
1736
+ return lines.join("\n");
1737
+ }
1738
+
1739
+ // src/commands/voice.ts
1740
+ var QUESTIONS = [
1741
+ { key: "tone", q: "What tone do you want to sound like? (casual / professional / playful / dry, or describe it)", depth: "quick" },
1742
+ { key: "sentences", q: "Short punchy sentences, longer flowing ones, or a mix?", depth: "quick" },
1743
+ { key: "person", q: 'Do you write in first person ("I"), third person, or a mix?', depth: "quick" },
1744
+ { key: "emoji", q: "Emoji: never, rarely, or freely? Any you like or ban?", depth: "quick" },
1745
+ { key: "banned", q: "Any words or phrases you love, and any you never want used?", depth: "quick" },
1746
+ { key: "audience", q: "Who do you usually write for? (e.g. peers, customers, a general audience)", depth: "detailed" },
1747
+ { key: "formatting", q: "Formatting habits: short paragraphs, headers, bullet lists, plain prose?", depth: "detailed" },
1748
+ { key: "soundsLike", q: "Name a writer, publication, or vibe you want to sound like.", depth: "detailed" },
1749
+ { key: "avoid", q: "What do you NOT want to sound like? (e.g. corporate, salesy, robotic)", depth: "detailed" },
1750
+ { key: "signature", q: "Any signature moves? (openings you like, how you end, humor, analogies)", depth: "detailed" }
1751
+ ];
1752
+ async function askDepth(ask) {
1753
+ const a = await ask('Depth? "quick" (a few questions) or "detailed" (more): [quick] ');
1754
+ if (a === null) return null;
1755
+ const s = a.trim().toLowerCase();
1756
+ return s === "detailed" || s === "d" ? "detailed" : "quick";
1757
+ }
1758
+ async function runQuestions(ask, depth) {
1759
+ const set = QUESTIONS.filter((q) => depth === "detailed" || q.depth === "quick");
1760
+ const out = [];
1761
+ console.log(c.dim("\n Answer in your own words. Press enter to skip any question.\n"));
1762
+ for (const q of set) {
1763
+ const a = await ask(" " + q.q + "\n > ");
1764
+ if (a === null) break;
1765
+ out.push({ key: q.key, question: q.q, answer: a.trim() });
1766
+ }
1767
+ return out;
1768
+ }
1769
+ async function askKeepExcerpt(ask) {
1770
+ const a = (await ask(" Store a short excerpt so Holt can match your tone directly? [y/N] ") ?? "").trim().toLowerCase();
1771
+ return a === "y" || a === "yes";
1772
+ }
1773
+ async function addFileSample(ask, file, v) {
1774
+ const path = resolve(process.cwd(), file);
1775
+ let text;
1776
+ try {
1777
+ text = readFileSync8(path, "utf8");
1778
+ } catch (e) {
1779
+ console.error(c.red(` Could not read ${path}: ${e.message}`));
1780
+ return null;
1781
+ }
1782
+ if (!text.trim()) {
1783
+ console.error(c.red(` ${path} is empty.`));
1784
+ return null;
1785
+ }
1786
+ const keep = ask ? await askKeepExcerpt(ask) : true;
1787
+ v.samples.push(makeSample("file:" + path, text, keep));
1788
+ console.log(c.green(` Added sample from ${path}`) + c.dim(` (${text.length} chars${keep ? ", excerpt stored" : ", hash only"})`));
1789
+ return v;
1790
+ }
1791
+ async function pasteSample(ask, v) {
1792
+ console.log(c.dim(' Paste your writing sample. End with a single "." on its own line.'));
1793
+ const lines = [];
1794
+ for (; ; ) {
1795
+ const line = await ask("");
1796
+ if (line === null) break;
1797
+ if (line.trim() === ".") break;
1798
+ lines.push(line);
1799
+ }
1800
+ const text = lines.join("\n").trim();
1801
+ if (!text) {
1802
+ console.log(c.dim(" Nothing pasted, skipping."));
1803
+ return null;
1804
+ }
1805
+ const keep = await askKeepExcerpt(ask);
1806
+ v.samples.push(makeSample("paste", text, keep));
1807
+ console.log(c.green(" Sample added.") + c.dim(` (${text.length} chars${keep ? ", excerpt stored" : ", hash only"})`));
1808
+ return v;
1809
+ }
1810
+ function printStyle(s) {
1811
+ const row2 = (label, val) => {
1812
+ if (val === void 0 || Array.isArray(val) && val.length === 0) return;
1813
+ const shown = Array.isArray(val) ? val.join(", ") : String(val);
1814
+ console.log(` ${c.dim(label.padEnd(16))} ${shown}`);
1815
+ };
1816
+ row2("tone", s.tone);
1817
+ row2("formality", typeof s.formality === "number" ? `${s.formality}/5` : void 0);
1818
+ row2("sentence len", s.avgSentenceLength);
1819
+ row2("person", s.person);
1820
+ row2("emoji", s.emoji);
1821
+ row2("formatting", s.formatting);
1822
+ row2("signature", s.signatureMoves);
1823
+ row2("banned", s.bannedWords);
1824
+ row2("audience", s.targetAudiences);
1825
+ row2("sounds like", s.soundsLike);
1826
+ row2("not like", s.doesNotSoundLike);
1827
+ }
1828
+ function showProfile(v) {
1829
+ console.log("\n" + c.accent("Your writing voice") + c.dim(" (" + voicePath() + ")"));
1830
+ if (v.depth) console.log(c.dim(` interview depth: ${v.depth}`));
1831
+ console.log(c.dim(` answers: ${v.answers.filter((a) => a.answer.trim()).length} samples: ${v.samples.length}`));
1832
+ if (v.style) {
1833
+ console.log("");
1834
+ printStyle(v.style);
1835
+ } else if (v.synthesisNote) {
1836
+ console.log("\n " + c.dim(v.synthesisNote));
1837
+ } else {
1838
+ console.log("\n " + c.dim("No synthesized profile yet."));
1839
+ }
1840
+ console.log("");
1841
+ }
1842
+ var USAGE = [
1843
+ " holt voice [subcommand]",
1844
+ "",
1845
+ " (no subcommand) run the writing-style interview and build your profile",
1846
+ " add <file> add a writing sample from a file",
1847
+ " show print your voice profile",
1848
+ " edit show where voice.json lives (it is human-editable)",
1849
+ " clear remove your voice profile",
1850
+ "",
1851
+ " The interview only asks about writing and communication style.",
1852
+ " It never asks for personal details. Your profile lives at ~/.holt/voice.json."
1853
+ ].join("\n");
1854
+ async function voice(sub, args = []) {
1855
+ switch (sub) {
1856
+ case "show":
1857
+ return voiceShow();
1858
+ case "add":
1859
+ return voiceAdd(args[0]);
1860
+ case "edit":
1861
+ return voiceEdit();
1862
+ case "clear":
1863
+ return voiceClear();
1864
+ case "help":
1865
+ case "-h":
1866
+ case "--help":
1867
+ console.log(USAGE);
1868
+ return;
1869
+ case void 0:
1870
+ return voiceInterview();
1871
+ default:
1872
+ console.error(c.red(` Unknown voice subcommand: "${sub}"`));
1873
+ console.log(USAGE);
1874
+ process.exitCode = 1;
1875
+ }
1876
+ }
1877
+ async function voiceInterview() {
1878
+ const { ask, close } = createReader();
1879
+ const existing = loadVoice();
1880
+ const v = existing ?? emptyVoice();
1881
+ console.log("\n" + c.accent("Teach Holt your writing voice"));
1882
+ console.log(c.dim(" Style only. No personal questions. You can do the interview, share samples, or both.\n"));
1883
+ const mode = (await ask("Do the [i]nterview, add [s]amples, or [b]oth? [i] ") ?? "").trim().toLowerCase();
1884
+ const wantInterview = mode === "" || mode === "i" || mode === "b" || mode === "interview" || mode === "both";
1885
+ const wantSamples = mode === "s" || mode === "b" || mode === "samples" || mode === "both";
1886
+ if (wantInterview) {
1887
+ const depth = await askDepth(ask);
1888
+ if (depth === null) {
1889
+ close();
1890
+ return;
1891
+ }
1892
+ v.depth = depth;
1893
+ const answers = await runQuestions(ask, depth);
1894
+ const askedKeys = new Set(answers.map((a) => a.key));
1895
+ v.answers = [...v.answers.filter((a) => !askedKeys.has(a.key)), ...answers];
1896
+ }
1897
+ if (wantSamples) {
1898
+ for (; ; ) {
1899
+ const how = (await ask("\n Add a sample from a [f]ile or [p]aste it? (enter to finish): ") ?? "").trim().toLowerCase();
1900
+ if (how === "" || how === "q" || how === "n" || how === "done") break;
1901
+ if (how === "f" || how === "file") {
1902
+ const fp = await ask(" File path: ");
1903
+ if (fp === null) break;
1904
+ if (fp.trim()) await addFileSample(ask, fp.trim(), v);
1905
+ } else if (how === "p" || how === "paste") {
1906
+ await pasteSample(ask, v);
1907
+ } else {
1908
+ console.log(c.dim(" Type f, p, or press enter to finish."));
1909
+ }
1910
+ }
1911
+ }
1912
+ close();
1913
+ saveVoice(v);
1914
+ console.log(c.dim("\n Building your style profile from a brain..."));
1915
+ const synthed = await synthesizeVoice(v);
1916
+ saveVoice(synthed);
1917
+ if (synthed.style) {
1918
+ console.log(c.green(" Style profile ready."));
1919
+ printStyle(synthed.style);
1920
+ } else if (synthed.synthesisNote) {
1921
+ console.log(" " + c.dim(synthed.synthesisNote));
1922
+ }
1923
+ console.log(c.dim(`
1924
+ Saved to ${voicePath()}`));
1925
+ console.log(c.dim(" Draft in this voice with: ") + c.accent('holt write "..."') + "\n");
1926
+ }
1927
+ async function voiceShow() {
1928
+ const v = loadVoice();
1929
+ if (!v) {
1930
+ console.log(c.dim("\n No voice profile yet. Run ") + c.accent("holt voice") + c.dim(" to create one.\n"));
1931
+ return;
1932
+ }
1933
+ showProfile(v);
1934
+ }
1935
+ async function voiceAdd(file) {
1936
+ if (!file) {
1937
+ console.log(c.dim(" Usage: holt voice add <file>"));
1938
+ process.exitCode = 1;
1939
+ return;
1940
+ }
1941
+ const v = loadVoice() ?? emptyVoice();
1942
+ const updated = await addFileSample(null, file, v);
1943
+ if (!updated) {
1944
+ process.exitCode = 1;
1945
+ return;
1946
+ }
1947
+ saveVoice(updated);
1948
+ console.log(c.dim(" Refreshing style profile..."));
1949
+ const synthed = await synthesizeVoice(updated);
1950
+ saveVoice(synthed);
1951
+ if (synthed.synthesisNote) console.log(" " + c.dim(synthed.synthesisNote));
1952
+ else console.log(c.green(" Profile updated."));
1953
+ }
1954
+ async function voiceEdit() {
1955
+ const path = voicePath();
1956
+ const v = loadVoice();
1957
+ if (!v) {
1958
+ console.log(c.dim(`
1959
+ No profile yet. Run "holt voice" first. It will be written to:
1960
+ ${path}
1961
+ `));
1962
+ return;
1963
+ }
1964
+ console.log("\n" + c.accent("Your voice profile is a plain JSON file you can edit by hand:"));
1965
+ console.log(" " + path);
1966
+ console.log(c.dim(" Open it in any editor. Keys: answers, samples, style (tone, formality, bannedWords, ...)."));
1967
+ console.log(c.dim(' After hand-editing "answers" or "samples", run "holt voice" to re-synthesize the style.\n'));
1968
+ }
1969
+ async function voiceClear() {
1970
+ const had = clearVoice();
1971
+ console.log(had ? c.green("\n Voice profile removed.\n") : c.dim("\n No voice profile to remove.\n"));
1972
+ }
1973
+
1363
1974
  // src/commands/init.ts
1364
1975
  function parseBrains(raw, found) {
1365
1976
  const s = raw.trim().toLowerCase();
@@ -1491,6 +2102,14 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
1491
2102
  } else {
1492
2103
  console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
1493
2104
  }
2105
+ const voiceReader = createReader();
2106
+ const voiceAns = (await voiceReader.ask("Want Holt to learn how you write, so it can draft in your voice? [y/N] ") ?? "").trim().toLowerCase();
2107
+ voiceReader.close();
2108
+ if (voiceAns === "y" || voiceAns === "yes") {
2109
+ await voiceInterview();
2110
+ } else {
2111
+ console.log(c.dim(' Skipped. Run "holt voice" anytime to set up your writing voice.\n'));
2112
+ }
1494
2113
  }
1495
2114
 
1496
2115
  // src/commands/chat.ts
@@ -1605,60 +2224,574 @@ async function extractAndSaveFacts(active, cfg, history, session) {
1605
2224
  }
1606
2225
  }
1607
2226
 
1608
- // src/output.ts
1609
- import { writeFileSync as writeFileSync6 } from "fs";
1610
- import { join as join7, isAbsolute } from "path";
1611
- function escapeHtml(s) {
1612
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1613
- }
1614
- function renderInline(text) {
1615
- const codes = [];
1616
- let s = text.replace(/`([^`]+)`/g, (_m, code) => {
1617
- codes.push(`<code>${escapeHtml(code)}</code>`);
1618
- return `\0${codes.length - 1}\0`;
1619
- });
1620
- s = escapeHtml(s);
1621
- s = s.replace(/\[([^\]]+)\]\((\S*?(?:\([^)\s]*\)[^)\s]*)*)\)/g, (_m, label, url) => {
1622
- const trimmed = url.trim();
1623
- const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
1624
- const safeScheme = !scheme || /^(https?|mailto)$/i.test(scheme[1]);
1625
- if (!safeScheme) return label;
1626
- const safe = trimmed.replace(/"/g, "%22");
1627
- return `<a href="${safe}">${label}</a>`;
1628
- });
1629
- s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1630
- s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
1631
- s = s.replace(/(\d+)/g, (_m, i) => codes[Number(i)] ?? "");
1632
- return s;
2227
+ // src/wiki.ts
2228
+ import {
2229
+ readFileSync as readFileSync9,
2230
+ writeFileSync as writeFileSync7,
2231
+ existsSync as existsSync8,
2232
+ mkdirSync as mkdirSync7,
2233
+ rmSync as rmSync4,
2234
+ readdirSync as readdirSync2,
2235
+ statSync as statSync3
2236
+ } from "fs";
2237
+ import { join as join8 } from "path";
2238
+ import { totalmem } from "os";
2239
+
2240
+ // src/localmodel.ts
2241
+ var OLLAMA_URL2 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
2242
+ async function localModelStatus(model) {
2243
+ try {
2244
+ const res = await fetch(`${OLLAMA_URL2}/api/tags`, { signal: AbortSignal.timeout(1500) });
2245
+ if (!res.ok) return { reachable: false, hasModel: false };
2246
+ const data = await res.json();
2247
+ const want = model.includes(":") ? model : `${model}:latest`;
2248
+ const hasModel = !!data.models?.some((m) => {
2249
+ const n = m.name || "";
2250
+ return n === model || n === want || n.startsWith(`${model}:`);
2251
+ });
2252
+ return { reachable: true, hasModel };
2253
+ } catch {
2254
+ return { reachable: false, hasModel: false };
2255
+ }
1633
2256
  }
1634
- function markdownToHtmlBody(md) {
1635
- const lines = md.replace(/\r\n/g, "\n").split("\n");
1636
- const out = [];
1637
- let i = 0;
1638
- let listOpen = false;
1639
- let paragraph = [];
1640
- const flushParagraph = () => {
1641
- if (paragraph.length) {
1642
- out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
1643
- paragraph = [];
1644
- }
1645
- };
1646
- const closeList = () => {
1647
- if (listOpen) {
1648
- out.push("</ul>");
1649
- listOpen = false;
2257
+ async function localGenerate(model, prompt, timeoutMs = 12e4) {
2258
+ let res;
2259
+ try {
2260
+ res = await fetch(`${OLLAMA_URL2}/api/generate`, {
2261
+ method: "POST",
2262
+ headers: { "content-type": "application/json" },
2263
+ body: JSON.stringify({ model, prompt, stream: false }),
2264
+ signal: AbortSignal.timeout(timeoutMs)
2265
+ });
2266
+ } catch (e) {
2267
+ return { ok: false, text: `Could not reach local Ollama at ${OLLAMA_URL2}: ${e.message}` };
2268
+ }
2269
+ if (!res.ok) {
2270
+ let body = "";
2271
+ try {
2272
+ body = await res.text();
2273
+ } catch {
1650
2274
  }
1651
- };
1652
- while (i < lines.length) {
1653
- const line = lines[i] ?? "";
1654
- const fence = line.match(/^```(.*)$/);
1655
- if (fence) {
1656
- flushParagraph();
1657
- closeList();
1658
- const code = [];
1659
- i++;
1660
- while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
1661
- code.push(lines[i] ?? "");
2275
+ return { ok: false, text: `Ollama error ${res.status}: ${body.slice(0, 200)}` };
2276
+ }
2277
+ try {
2278
+ const data = await res.json();
2279
+ const text = (data.response || "").trim();
2280
+ if (text) return { ok: true, text };
2281
+ return { ok: false, text: "Local model returned an empty reply." };
2282
+ } catch (e) {
2283
+ return { ok: false, text: `Could not parse Ollama reply: ${e.message}` };
2284
+ }
2285
+ }
2286
+ function pullHint(model) {
2287
+ return `ollama pull ${model}`;
2288
+ }
2289
+
2290
+ // src/wiki.ts
2291
+ function wikiDir() {
2292
+ return join8(wsHoltDir(), "wiki");
2293
+ }
2294
+ function wikiStatePath() {
2295
+ return join8(wikiDir(), ".state.json");
2296
+ }
2297
+ function wikiIndexPath() {
2298
+ return join8(wikiDir(), "index.md");
2299
+ }
2300
+ function pagePath(slug) {
2301
+ return join8(wikiDir(), `${slug}.md`);
2302
+ }
2303
+ function loadState() {
2304
+ try {
2305
+ const s = JSON.parse(readFileSync9(wikiStatePath(), "utf8"));
2306
+ return { lastSyncTs: typeof s.lastSyncTs === "number" ? s.lastSyncTs : 0, updatedAt: s.updatedAt ?? 0 };
2307
+ } catch {
2308
+ return { lastSyncTs: 0, updatedAt: 0 };
2309
+ }
2310
+ }
2311
+ function saveState(s) {
2312
+ mkdirSync7(wikiDir(), { recursive: true });
2313
+ writeFileSync7(wikiStatePath(), JSON.stringify(s, null, 2) + "\n", "utf8");
2314
+ }
2315
+ function saveSyncMarker(ts) {
2316
+ saveState({ lastSyncTs: ts, updatedAt: Date.now() });
2317
+ }
2318
+ function isPageFile(name) {
2319
+ return name.endsWith(".md") && name !== "index.md";
2320
+ }
2321
+ function listPageSlugs() {
2322
+ const dir = wikiDir();
2323
+ if (!existsSync8(dir)) return [];
2324
+ try {
2325
+ return readdirSync2(dir).filter(isPageFile).map((f) => f.slice(0, -3)).sort();
2326
+ } catch {
2327
+ return [];
2328
+ }
2329
+ }
2330
+ function readPage(slug) {
2331
+ const p = pagePath(slug);
2332
+ if (!existsSync8(p)) return null;
2333
+ let raw = "";
2334
+ try {
2335
+ raw = readFileSync9(p, "utf8");
2336
+ } catch {
2337
+ return null;
2338
+ }
2339
+ const { data, body } = parseFrontmatter(raw);
2340
+ return {
2341
+ slug,
2342
+ title: data.title || slug,
2343
+ updated: data.updated || "",
2344
+ sources: parseSources(data.sources),
2345
+ body
2346
+ };
2347
+ }
2348
+ function loadPages() {
2349
+ const out = [];
2350
+ for (const slug of listPageSlugs()) {
2351
+ const pg = readPage(slug);
2352
+ if (pg) out.push(pg);
2353
+ }
2354
+ return out;
2355
+ }
2356
+ function parseSources(raw) {
2357
+ if (!raw) return [];
2358
+ return raw.replace(/^\[|\]$/g, "").split(/[\s,]+/).map((s) => s.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
2359
+ }
2360
+ var PAGE_HEADER_NOTE = "Derived page, maintained by Holt. Safe to edit; a rebuild will regenerate it from facts.";
2361
+ function renderPage(pg) {
2362
+ const fm = [
2363
+ "---",
2364
+ `title: ${pg.title}`,
2365
+ `updated: ${pg.updated}`,
2366
+ `sources: ${pg.sources.join(", ")}`,
2367
+ "---",
2368
+ "",
2369
+ `> ${PAGE_HEADER_NOTE}`,
2370
+ "",
2371
+ pg.body.trim(),
2372
+ ""
2373
+ ].join("\n");
2374
+ return fm;
2375
+ }
2376
+ function writePage(pg) {
2377
+ mkdirSync7(wikiDir(), { recursive: true });
2378
+ writeFileSync7(pagePath(pg.slug), renderPage(pg), "utf8");
2379
+ }
2380
+ var WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
2381
+ function extractLinks(body) {
2382
+ const out = [];
2383
+ let m;
2384
+ WIKILINK_RE.lastIndex = 0;
2385
+ while ((m = WIKILINK_RE.exec(body)) !== null) {
2386
+ const target = (m[1] || "").split("|")[0].trim();
2387
+ if (target) out.push(target);
2388
+ }
2389
+ return out;
2390
+ }
2391
+ function cosine2(a, b) {
2392
+ let dot = 0;
2393
+ let na = 0;
2394
+ let nb = 0;
2395
+ const n = Math.min(a.length, b.length);
2396
+ for (let i = 0; i < n; i++) {
2397
+ const x = a[i];
2398
+ const y = b[i];
2399
+ dot += x * y;
2400
+ na += x * x;
2401
+ nb += y * y;
2402
+ }
2403
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
2404
+ }
2405
+ var ROUTE_SIMILARITY_THRESHOLD = 0.55;
2406
+ async function pageEmbeddings(pages) {
2407
+ const out = [];
2408
+ for (const page of pages) {
2409
+ const emb = await embed(`${page.title}
2410
+ ${page.body.slice(0, 1500)}`);
2411
+ if (emb) out.push({ page, emb });
2412
+ }
2413
+ return out;
2414
+ }
2415
+ async function routeFact(content, targets) {
2416
+ const e = await embed(content);
2417
+ if (!e) {
2418
+ return { kind: targets.length ? "existing" : "new", slug: targets[0]?.page.slug ?? "notes", score: 0 };
2419
+ }
2420
+ let best = null;
2421
+ let bestScore = -1;
2422
+ for (const t of targets) {
2423
+ const s = cosine2(e, t.emb);
2424
+ if (s > bestScore) {
2425
+ bestScore = s;
2426
+ best = t;
2427
+ }
2428
+ }
2429
+ if (best && bestScore >= ROUTE_SIMILARITY_THRESHOLD) {
2430
+ return { kind: "existing", slug: best.page.slug, score: bestScore };
2431
+ }
2432
+ return { kind: "new", slug: proposeSlug(content), score: bestScore < 0 ? 0 : bestScore };
2433
+ }
2434
+ function proposeSlug(content) {
2435
+ const words2 = content.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !ROUTE_STOPWORDS.has(w)).slice(0, 4);
2436
+ const slug = sanitizeName(words2.join("-"));
2437
+ return slug || "notes";
2438
+ }
2439
+ var ROUTE_STOPWORDS = /* @__PURE__ */ new Set([
2440
+ "the",
2441
+ "and",
2442
+ "that",
2443
+ "this",
2444
+ "with",
2445
+ "from",
2446
+ "for",
2447
+ "was",
2448
+ "are",
2449
+ "has",
2450
+ "have",
2451
+ "will",
2452
+ "user",
2453
+ "their",
2454
+ "they",
2455
+ "about",
2456
+ "into",
2457
+ "per",
2458
+ "via"
2459
+ ]);
2460
+ async function resolveMaintainer(cfg, resolveBrain) {
2461
+ if (cfg.wiki.maintainer === "local") {
2462
+ const model = cfg.wiki.localModel;
2463
+ const status2 = await localModelStatus(model);
2464
+ if (status2.reachable && status2.hasModel) {
2465
+ const m = { kind: "local", model, label: `local (${model})` };
2466
+ return { maintainer: m, report: { maintainer: m, fellBack: false } };
2467
+ }
2468
+ const why = status2.reachable ? `local model "${model}" is not pulled. Run: ${pullHint(model)}` : "local Ollama is not reachable.";
2469
+ const brain2 = resolveBrain();
2470
+ if (brain2) {
2471
+ return {
2472
+ maintainer: brain2,
2473
+ report: { maintainer: brain2, fellBack: true, note: `${why} Falling back to ${brain2.label}.` }
2474
+ };
2475
+ }
2476
+ return { maintainer: null, report: null };
2477
+ }
2478
+ const brain = resolveBrain();
2479
+ if (!brain) return { maintainer: null, report: null };
2480
+ return { maintainer: brain, report: { maintainer: brain, fellBack: false } };
2481
+ }
2482
+ function resolveBrainMaintainer(cfg) {
2483
+ const id = cfg.defaultBrain;
2484
+ if (!id) return null;
2485
+ if (BRAIN_IDS.includes(id)) {
2486
+ const b = cfg.brains[id];
2487
+ if (!isInstalled(b.command)) return null;
2488
+ return { kind: "cli", id, label: b.label };
2489
+ }
2490
+ const api = findApiBrain(cfg, id);
2491
+ if (api && resolveApiKey(api)) return { kind: "api", brain: api, label: `${id} (api: ${api.provider}/${api.model})` };
2492
+ return null;
2493
+ }
2494
+ async function runMaintainer(m, cfg, prompt) {
2495
+ if (m.kind === "cli") return runBrain(cfg.brains[m.id], prompt);
2496
+ if (m.kind === "api") return runApiBrain(m.brain, prompt);
2497
+ const r = await localGenerate(m.model, prompt);
2498
+ return { ok: r.ok, text: r.text };
2499
+ }
2500
+ function buildPagePrompt(title, existingBody, facts, otherTitles) {
2501
+ const lines = [
2502
+ `You maintain a personal knowledge wiki. Write or update ONE page titled "${title}".`,
2503
+ "Output ONLY the page body in Markdown. No frontmatter, no code fences, no preamble.",
2504
+ "",
2505
+ "Rules:",
2506
+ "- Synthesize the facts into concise, self-contained prose (a few short paragraphs or tight bullets).",
2507
+ "- Merge new facts into the existing text; do not just append. Resolve overlaps. Keep it current.",
2508
+ "- Do not invent anything beyond the facts given.",
2509
+ '- End with a line "## Related" followed by [[Wiki Page]] links to other pages that connect.',
2510
+ "- Use [[double-bracket]] links for any other page you reference."
2511
+ ];
2512
+ if (otherTitles.length) {
2513
+ lines.push("", "Other existing pages you may link to:", ...otherTitles.map((t) => `- ${t}`));
2514
+ }
2515
+ if (existingBody.trim()) {
2516
+ lines.push("", "Current page body:", existingBody.trim());
2517
+ }
2518
+ lines.push("", "New facts to integrate:", ...facts.map((f) => `- ${f}`), "", "Updated page body:");
2519
+ return lines.join("\n");
2520
+ }
2521
+ function emptySummary() {
2522
+ return { created: 0, updated: 0, factsIntegrated: 0 };
2523
+ }
2524
+ function dedupe(a) {
2525
+ return [...new Set(a)];
2526
+ }
2527
+ function deriveTitle(content, slug) {
2528
+ const fromSlug = slug.split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
2529
+ return fromSlug || content.slice(0, 40);
2530
+ }
2531
+ function stripFrontmatterEcho(text) {
2532
+ let s = text.trim();
2533
+ s = s.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
2534
+ if (s.startsWith("---")) {
2535
+ const end = s.indexOf("\n---", 3);
2536
+ if (end >= 0) s = s.slice(end + 4).trim();
2537
+ }
2538
+ return s;
2539
+ }
2540
+ async function foldFacts(cfg, maintainer, facts) {
2541
+ const summary = emptySummary();
2542
+ if (facts.length === 0) return { summary, changed: [] };
2543
+ let pages = loadPages();
2544
+ const targets = await pageEmbeddings(pages);
2545
+ const groups = /* @__PURE__ */ new Map();
2546
+ for (const f of facts) {
2547
+ const decision = await routeFact(f.content, targets);
2548
+ const slug = decision.slug;
2549
+ let isNew = decision.kind === "new";
2550
+ const existingGroup = groups.get(slug);
2551
+ if (existingGroup) {
2552
+ existingGroup.facts.push(f);
2553
+ } else {
2554
+ if (isNew && listPageSlugs().includes(slug)) isNew = false;
2555
+ groups.set(slug, { slug, isNew, facts: [f] });
2556
+ }
2557
+ }
2558
+ const knownTitles = new Set(pages.map((p) => p.title));
2559
+ for (const group of groups.values()) {
2560
+ knownTitles.add(readPage(group.slug)?.title ?? deriveTitle(group.facts[0].content, group.slug));
2561
+ }
2562
+ const changed = [];
2563
+ for (const group of groups.values()) {
2564
+ const existing = readPage(group.slug);
2565
+ const title = existing?.title ?? deriveTitle(group.facts[0].content, group.slug);
2566
+ const prompt = buildPagePrompt(
2567
+ title,
2568
+ existing?.body ?? "",
2569
+ group.facts.map((f) => f.content),
2570
+ [...knownTitles].filter((t) => t !== title)
2571
+ );
2572
+ const res = await runMaintainer(maintainer, cfg, prompt);
2573
+ if (!res.ok || !res.text.trim()) continue;
2574
+ const body = stripFrontmatterEcho(res.text);
2575
+ const prevSources = existing?.sources ?? [];
2576
+ const page = {
2577
+ slug: group.slug,
2578
+ title,
2579
+ updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
2580
+ sources: dedupe([...prevSources, ...group.facts.map((f) => f.id)]),
2581
+ body
2582
+ };
2583
+ writePage(page);
2584
+ changed.push(page);
2585
+ if (existing) summary.updated++;
2586
+ else summary.created++;
2587
+ summary.factsIntegrated += group.facts.length;
2588
+ }
2589
+ pages = loadPages();
2590
+ writeIndex(pages);
2591
+ await indexPagesForRecall(changed);
2592
+ return { summary, changed };
2593
+ }
2594
+ async function syncWiki(cfg, resolveBrain) {
2595
+ const base = {
2596
+ ...emptySummary(),
2597
+ status: "nothing",
2598
+ freshFacts: 0,
2599
+ maintainer: null,
2600
+ report: null,
2601
+ changed: false
2602
+ };
2603
+ try {
2604
+ const state = loadState();
2605
+ const fresh = factRows().filter((f) => f.ts > state.lastSyncTs);
2606
+ if (fresh.length === 0) return base;
2607
+ base.freshFacts = fresh.length;
2608
+ const { maintainer, report } = await resolveMaintainer(cfg, resolveBrain);
2609
+ base.maintainer = maintainer;
2610
+ base.report = report;
2611
+ if (!maintainer) return { ...base, status: "no-maintainer" };
2612
+ const { summary, changed } = await foldFacts(cfg, maintainer, fresh);
2613
+ const newest = Math.max(state.lastSyncTs, ...fresh.map((f) => f.ts));
2614
+ saveSyncMarker(newest);
2615
+ return {
2616
+ ...summary,
2617
+ status: "ok",
2618
+ freshFacts: fresh.length,
2619
+ maintainer,
2620
+ report,
2621
+ changed: changed.length > 0
2622
+ };
2623
+ } catch {
2624
+ return base;
2625
+ }
2626
+ }
2627
+ function renderIndex(pages) {
2628
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2629
+ const lines = [
2630
+ "---",
2631
+ "title: Wiki Index",
2632
+ `updated: ${today}`,
2633
+ "---",
2634
+ "",
2635
+ "# Wiki",
2636
+ "",
2637
+ "Derived knowledge for this folder. Pages are synthesized from `facts.md` and",
2638
+ "`turns.jsonl` and are regenerable with `holt wiki rebuild`. Open in Obsidian to",
2639
+ "browse the [[links]].",
2640
+ ""
2641
+ ];
2642
+ if (pages.length === 0) {
2643
+ lines.push("_No pages yet. Run `holt wiki sync` after chatting._", "");
2644
+ return lines.join("\n");
2645
+ }
2646
+ lines.push("## Pages", "");
2647
+ for (const pg of [...pages].sort((a, b) => a.title.localeCompare(b.title))) {
2648
+ const when = pg.updated ? ` _(updated ${pg.updated})_` : "";
2649
+ lines.push(`- [[${pg.title}]]${when}`);
2650
+ }
2651
+ lines.push("");
2652
+ return lines.join("\n");
2653
+ }
2654
+ function writeIndex(pages) {
2655
+ mkdirSync7(wikiDir(), { recursive: true });
2656
+ writeFileSync7(wikiIndexPath(), renderIndex(pages), "utf8");
2657
+ }
2658
+ var WIKI_SESSION = "wiki";
2659
+ function wikiRowId(slug) {
2660
+ return "wiki-" + sanitizeName(slug).slice(0, 24);
2661
+ }
2662
+ function dropWikiRows(slugs) {
2663
+ const keep = loadTurns().filter((t) => !(t.session === WIKI_SESSION && slugs.has(t.id.replace(/^wiki-/, ""))));
2664
+ return keep;
2665
+ }
2666
+ async function indexPagesForRecall(pages) {
2667
+ try {
2668
+ if (pages.length === 0) return;
2669
+ const slugs = new Set(pages.map((p) => sanitizeName(p.slug).slice(0, 24)));
2670
+ const kept = dropWikiRows(slugs);
2671
+ mkdirSync7(join8(wsHoltDir(), "memory"), { recursive: true });
2672
+ writeFileSync7(
2673
+ join8(wsHoltDir(), "memory", "turns.jsonl"),
2674
+ kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
2675
+ "utf8"
2676
+ );
2677
+ for (const pg of pages) {
2678
+ const content = `${pg.title}
2679
+ ${pg.body}`.slice(0, 4e3);
2680
+ appendTurn({
2681
+ id: wikiRowId(pg.slug),
2682
+ ts: Date.now(),
2683
+ session: WIKI_SESSION,
2684
+ role: "fact",
2685
+ content,
2686
+ emb: await embed(content) ?? void 0
2687
+ });
2688
+ }
2689
+ } catch {
2690
+ }
2691
+ }
2692
+ function clearWikiRecallRows() {
2693
+ try {
2694
+ const kept = loadTurns().filter((t) => t.session !== WIKI_SESSION);
2695
+ mkdirSync7(join8(wsHoltDir(), "memory"), { recursive: true });
2696
+ writeFileSync7(
2697
+ join8(wsHoltDir(), "memory", "turns.jsonl"),
2698
+ kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
2699
+ "utf8"
2700
+ );
2701
+ } catch {
2702
+ }
2703
+ }
2704
+ function wipeWiki() {
2705
+ if (existsSync8(wikiDir())) rmSync4(wikiDir(), { recursive: true, force: true });
2706
+ }
2707
+ function wikiStats() {
2708
+ const slugs = listPageSlugs();
2709
+ let bytes = 0;
2710
+ for (const slug of slugs) {
2711
+ try {
2712
+ bytes += statSync3(pagePath(slug)).size;
2713
+ } catch {
2714
+ }
2715
+ }
2716
+ return { pages: slugs.length, bytes, lastSyncTs: loadState().lastSyncTs };
2717
+ }
2718
+ function factRows() {
2719
+ return loadTurns().filter((t) => t.role === "fact" && t.session !== WIKI_SESSION).map((t) => ({ id: t.id, content: t.content, ts: t.ts }));
2720
+ }
2721
+ var RAM_RECOMMENDATIONS = [
2722
+ { minGiB: 48, model: "qwen2.5:32b", note: "plenty of headroom for a large local model (~20GB)." },
2723
+ { minGiB: 24, model: "qwen2.5:14b", note: "a 14B model (~9GB) fits comfortably." },
2724
+ { minGiB: 16, model: "qwen2.5:7b", note: "default. ~4.7GB; tight alongside the embed model + editor. An always-on machine is a better host. Alternative: llama3.1:8b." },
2725
+ { minGiB: 0, model: "llama3.2:3b", note: "under 16GB, local is discouraged; if you insist, this ~2GB model is modest quality. The brain maintainer is the better default." }
2726
+ ];
2727
+ function totalGiB() {
2728
+ return Math.round(totalmem() / 1024 ** 3 * 10) / 10;
2729
+ }
2730
+ function recommendLocalModel(gib = totalGiB()) {
2731
+ const tier = RAM_RECOMMENDATIONS.find((r) => gib >= r.minGiB) ?? RAM_RECOMMENDATIONS[RAM_RECOMMENDATIONS.length - 1];
2732
+ return {
2733
+ gib,
2734
+ model: tier.model,
2735
+ note: tier.note,
2736
+ pull: pullHint(tier.model),
2737
+ discourageLocal: gib < 16
2738
+ };
2739
+ }
2740
+
2741
+ // src/output.ts
2742
+ import { writeFileSync as writeFileSync8 } from "fs";
2743
+ import { join as join9, isAbsolute } from "path";
2744
+ function escapeHtml(s) {
2745
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2746
+ }
2747
+ function renderInline(text) {
2748
+ const codes = [];
2749
+ let s = text.replace(/`([^`]+)`/g, (_m, code) => {
2750
+ codes.push(`<code>${escapeHtml(code)}</code>`);
2751
+ return `\0${codes.length - 1}\0`;
2752
+ });
2753
+ s = escapeHtml(s);
2754
+ s = s.replace(/\[([^\]]+)\]\((\S*?(?:\([^)\s]*\)[^)\s]*)*)\)/g, (_m, label, url) => {
2755
+ const trimmed = url.trim();
2756
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
2757
+ const safeScheme = !scheme || /^(https?|mailto)$/i.test(scheme[1]);
2758
+ if (!safeScheme) return label;
2759
+ const safe = trimmed.replace(/"/g, "%22");
2760
+ return `<a href="${safe}">${label}</a>`;
2761
+ });
2762
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
2763
+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
2764
+ s = s.replace(/(\d+)/g, (_m, i) => codes[Number(i)] ?? "");
2765
+ return s;
2766
+ }
2767
+ function markdownToHtmlBody(md) {
2768
+ const lines = md.replace(/\r\n/g, "\n").split("\n");
2769
+ const out = [];
2770
+ let i = 0;
2771
+ let listOpen = false;
2772
+ let paragraph = [];
2773
+ const flushParagraph = () => {
2774
+ if (paragraph.length) {
2775
+ out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
2776
+ paragraph = [];
2777
+ }
2778
+ };
2779
+ const closeList = () => {
2780
+ if (listOpen) {
2781
+ out.push("</ul>");
2782
+ listOpen = false;
2783
+ }
2784
+ };
2785
+ while (i < lines.length) {
2786
+ const line = lines[i] ?? "";
2787
+ const fence = line.match(/^```(.*)$/);
2788
+ if (fence) {
2789
+ flushParagraph();
2790
+ closeList();
2791
+ const code = [];
2792
+ i++;
2793
+ while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
2794
+ code.push(lines[i] ?? "");
1662
2795
  i++;
1663
2796
  }
1664
2797
  i++;
@@ -1747,8 +2880,8 @@ function saveReply(reply, format, name) {
1747
2880
  throw new Error('name must be a plain file name in this folder (no "/", "\\", or "..").');
1748
2881
  }
1749
2882
  if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
1750
- const path = join7(process.cwd(), base);
1751
- writeFileSync6(path, renderReply(reply, format), "utf8");
2883
+ const path = join9(process.cwd(), base);
2884
+ writeFileSync8(path, renderReply(reply, format), "utf8");
1752
2885
  return path;
1753
2886
  }
1754
2887
 
@@ -1980,6 +3113,13 @@ async function chat() {
1980
3113
  } catch {
1981
3114
  }
1982
3115
  }
3116
+ if (cfg.wiki?.autoSync) {
3117
+ try {
3118
+ const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
3119
+ if (res.status === "ok" && res.changed) console.log(c.dim(" updated the wiki."));
3120
+ } catch {
3121
+ }
3122
+ }
1983
3123
  close();
1984
3124
  console.log(c.dim("\nBye.\n"));
1985
3125
  }
@@ -2008,7 +3148,8 @@ async function login(which) {
2008
3148
  }
2009
3149
 
2010
3150
  // src/commands/memory.ts
2011
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
3151
+ import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
3152
+ import { basename } from "path";
2012
3153
  async function memoryCmd(sub, rest = []) {
2013
3154
  const { ask, close } = createReader();
2014
3155
  if (!await ensureTrusted(ask)) {
@@ -2055,12 +3196,12 @@ async function memoryCmd(sub, rest = []) {
2055
3196
  return;
2056
3197
  }
2057
3198
  if (action === "facts") {
2058
- if (!existsSync7(factsMdPath())) {
3199
+ if (!existsSync9(factsMdPath())) {
2059
3200
  console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
2060
3201
  close();
2061
3202
  return;
2062
3203
  }
2063
- const body = readFileSync7(factsMdPath(), "utf8").trim();
3204
+ const body = readFileSync10(factsMdPath(), "utf8").trim();
2064
3205
  console.log("\n" + body + "\n");
2065
3206
  close();
2066
3207
  return;
@@ -2077,15 +3218,23 @@ async function memoryCmd(sub, rest = []) {
2077
3218
  if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
2078
3219
  else for (const h of hits) {
2079
3220
  const when = new Date(h.turn.ts).toISOString().slice(0, 10);
2080
- console.log(` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role}) ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`);
3221
+ const src = h.workspace ? c.dim(` [${basename(h.workspace)}]`) : "";
3222
+ console.log(
3223
+ ` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role})${src} ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`
3224
+ );
2081
3225
  }
2082
3226
  console.log("");
2083
3227
  close();
2084
3228
  return;
2085
3229
  }
2086
- if (action && !["clear", "embed", "facts", "search"].includes(action)) {
3230
+ if (action === "global") {
3231
+ globalCmd((rest[0] || "").toLowerCase(), rest.slice(1));
3232
+ close();
3233
+ return;
3234
+ }
3235
+ if (action && !["clear", "embed", "facts", "search", "global"].includes(action)) {
2087
3236
  console.error(`
2088
- Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | clear
3237
+ Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | global | clear
2089
3238
  `);
2090
3239
  process.exitCode = 1;
2091
3240
  close();
@@ -2105,41 +3254,618 @@ async function memoryCmd(sub, rest = []) {
2105
3254
  console.log(c.dim(`
2106
3255
  ${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
2107
3256
  }
3257
+ const gEnabled = isGlobalEnabled(workspace());
3258
+ console.log(` global ${gEnabled ? c.green("on") + c.dim(" (contributes + reads shared facts)") : c.dim("off (this folder is isolated, the default)")}`);
2108
3259
  console.log(c.dim("\n holt memory search <query> find remembered moments"));
2109
3260
  console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
2110
3261
  console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
3262
+ console.log(c.dim(" holt memory global share high-value facts across your folders"));
2111
3263
  console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
2112
3264
  close();
2113
3265
  }
3266
+ function globalCmd(act, rest) {
3267
+ const ws = workspace();
3268
+ if (act === "on") {
3269
+ const already = isGlobalEnabled(ws);
3270
+ const added = enableGlobal(ws);
3271
+ const g = globalStats();
3272
+ if (already) console.log("\n" + c.green(" Global memory already on for this folder."));
3273
+ else console.log("\n" + c.green(" Global memory ON for this folder."));
3274
+ console.log(c.dim(` Backfilled ${added} fact${added === 1 ? "" : "s"} into the shared store.`));
3275
+ console.log(c.dim(` New facts here mirror automatically; recall now spans ${g.workspaces} contributing folder${g.workspaces === 1 ? "" : "s"}.
3276
+ `));
3277
+ return;
3278
+ }
3279
+ if (act === "off") {
3280
+ const purge = rest.includes("--purge");
3281
+ if (!isGlobalEnabled(ws) && !purge) {
3282
+ console.log("\n" + c.dim(" Global memory is already off for this folder.\n"));
3283
+ return;
3284
+ }
3285
+ const { purged } = disableGlobal(ws, purge);
3286
+ console.log("\n" + c.green(" Global memory OFF for this folder.") + c.dim(" (recall is local-only again)"));
3287
+ if (purge) console.log(c.dim(` Purged ${purged} of this folder's row${purged === 1 ? "" : "s"} from the shared store.`));
3288
+ else console.log(c.dim(" This folder's rows stay in the shared store. Re-run with --purge to remove them."));
3289
+ console.log("");
3290
+ return;
3291
+ }
3292
+ if (act === "" || act === "status") {
3293
+ const enabled = isGlobalEnabled(ws);
3294
+ const g = globalStats();
3295
+ const contributors = globalWorkspaces();
3296
+ console.log("\n" + c.accent("Holt global memory") + c.dim(" (opt-in, shared facts across your folders)"));
3297
+ console.log(` this folder ${enabled ? c.green("on") : c.dim("off (isolated, the default)")}`);
3298
+ console.log(` contributing ${g.workspaces} folder${g.workspaces === 1 ? "" : "s"}`);
3299
+ console.log(` shared facts ${g.facts}`);
3300
+ console.log(` size ${(g.bytes / 1024).toFixed(1)} KB (${globalMemPath()})`);
3301
+ console.log(` registry ${memoryScopesPath()}`);
3302
+ if (contributors.length) {
3303
+ console.log(c.dim("\n Folders sharing facts:"));
3304
+ for (const w of contributors) console.log(c.dim(` ${basename(w)} ${w === ws ? "(this folder)" : w}`));
3305
+ }
3306
+ console.log(c.dim("\n holt memory global on share this folder's facts + read others"));
3307
+ console.log(c.dim(" holt memory global off stop sharing + reading (add --purge to delete rows)"));
3308
+ console.log(c.dim(" holt memory global status this view\n"));
3309
+ return;
3310
+ }
3311
+ console.error(`
3312
+ Unknown "memory global" action: "${act}". Use: on | off [--purge] | status
3313
+ `);
3314
+ process.exitCode = 1;
3315
+ }
2114
3316
 
2115
- // src/commands/skill.ts
2116
- import {
2117
- mkdirSync as mkdirSync6,
2118
- writeFileSync as writeFileSync7,
2119
- readFileSync as readFileSync8,
2120
- existsSync as existsSync8,
2121
- readdirSync as readdirSync2,
2122
- statSync as statSync3,
2123
- rmSync as rmSync3,
2124
- cpSync,
2125
- mkdtempSync
2126
- } from "fs";
2127
- import { join as join8, resolve } from "path";
2128
- import { tmpdir } from "os";
2129
- import { spawnSync as spawnSync2 } from "child_process";
2130
- function usage() {
2131
- console.log(c.dim([
2132
- "",
2133
- " " + c.accent("holt skill") + c.dim(" - manage SKILL.md skills"),
2134
- "",
2135
- " holt skill list list installed skills",
2136
- " holt skill show <name> print a skill",
2137
- " holt skill create <name> [--global] scaffold a new skill",
2138
- " holt skill add <source> [--global] install from a git URL or local path",
2139
- " holt skill remove <name> delete a skill",
2140
- "",
2141
- " --global installs into ~/.holt/skills (available in every folder).",
2142
- " Without it, skills live in this folder at ./.holt/skills.",
3317
+ // src/commands/wiki.ts
3318
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
3319
+ import { join as join10 } from "path";
3320
+ import { spawn as spawn3 } from "child_process";
3321
+ function titleToSlug(title) {
3322
+ return proposeSlug(title);
3323
+ }
3324
+ async function syncCmd(cfg) {
3325
+ const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
3326
+ if (res.status === "nothing") {
3327
+ console.log(c.dim("\n No new facts since the last sync. Nothing to do.\n"));
3328
+ return;
3329
+ }
3330
+ if (res.status === "no-maintainer") {
3331
+ if (res.report?.note) console.log(c.dim("\n " + res.report.note));
3332
+ console.log(c.red("\n No maintainer available."));
3333
+ console.log(c.dim(" Configure a brain (holt init) or set wiki.maintainer to local with a pulled model.\n"));
3334
+ return;
3335
+ }
3336
+ const maintainer = res.maintainer;
3337
+ if (res.report?.note) console.log(c.dim("\n " + res.report.note));
3338
+ console.log("\n" + c.dim(` Integrated ${res.freshFacts} new fact${res.freshFacts === 1 ? "" : "s"} via ${maintainer.label}...`));
3339
+ console.log("\n" + c.accent("Wiki synced") + c.dim(" (this folder)"));
3340
+ console.log(` facts integrated ${res.factsIntegrated}`);
3341
+ console.log(` pages created ${res.created}`);
3342
+ console.log(` pages updated ${res.updated}`);
3343
+ console.log(` maintainer ${maintainer.label}${res.report?.fellBack ? c.dim(" (fell back)") : ""}`);
3344
+ console.log(c.dim(`
3345
+ Wiki: ${wikiDir()} (open in Obsidian to browse [[links]])
3346
+ `));
3347
+ }
3348
+ async function rebuildCmd(cfg, ask) {
3349
+ const facts = factRows();
3350
+ if (facts.length === 0) {
3351
+ console.log(c.dim("\n No facts to build from yet. Chat first so facts distill, then sync.\n"));
3352
+ return;
3353
+ }
3354
+ const a = (await ask(`
3355
+ Wipe and regenerate the wiki from ${facts.length} facts? This discards hand-edits. [y/N] `) ?? "").trim().toLowerCase();
3356
+ if (a !== "y" && a !== "yes") {
3357
+ console.log(c.dim(" Kept.\n"));
3358
+ return;
3359
+ }
3360
+ const { maintainer, report } = await resolveMaintainer(cfg, () => resolveBrainMaintainer(cfg));
3361
+ if (!maintainer) {
3362
+ console.log(c.red("\n No maintainer available. Configure a brain or a local model first.\n"));
3363
+ return;
3364
+ }
3365
+ if (report?.note) console.log(c.dim(" " + report.note));
3366
+ wipeWiki();
3367
+ clearWikiRecallRows();
3368
+ console.log(c.dim(`
3369
+ Rebuilding from ${facts.length} facts via ${maintainer.label}...`));
3370
+ const { summary } = await foldFacts(cfg, maintainer, facts);
3371
+ saveSyncMarker(Math.max(0, ...facts.map((f) => f.ts)));
3372
+ console.log("\n" + c.accent("Wiki rebuilt"));
3373
+ console.log(` pages ${summary.created}`);
3374
+ console.log(` facts folded ${summary.factsIntegrated}`);
3375
+ console.log(c.dim(`
3376
+ Wiki: ${wikiDir()}
3377
+ `));
3378
+ }
3379
+ function buildLintPrompt(pages) {
3380
+ const lines = [
3381
+ "You are auditing a personal knowledge wiki for quality. Review the pages below.",
3382
+ "Report, concisely and as plain text (no code fences):",
3383
+ "- CONTRADICTIONS: statements across pages that conflict.",
3384
+ "- DUPLICATES: pages or passages that overlap and should merge.",
3385
+ "- GAPS: obvious missing links ([[...]]) or topics implied but not written.",
3386
+ "If a category is clean, say so in one line. Do not rewrite the pages.",
3387
+ "",
3388
+ "Pages:"
3389
+ ];
3390
+ for (const pg of pages) {
3391
+ lines.push("", `### ${pg.title} (${pg.slug})`, pg.body.slice(0, 1200));
3392
+ }
3393
+ return lines.join("\n");
3394
+ }
3395
+ var FIX_BEGIN = "<<<HOLT-PAGE ";
3396
+ var FIX_END = "<<<HOLT-END>>>";
3397
+ function buildFixPrompt(pages) {
3398
+ const lines = [
3399
+ "You maintain a personal knowledge wiki. Fix quality issues across the pages",
3400
+ "below: resolve CONTRADICTIONS (keep the most recent/consistent statement),",
3401
+ "merge DUPLICATE passages, and add obvious missing [[wikilinks]]. Do not invent",
3402
+ "new facts; only reconcile and de-duplicate what is already written.",
3403
+ "",
3404
+ "Return EVERY page, even unchanged ones, using EXACTLY this format and nothing",
3405
+ "else (no prose, no code fences, no frontmatter):",
3406
+ "",
3407
+ `${FIX_BEGIN}<slug>>>>`,
3408
+ '<corrected page body in Markdown, ending with a "## Related" section of [[links]]>',
3409
+ FIX_END,
3410
+ "",
3411
+ "Pages:"
3412
+ ];
3413
+ for (const pg of pages) {
3414
+ lines.push("", `${FIX_BEGIN}${pg.slug}>>>`, `### ${pg.title}`, pg.body.trim(), FIX_END);
3415
+ }
3416
+ return lines.join("\n");
3417
+ }
3418
+ function parseFixReply(raw) {
3419
+ const out = /* @__PURE__ */ new Map();
3420
+ const text = raw || "";
3421
+ let i = 0;
3422
+ while (true) {
3423
+ const begin = text.indexOf(FIX_BEGIN, i);
3424
+ if (begin < 0) break;
3425
+ const slugStart = begin + FIX_BEGIN.length;
3426
+ const slugEnd = text.indexOf(">>>", slugStart);
3427
+ if (slugEnd < 0) break;
3428
+ const slug = text.slice(slugStart, slugEnd).trim();
3429
+ const bodyStart = slugEnd + 3;
3430
+ const end = text.indexOf(FIX_END, bodyStart);
3431
+ if (end < 0) break;
3432
+ let body = text.slice(bodyStart, end).trim();
3433
+ body = body.replace(/^###[^\n]*\n+/, "").trim();
3434
+ if (slug && body) out.set(slug, body);
3435
+ i = end + FIX_END.length;
3436
+ }
3437
+ return out;
3438
+ }
3439
+ async function lintCmd(cfg, fix) {
3440
+ const pages = loadPages();
3441
+ if (pages.length === 0) {
3442
+ console.log(c.dim('\n No pages to lint. Run "holt wiki sync" first.\n'));
3443
+ return;
3444
+ }
3445
+ const { maintainer, report } = await resolveMaintainer(cfg, () => resolveBrainMaintainer(cfg));
3446
+ if (!maintainer) {
3447
+ console.log(c.red("\n No maintainer available. Configure a brain or a local model first.\n"));
3448
+ return;
3449
+ }
3450
+ if (report?.note) console.log(c.dim("\n " + report.note));
3451
+ if (!fix) {
3452
+ console.log(c.dim(`
3453
+ Auditing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
3454
+ `));
3455
+ const res2 = await runMaintainer(maintainer, cfg, buildLintPrompt(pages));
3456
+ if (!res2.ok || !res2.text.trim()) {
3457
+ console.log(c.dim(" Lint could not produce a report (maintainer returned nothing).\n"));
3458
+ return;
3459
+ }
3460
+ console.log(res2.text.trim() + "\n");
3461
+ return;
3462
+ }
3463
+ console.log(c.dim("\n --fix will rewrite affected pages. Put .holt/wiki under git or back it up first;"));
3464
+ console.log(c.dim(' a bad fix is fully recoverable with "holt wiki rebuild".'));
3465
+ console.log(c.dim(`
3466
+ Auditing + fixing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
3467
+ `));
3468
+ const res = await runMaintainer(maintainer, cfg, buildFixPrompt(pages));
3469
+ if (!res.ok || !res.text.trim()) {
3470
+ console.log(c.dim(" Lint --fix could not produce corrections (maintainer returned nothing). Files untouched.\n"));
3471
+ return;
3472
+ }
3473
+ const corrected = parseFixReply(res.text);
3474
+ if (corrected.size === 0) {
3475
+ console.log(c.dim(" No parseable corrections returned. Files untouched.\n"));
3476
+ return;
3477
+ }
3478
+ const byPage = new Map(pages.map((p) => [p.slug, p]));
3479
+ const applied = [];
3480
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3481
+ for (const [slug, body] of corrected) {
3482
+ const page = byPage.get(slug);
3483
+ if (!page) continue;
3484
+ if (body.trim() === page.body.trim()) continue;
3485
+ try {
3486
+ writePage({ ...page, body: body.trim(), updated: today });
3487
+ applied.push(page.title);
3488
+ } catch {
3489
+ }
3490
+ }
3491
+ if (applied.length === 0) {
3492
+ console.log(c.dim(" Lint found nothing to change. Pages already clean.\n"));
3493
+ return;
3494
+ }
3495
+ console.log("\n" + c.accent("Wiki lint applied") + c.dim(" (this folder)"));
3496
+ console.log(` pages rewritten ${applied.length}`);
3497
+ for (const t of applied) console.log(c.dim(` - ${t}`));
3498
+ console.log(c.dim(`
3499
+ Recover any page with "holt wiki rebuild". Wiki: ${wikiDir()}
3500
+ `));
3501
+ }
3502
+ function listCmd() {
3503
+ const slugs = listPageSlugs();
3504
+ if (slugs.length === 0) {
3505
+ console.log(c.dim('\n No wiki pages yet. Run "holt wiki sync" after chatting.\n'));
3506
+ return;
3507
+ }
3508
+ console.log("\n" + c.accent("Wiki pages") + c.dim(" (this folder)"));
3509
+ for (const slug of slugs) {
3510
+ const pg = readPage(slug);
3511
+ if (!pg) continue;
3512
+ const size = existsSync10(join10(wikiDir(), slug + ".md")) ? (readFileSync11(join10(wikiDir(), slug + ".md"), "utf8").length / 1024).toFixed(1) : "0.0";
3513
+ console.log(` ${c.cyan(pg.title.padEnd(28).slice(0, 28))} ${c.dim(pg.updated || " ")} ${size} KB`);
3514
+ }
3515
+ console.log("");
3516
+ }
3517
+ function showCmd(name) {
3518
+ if (!name) {
3519
+ console.log(c.dim("\n Usage: holt wiki show <page>\n"));
3520
+ return;
3521
+ }
3522
+ const want = name.toLowerCase();
3523
+ const slug = listPageSlugs().find((s) => s.toLowerCase() === want || s.toLowerCase() === titleToSlug(name)) ?? loadPages().find((p) => p.title.toLowerCase() === want)?.slug;
3524
+ if (!slug) {
3525
+ console.log(c.dim(`
3526
+ No page "${name}". Try "holt wiki list".
3527
+ `));
3528
+ return;
3529
+ }
3530
+ const raw = readFileSync11(join10(wikiDir(), slug + ".md"), "utf8");
3531
+ console.log("\n" + raw.trim() + "\n");
3532
+ }
3533
+ async function statusCmd(cfg) {
3534
+ const stats = wikiStats();
3535
+ const rec = recommendLocalModel();
3536
+ const state = loadState();
3537
+ const lastSync = state.lastSyncTs ? new Date(state.lastSyncTs).toISOString().slice(0, 10) : "never";
3538
+ console.log("\n" + c.accent("Holt wiki") + c.dim(" (this folder)"));
3539
+ console.log(` maintainer ${cfg.wiki.maintainer}`);
3540
+ console.log(` localModel ${cfg.wiki.localModel}`);
3541
+ console.log(` auto-sync ${cfg.wiki.autoSync ? c.green("on") : c.dim("off")}${cfg.wiki.autoSync ? "" : c.dim(" (holt wiki auto on)")}`);
3542
+ console.log(` pages ${stats.pages}`);
3543
+ console.log(` size ${(stats.bytes / 1024).toFixed(1)} KB (./.holt/wiki/)`);
3544
+ console.log(` last sync ${lastSync}`);
3545
+ if (cfg.wiki.maintainer === "local") {
3546
+ const st = await localModelStatus(cfg.wiki.localModel);
3547
+ const line = st.reachable ? st.hasModel ? c.green("ready") : c.red("model not pulled") + c.dim(` (${pullHint(cfg.wiki.localModel)})`) : c.red("Ollama not reachable");
3548
+ console.log(` local status ${line}`);
3549
+ }
3550
+ console.log(c.dim(`
3551
+ RAM ${rec.gib} GiB -> recommended local model: ${rec.model}`));
3552
+ console.log(c.dim(` ${rec.note}`));
3553
+ if (rec.discourageLocal) console.log(c.dim(" Local maintenance is discouraged at this RAM; the brain maintainer is recommended."));
3554
+ console.log(c.dim(` Pull it with: ${rec.pull}`));
3555
+ console.log(c.dim("\n holt wiki sync fold new facts into pages"));
3556
+ console.log(c.dim(" holt wiki auto [on|off] auto-sync the wiki when a session ends"));
3557
+ console.log(c.dim(" holt wiki rebuild regenerate the whole wiki from facts"));
3558
+ console.log(c.dim(" holt wiki lint [--fix] audit (and with --fix, apply) contradiction/duplicate fixes"));
3559
+ console.log(c.dim(" holt wiki list / show <p> browse pages"));
3560
+ console.log(c.dim(" holt wiki open open in your default app (Obsidian reads it natively)\n"));
3561
+ }
3562
+ function autoCmd(cfg, arg) {
3563
+ const want = (arg || "").toLowerCase();
3564
+ if (want === "") {
3565
+ const on2 = cfg.wiki.autoSync === true;
3566
+ console.log("\n" + c.accent("Wiki auto-sync") + c.dim(" (this folder)"));
3567
+ console.log(` ${on2 ? c.green("on") : c.dim("off")}`);
3568
+ console.log(c.dim(" usage: holt wiki auto on | off"));
3569
+ console.log(c.dim(" When on, the wiki folds new facts in automatically after a chat session"));
3570
+ console.log(c.dim(' or when the Claude Code Stop hook captures facts. No manual "holt wiki sync".\n'));
3571
+ return;
3572
+ }
3573
+ if (want !== "on" && want !== "off") {
3574
+ console.log(c.dim("\n usage: holt wiki auto on | off\n"));
3575
+ return;
3576
+ }
3577
+ cfg.wiki.autoSync = want === "on";
3578
+ saveConfig(cfg);
3579
+ console.log(
3580
+ want === "on" ? c.green("\n wiki auto-sync: on") + c.dim(" (syncs at session end)\n") : c.dim("\n wiki auto-sync: off\n")
3581
+ );
3582
+ }
3583
+ function setupCmd(cfg) {
3584
+ const rec = recommendLocalModel();
3585
+ console.log("\n" + c.accent("Local wiki maintainer: model recommendation"));
3586
+ console.log(` detected RAM ${rec.gib} GiB`);
3587
+ console.log(` recommended ${c.cyan(rec.model)}`);
3588
+ console.log(` why ${rec.note}`);
3589
+ if (rec.discourageLocal) {
3590
+ console.log(c.dim("\n At under 16 GiB, keep wiki.maintainer = brain (rides your Claude plan, no marginal RAM)."));
3591
+ }
3592
+ console.log(c.dim(`
3593
+ To use it locally:`));
3594
+ console.log(c.dim(` 1. ${rec.pull}`));
3595
+ console.log(c.dim(` 2. set wiki.maintainer = "local" and wiki.localModel = "${rec.model}" in .holt/config.json`));
3596
+ console.log(c.dim(` Current: maintainer=${cfg.wiki.maintainer}, localModel=${cfg.wiki.localModel}
3597
+ `));
3598
+ }
3599
+ function openCmd() {
3600
+ const target = existsSync10(wikiIndexPath()) ? wikiIndexPath() : wikiDir();
3601
+ if (!existsSync10(target)) {
3602
+ console.log(c.dim('\n No wiki yet. Run "holt wiki sync" first.\n'));
3603
+ return;
3604
+ }
3605
+ try {
3606
+ let cmd;
3607
+ let args;
3608
+ if (process.platform === "darwin") {
3609
+ cmd = "open";
3610
+ args = [target];
3611
+ } else if (process.platform === "win32") {
3612
+ cmd = "cmd";
3613
+ args = ["/c", "start", "", target];
3614
+ } else {
3615
+ cmd = "xdg-open";
3616
+ args = [target];
3617
+ }
3618
+ const child = spawn3(cmd, args, { detached: true, stdio: "ignore" });
3619
+ child.on("error", () => {
3620
+ });
3621
+ child.unref();
3622
+ console.log(c.dim(`
3623
+ Opening ${target}`));
3624
+ console.log(c.dim(" Tip: Obsidian can open .holt/wiki as a vault and read the [[links]] natively.\n"));
3625
+ } catch {
3626
+ console.log(c.dim(`
3627
+ Could not open it. Path: ${target}
3628
+ `));
3629
+ }
3630
+ }
3631
+ async function wikiCmd(sub, rest = []) {
3632
+ const action = (sub || "").toLowerCase();
3633
+ const { ask, close } = createReader();
3634
+ if (!await ensureTrusted(ask)) {
3635
+ close();
3636
+ return;
3637
+ }
3638
+ const cfg = loadConfig();
3639
+ if (!cfg) {
3640
+ console.log(c.dim('\n No Holt setup in this folder. Run "holt init" first.\n'));
3641
+ close();
3642
+ return;
3643
+ }
3644
+ try {
3645
+ switch (action) {
3646
+ case "sync":
3647
+ await syncCmd(cfg);
3648
+ break;
3649
+ case "auto":
3650
+ autoCmd(cfg, rest[0] ?? "");
3651
+ break;
3652
+ case "rebuild":
3653
+ await rebuildCmd(cfg, ask);
3654
+ break;
3655
+ case "lint":
3656
+ await lintCmd(cfg, rest.includes("--fix"));
3657
+ break;
3658
+ case "list":
3659
+ listCmd();
3660
+ break;
3661
+ case "show":
3662
+ showCmd(rest.join(" ").trim());
3663
+ break;
3664
+ case "open":
3665
+ openCmd();
3666
+ break;
3667
+ case "setup":
3668
+ case "recommend":
3669
+ setupCmd(cfg);
3670
+ break;
3671
+ case "":
3672
+ case "status":
3673
+ await statusCmd(cfg);
3674
+ break;
3675
+ default:
3676
+ console.error(`
3677
+ Unknown wiki subcommand: "${sub}". Use: sync | auto | rebuild | lint | list | show <page> | open | status | setup
3678
+ `);
3679
+ process.exitCode = 1;
3680
+ }
3681
+ } finally {
3682
+ close();
3683
+ }
3684
+ }
3685
+
3686
+ // src/commands/skill.ts
3687
+ import {
3688
+ mkdirSync as mkdirSync9,
3689
+ writeFileSync as writeFileSync10,
3690
+ readFileSync as readFileSync13,
3691
+ existsSync as existsSync12,
3692
+ readdirSync as readdirSync3,
3693
+ statSync as statSync5,
3694
+ rmSync as rmSync5,
3695
+ cpSync,
3696
+ mkdtempSync
3697
+ } from "fs";
3698
+ import { join as join12, resolve as resolve3 } from "path";
3699
+ import { tmpdir } from "os";
3700
+ import { spawnSync as spawnSync2 } from "child_process";
3701
+
3702
+ // src/registry.ts
3703
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync9, existsSync as existsSync11, mkdirSync as mkdirSync8, statSync as statSync4 } from "fs";
3704
+ import { join as join11, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
3705
+ import { pathToFileURL, fileURLToPath } from "url";
3706
+ var DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/holt-os/registry/main/registry.json";
3707
+ var REGISTRY_REPO_URL = "https://github.com/holt-os/registry";
3708
+ var CACHE_TTL_MS = 60 * 60 * 1e3;
3709
+ function registryCachePath() {
3710
+ return join11(GLOBAL_DIR, "registry-cache.json");
3711
+ }
3712
+ function registryUrl() {
3713
+ const env = (process.env.HOLT_REGISTRY_URL || "").trim();
3714
+ return env || DEFAULT_REGISTRY_URL;
3715
+ }
3716
+ function coerceRegistry(value) {
3717
+ if (!value || typeof value !== "object") return null;
3718
+ const obj = value;
3719
+ const skillsRaw = Array.isArray(obj.skills) ? obj.skills : [];
3720
+ const skills = [];
3721
+ for (const row2 of skillsRaw) {
3722
+ if (!row2 || typeof row2 !== "object") continue;
3723
+ const r = row2;
3724
+ const name = typeof r.name === "string" ? r.name.trim() : "";
3725
+ const source = typeof r.source === "string" ? r.source.trim() : "";
3726
+ if (!name || !source) continue;
3727
+ const description = typeof r.description === "string" ? r.description.trim() : "";
3728
+ const author = typeof r.author === "string" ? r.author.trim() : "";
3729
+ const tags = Array.isArray(r.tags) ? r.tags.filter((t) => typeof t === "string").map((t) => t.trim()).filter(Boolean) : void 0;
3730
+ const entry = { name, description, source, author };
3731
+ if (tags && tags.length) entry.tags = tags;
3732
+ skills.push(entry);
3733
+ }
3734
+ const version = typeof obj.version === "number" ? obj.version : 1;
3735
+ return { version, skills };
3736
+ }
3737
+ function parseRegistry(text) {
3738
+ let parsed;
3739
+ try {
3740
+ parsed = JSON.parse(text);
3741
+ } catch {
3742
+ return null;
3743
+ }
3744
+ return coerceRegistry(parsed);
3745
+ }
3746
+ function readCache() {
3747
+ try {
3748
+ const path = registryCachePath();
3749
+ if (!existsSync11(path)) return null;
3750
+ const raw = JSON.parse(readFileSync12(path, "utf8"));
3751
+ if (!raw || typeof raw.fetchedAt !== "number" || typeof raw.url !== "string") return null;
3752
+ const registry = coerceRegistry(raw.registry);
3753
+ if (!registry) return null;
3754
+ return { fetchedAt: raw.fetchedAt, url: raw.url, registry };
3755
+ } catch {
3756
+ return null;
3757
+ }
3758
+ }
3759
+ function writeCache(url, registry) {
3760
+ try {
3761
+ mkdirSync8(GLOBAL_DIR, { recursive: true });
3762
+ const body = { fetchedAt: Date.now(), url, registry };
3763
+ writeFileSync9(registryCachePath(), JSON.stringify(body, null, 2) + "\n", "utf8");
3764
+ } catch {
3765
+ }
3766
+ }
3767
+ function localPathFor(loc) {
3768
+ if (loc.startsWith("file://")) {
3769
+ try {
3770
+ return fileURLToPath(loc);
3771
+ } catch {
3772
+ return null;
3773
+ }
3774
+ }
3775
+ if (/^https?:\/\//i.test(loc)) return null;
3776
+ return isAbsolute2(loc) ? loc : resolve2(loc);
3777
+ }
3778
+ var UNREACHABLE_MSG = "no registry reachable; set HOLT_REGISTRY_URL or the community registry is not live yet";
3779
+ function loadLocal(path) {
3780
+ try {
3781
+ if (!existsSync11(path)) {
3782
+ return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
3783
+ }
3784
+ const registry = parseRegistry(readFileSync12(path, "utf8"));
3785
+ if (!registry) {
3786
+ return { registry: null, fromCache: false, error: "registry file is not valid JSON" };
3787
+ }
3788
+ return { registry, fromCache: false };
3789
+ } catch {
3790
+ return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
3791
+ }
3792
+ }
3793
+ async function loadRemote(url) {
3794
+ try {
3795
+ const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
3796
+ if (!res.ok) {
3797
+ return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
3798
+ }
3799
+ const registry = parseRegistry(await res.text());
3800
+ if (!registry) {
3801
+ return { registry: null, fromCache: false, error: "registry response is not valid JSON" };
3802
+ }
3803
+ return { registry, fromCache: false };
3804
+ } catch {
3805
+ return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
3806
+ }
3807
+ }
3808
+ async function loadRegistry(opts = {}) {
3809
+ const url = registryUrl();
3810
+ const cached = readCache();
3811
+ const fresh = cached && cached.url === url && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
3812
+ if (!opts.refresh && fresh && cached) {
3813
+ return { registry: cached.registry, fromCache: true };
3814
+ }
3815
+ const local = localPathFor(url);
3816
+ const result = local ? loadLocal(local) : await loadRemote(url);
3817
+ if (result.registry) {
3818
+ writeCache(url, result.registry);
3819
+ return result;
3820
+ }
3821
+ if (cached && cached.url === url) {
3822
+ return { registry: cached.registry, fromCache: true, error: result.error };
3823
+ }
3824
+ return result;
3825
+ }
3826
+ function searchSkills(skills, query) {
3827
+ const q = query.trim().toLowerCase();
3828
+ if (!q) return [...skills].sort((a, b) => a.name.localeCompare(b.name));
3829
+ const rank = (s) => {
3830
+ if (s.name.toLowerCase().includes(q)) return 0;
3831
+ if (s.description.toLowerCase().includes(q)) return 1;
3832
+ if ((s.tags || []).some((t) => t.toLowerCase().includes(q))) return 2;
3833
+ return 3;
3834
+ };
3835
+ return skills.map((s) => ({ s, r: rank(s) })).filter((x) => x.r < 3).sort((a, b) => a.r !== b.r ? a.r - b.r : a.s.name.localeCompare(b.s.name)).map((x) => x.s);
3836
+ }
3837
+ function resolveByName(skills, name) {
3838
+ const want = name.trim().toLowerCase();
3839
+ return skills.find((s) => s.name.toLowerCase() === want) || null;
3840
+ }
3841
+ function buildPublishEntry(input) {
3842
+ const entry = {
3843
+ name: input.name,
3844
+ description: input.description,
3845
+ source: input.source,
3846
+ author: input.author
3847
+ };
3848
+ if (input.tags && input.tags.length) entry.tags = input.tags;
3849
+ return JSON.stringify(entry, null, 2).split("\n").map((l) => " " + l).join("\n");
3850
+ }
3851
+
3852
+ // src/commands/skill.ts
3853
+ function usage() {
3854
+ console.log(c.dim([
3855
+ "",
3856
+ " " + c.accent("holt skill") + c.dim(" - manage SKILL.md skills"),
3857
+ "",
3858
+ " holt skill list list installed skills",
3859
+ " holt skill show <name> print a skill",
3860
+ " holt skill create <name> [--global] scaffold a new skill",
3861
+ " holt skill search <query> find skills in the registry",
3862
+ " holt skill add <src|name> [--global] install from a git URL, path, or registry name",
3863
+ " holt skill publish [<name>] prepare a skill for the registry (prints a PR entry)",
3864
+ " holt skill remove <name> delete a skill",
3865
+ "",
3866
+ " --global installs into ~/.holt/skills (available in every folder).",
3867
+ " Without it, skills live in this folder at ./.holt/skills.",
3868
+ " search/add-by-name use the registry (HOLT_REGISTRY_URL or the community index).",
2143
3869
  ""
2144
3870
  ].join("\n")));
2145
3871
  }
@@ -2170,12 +3896,12 @@ function isGitUrl(s) {
2170
3896
  return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
2171
3897
  }
2172
3898
  function findSkillDir(root) {
2173
- if (existsSync8(join8(root, "SKILL.md"))) return root;
3899
+ if (existsSync12(join12(root, "SKILL.md"))) return root;
2174
3900
  let subs = [];
2175
3901
  try {
2176
- subs = readdirSync2(root).filter((f) => {
3902
+ subs = readdirSync3(root).filter((f) => {
2177
3903
  try {
2178
- return statSync3(join8(root, f)).isDirectory();
3904
+ return statSync5(join12(root, f)).isDirectory();
2179
3905
  } catch {
2180
3906
  return false;
2181
3907
  }
@@ -2183,8 +3909,8 @@ function findSkillDir(root) {
2183
3909
  } catch {
2184
3910
  return null;
2185
3911
  }
2186
- const withSkill = subs.filter((f) => existsSync8(join8(root, f, "SKILL.md")));
2187
- if (withSkill.length === 1) return join8(root, withSkill[0]);
3912
+ const withSkill = subs.filter((f) => existsSync12(join12(root, f, "SKILL.md")));
3913
+ if (withSkill.length === 1) return join12(root, withSkill[0]);
2188
3914
  return null;
2189
3915
  }
2190
3916
  function cmdList() {
@@ -2224,7 +3950,7 @@ function cmdShow(name) {
2224
3950
  const { skill, body } = loaded;
2225
3951
  console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
2226
3952
  if (skill.description) console.log(" " + skill.description);
2227
- console.log(c.dim(" " + join8(skill.dir, "SKILL.md")));
3953
+ console.log(c.dim(" " + join12(skill.dir, "SKILL.md")));
2228
3954
  console.log("");
2229
3955
  console.log(c.dim(body));
2230
3956
  console.log("");
@@ -2236,60 +3962,55 @@ function cmdCreate(name, global) {
2236
3962
  return;
2237
3963
  }
2238
3964
  const scope = global ? "global" : "workspace";
2239
- const dir = join8(skillsRoot(scope), clean);
2240
- if (existsSync8(dir)) {
3965
+ const dir = join12(skillsRoot(scope), clean);
3966
+ if (existsSync12(dir)) {
2241
3967
  console.error(c.red(`
2242
3968
  A ${scope} skill named "${clean}" already exists.`));
2243
3969
  console.error(c.dim(" " + dir + "\n"));
2244
3970
  process.exitCode = 1;
2245
3971
  return;
2246
3972
  }
2247
- mkdirSync6(dir, { recursive: true });
2248
- writeFileSync7(join8(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
3973
+ mkdirSync9(dir, { recursive: true });
3974
+ writeFileSync10(join12(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
2249
3975
  console.log(c.green(`
2250
3976
  Created ${scope} skill "${clean}".`));
2251
- console.log(c.dim(" " + join8(dir, "SKILL.md")));
3977
+ console.log(c.dim(" " + join12(dir, "SKILL.md")));
2252
3978
  console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
2253
3979
  }
2254
- function cmdAdd(source, global) {
2255
- if (!source) {
2256
- console.log(c.dim("\n Usage: holt skill add <git-url|path> [--global]\n"));
2257
- return;
2258
- }
2259
- const scope = global ? "global" : "workspace";
3980
+ function installFromSource(source, scope) {
2260
3981
  let fetched = "";
2261
3982
  let tempDir = "";
2262
3983
  try {
2263
3984
  if (isGitUrl(source)) {
2264
- tempDir = mkdtempSync(join8(tmpdir(), "holt-skill-"));
3985
+ tempDir = mkdtempSync(join12(tmpdir(), "holt-skill-"));
2265
3986
  console.log(c.dim(`
2266
3987
  Cloning ${source} ...`));
2267
3988
  const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
2268
3989
  if (res.status !== 0) {
2269
3990
  console.error(c.red(" Clone failed. Check the URL and that git is installed.\n"));
2270
3991
  process.exitCode = 1;
2271
- return;
3992
+ return false;
2272
3993
  }
2273
3994
  fetched = tempDir;
2274
3995
  } else {
2275
- fetched = resolve(source);
2276
- if (!existsSync8(fetched)) {
3996
+ fetched = resolve3(source);
3997
+ if (!existsSync12(fetched)) {
2277
3998
  console.error(c.red(`
2278
3999
  No such path: ${fetched}
2279
4000
  `));
2280
4001
  process.exitCode = 1;
2281
- return;
4002
+ return false;
2282
4003
  }
2283
4004
  }
2284
4005
  const skillDir = findSkillDir(fetched);
2285
4006
  if (!skillDir) {
2286
4007
  console.error(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
2287
4008
  process.exitCode = 1;
2288
- return;
4009
+ return false;
2289
4010
  }
2290
4011
  let skillName = "";
2291
4012
  try {
2292
- const { data } = parseFrontmatter(readFileSync8(join8(skillDir, "SKILL.md"), "utf8"));
4013
+ const { data } = parseFrontmatter(readFileSync13(join12(skillDir, "SKILL.md"), "utf8"));
2293
4014
  skillName = sanitizeName(data.name || "");
2294
4015
  } catch {
2295
4016
  skillName = "";
@@ -2298,41 +4019,167 @@ function cmdAdd(source, global) {
2298
4019
  if (!skillName) {
2299
4020
  console.error(c.red("\n Could not determine a valid skill name.\n"));
2300
4021
  process.exitCode = 1;
2301
- return;
4022
+ return false;
2302
4023
  }
2303
- const dest = join8(skillsRoot(scope), skillName);
2304
- if (existsSync8(dest)) {
4024
+ const dest = join12(skillsRoot(scope), skillName);
4025
+ if (existsSync12(dest)) {
2305
4026
  console.error(c.red(`
2306
4027
  A ${scope} skill named "${skillName}" already exists.`));
2307
4028
  console.error(c.dim(" " + dest + "\n"));
2308
4029
  process.exitCode = 1;
2309
- return;
4030
+ return false;
2310
4031
  }
2311
- mkdirSync6(skillsRoot(scope), { recursive: true });
4032
+ mkdirSync9(skillsRoot(scope), { recursive: true });
2312
4033
  cpSync(skillDir, dest, { recursive: true });
2313
- const nestedGit = join8(dest, ".git");
2314
- if (existsSync8(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
4034
+ const nestedGit = join12(dest, ".git");
4035
+ if (existsSync12(nestedGit)) rmSync5(nestedGit, { recursive: true, force: true });
2315
4036
  console.log(c.green(`
2316
4037
  Installed ${scope} skill "${skillName}".`));
2317
- console.log(c.dim(" " + join8(dest, "SKILL.md") + "\n"));
4038
+ console.log(c.dim(" " + join12(dest, "SKILL.md") + "\n"));
4039
+ return true;
2318
4040
  } finally {
2319
- if (tempDir && existsSync8(tempDir)) {
4041
+ if (tempDir && existsSync12(tempDir)) {
2320
4042
  try {
2321
- rmSync3(tempDir, { recursive: true, force: true });
4043
+ rmSync5(tempDir, { recursive: true, force: true });
2322
4044
  } catch {
2323
4045
  }
2324
4046
  }
2325
4047
  }
2326
4048
  }
4049
+ async function cmdAdd(source, global, refresh) {
4050
+ if (!source) {
4051
+ console.log(c.dim("\n Usage: holt skill add <git-url|path|name> [--global]\n"));
4052
+ return;
4053
+ }
4054
+ const scope = global ? "global" : "workspace";
4055
+ if (isGitUrl(source) || existsSync12(resolve3(source))) {
4056
+ installFromSource(source, scope);
4057
+ return;
4058
+ }
4059
+ const { registry, error } = await loadRegistry({ refresh });
4060
+ if (!registry) {
4061
+ console.error(c.red(`
4062
+ "${source}" is not a git URL or a local path, and the registry is unavailable.`));
4063
+ console.error(c.dim(" " + (error || "no registry reachable") + "\n"));
4064
+ process.exitCode = 1;
4065
+ return;
4066
+ }
4067
+ const hit = resolveByName(registry.skills, source);
4068
+ if (!hit) {
4069
+ console.error(c.red(`
4070
+ No skill named "${source}" in the registry.`));
4071
+ console.error(c.dim(' Try "holt skill search ' + source + '" to find one.\n'));
4072
+ process.exitCode = 1;
4073
+ return;
4074
+ }
4075
+ console.log(c.dim(`
4076
+ Resolved "${hit.name}" -> ${hit.source}`));
4077
+ installFromSource(hit.source, scope);
4078
+ }
4079
+ async function cmdSearch(query, refresh) {
4080
+ const { registry, error, fromCache } = await loadRegistry({ refresh });
4081
+ if (!registry) {
4082
+ console.error(c.red("\n Registry unavailable."));
4083
+ console.error(c.dim(" " + (error || "no registry reachable") + "\n"));
4084
+ process.exitCode = 1;
4085
+ return;
4086
+ }
4087
+ const hits = searchSkills(registry.skills, query);
4088
+ if (hits.length === 0) {
4089
+ const what = query.trim() ? `matching "${query.trim()}"` : "in the registry";
4090
+ console.log(c.dim(`
4091
+ No skills ${what}.
4092
+ `));
4093
+ return;
4094
+ }
4095
+ const nameW = Math.max(4, ...hits.map((s) => s.name.length));
4096
+ const authorW = Math.max(6, ...hits.map((s) => (s.author || "").length));
4097
+ console.log(
4098
+ "\n" + c.accent("Registry") + c.dim(" " + registryUrl() + (fromCache ? " (cached)" : ""))
4099
+ );
4100
+ console.log(
4101
+ " " + c.dim("name".padEnd(nameW)) + " " + c.dim("author".padEnd(authorW)) + " " + c.dim("description")
4102
+ );
4103
+ for (const s of hits) {
4104
+ const desc = s.description.length > 56 ? s.description.slice(0, 55) + "\u2026" : s.description;
4105
+ console.log(
4106
+ " " + c.bold(s.name.padEnd(nameW)) + " " + c.dim((s.author || "").padEnd(authorW)) + " " + (desc || c.dim("(no description)"))
4107
+ );
4108
+ console.log(" " + " ".repeat(nameW) + " " + " ".repeat(authorW) + " " + c.dim(s.source));
4109
+ }
4110
+ console.log(c.dim('\n Install one with "holt skill add <name>".\n'));
4111
+ }
4112
+ function cmdPublish(name) {
4113
+ let skillName = "";
4114
+ let description = "";
4115
+ let sourceHint = "";
4116
+ if (name) {
4117
+ const loaded = loadSkill(name);
4118
+ if (!loaded) {
4119
+ console.error(c.red(`
4120
+ No skill named "${sanitizeName(name)}". Try "holt skill list".
4121
+ `));
4122
+ process.exitCode = 1;
4123
+ return;
4124
+ }
4125
+ let raw = "";
4126
+ try {
4127
+ raw = readFileSync13(join12(loaded.skill.dir, "SKILL.md"), "utf8");
4128
+ } catch {
4129
+ console.error(c.red("\n Could not read the skill's SKILL.md.\n"));
4130
+ process.exitCode = 1;
4131
+ return;
4132
+ }
4133
+ const { data } = parseFrontmatter(raw);
4134
+ skillName = sanitizeName(data.name || loaded.skill.name);
4135
+ description = (data.description || loaded.skill.description || "").trim();
4136
+ } else {
4137
+ const file = join12(process.cwd(), "SKILL.md");
4138
+ if (!existsSync12(file)) {
4139
+ console.error(c.red("\n No SKILL.md in the current directory, and no skill name given."));
4140
+ console.error(c.dim(" Usage: holt skill publish [<name>]\n"));
4141
+ process.exitCode = 1;
4142
+ return;
4143
+ }
4144
+ const { data } = parseFrontmatter(readFileSync13(file, "utf8"));
4145
+ skillName = sanitizeName(data.name || process.cwd().split(/[\\/]/).pop() || "");
4146
+ description = (data.description || "").trim();
4147
+ sourceHint = process.cwd();
4148
+ }
4149
+ if (!skillName || !description) {
4150
+ console.error(c.red("\n This skill is missing a name or description in its SKILL.md frontmatter."));
4151
+ console.error(c.dim(" Both are required to publish. Add: name: <name> and description: <one line>\n"));
4152
+ process.exitCode = 1;
4153
+ return;
4154
+ }
4155
+ const entry = buildPublishEntry({
4156
+ name: skillName,
4157
+ description,
4158
+ source: sourceHint || `https://github.com/<you>/${skillName}`,
4159
+ author: "<your name or handle>",
4160
+ tags: []
4161
+ });
4162
+ console.log("\n" + c.accent(`Publish "${skillName}" to the Holt skill registry`));
4163
+ console.log(c.dim(" The registry is a git-based JSON index. Publishing = opening a PR that adds your entry.\n"));
4164
+ console.log(c.dim(" 1. Push this skill to a public git repo (or note a subpath spec Holt can clone)."));
4165
+ console.log(c.dim(' 2. Add this entry to the "skills" array in registry.json:'));
4166
+ console.log("");
4167
+ console.log(entry);
4168
+ console.log("");
4169
+ console.log(c.dim(' Replace the "source" (your public git URL or path) and "author" fields.'));
4170
+ console.log(c.dim(" 3. Open a pull request against the registry repo:"));
4171
+ console.log(c.dim(" " + REGISTRY_REPO_URL));
4172
+ console.log(c.dim('\n No push happens here. Once merged, anyone can "holt skill add ' + skillName + '".\n'));
4173
+ }
2327
4174
  async function cmdRemove(name, ask) {
2328
4175
  const clean = sanitizeName(name || "");
2329
4176
  if (!clean) {
2330
4177
  console.log(c.dim("\n Usage: holt skill remove <name>\n"));
2331
4178
  return;
2332
4179
  }
2333
- const wsDir = join8(skillsRoot("workspace"), clean);
2334
- const globalDir = join8(skillsRoot("global"), clean);
2335
- const target = existsSync8(wsDir) ? wsDir : existsSync8(globalDir) ? globalDir : "";
4180
+ const wsDir = join12(skillsRoot("workspace"), clean);
4181
+ const globalDir = join12(skillsRoot("global"), clean);
4182
+ const target = existsSync12(wsDir) ? wsDir : existsSync12(globalDir) ? globalDir : "";
2336
4183
  if (!target) {
2337
4184
  console.error(c.dim(`
2338
4185
  No skill named "${clean}" found.
@@ -2344,7 +4191,7 @@ async function cmdRemove(name, ask) {
2344
4191
  const a = (await ask(`
2345
4192
  Delete ${scope} skill "${clean}"? [y/N] `) ?? "").trim().toLowerCase();
2346
4193
  if (a === "y" || a === "yes") {
2347
- rmSync3(target, { recursive: true, force: true });
4194
+ rmSync5(target, { recursive: true, force: true });
2348
4195
  console.log(c.green(" Removed.\n"));
2349
4196
  } else {
2350
4197
  console.log(c.dim(" Kept.\n"));
@@ -2357,7 +4204,8 @@ async function skillCmd(sub, rest = []) {
2357
4204
  return;
2358
4205
  }
2359
4206
  const global = rest.includes("--global");
2360
- const args = rest.filter((a) => a !== "--global");
4207
+ const refresh = rest.includes("--refresh");
4208
+ const args = rest.filter((a) => a !== "--global" && a !== "--refresh");
2361
4209
  const action = (sub || "").toLowerCase();
2362
4210
  try {
2363
4211
  switch (action) {
@@ -2373,9 +4221,16 @@ async function skillCmd(sub, rest = []) {
2373
4221
  case "new":
2374
4222
  cmdCreate(args[0], global);
2375
4223
  break;
4224
+ case "search":
4225
+ case "find":
4226
+ await cmdSearch(args.join(" "), refresh);
4227
+ break;
2376
4228
  case "add":
2377
4229
  case "install":
2378
- cmdAdd(args[0], global);
4230
+ await cmdAdd(args[0], global, refresh);
4231
+ break;
4232
+ case "publish":
4233
+ cmdPublish(args[0]);
2379
4234
  break;
2380
4235
  case "remove":
2381
4236
  case "rm":
@@ -2392,9 +4247,73 @@ async function skillCmd(sub, rest = []) {
2392
4247
  }
2393
4248
 
2394
4249
  // src/commands/graph.ts
2395
- import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
2396
- import { join as join9, dirname as dirname2, resolve as resolve2 } from "path";
2397
- import { spawn as spawn3 } from "child_process";
4250
+ import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync10, existsSync as existsSync13 } from "fs";
4251
+ import { join as join14, dirname as dirname3, resolve as resolve4 } from "path";
4252
+ import { spawn as spawn4 } from "child_process";
4253
+
4254
+ // src/communities.ts
4255
+ var MAX_ITERS = 30;
4256
+ function detectCommunities(input) {
4257
+ const { nodeIds, edges } = input;
4258
+ const order = nodeIds.slice();
4259
+ const idx = /* @__PURE__ */ new Map();
4260
+ order.forEach((id, i) => idx.set(id, i));
4261
+ const adj = order.map(() => /* @__PURE__ */ new Map());
4262
+ for (const e of edges) {
4263
+ const a = idx.get(e.source);
4264
+ const b = idx.get(e.target);
4265
+ if (a === void 0 || b === void 0 || a === b) continue;
4266
+ const am = adj[a];
4267
+ const bm = adj[b];
4268
+ am.set(b, (am.get(b) || 0) + 1);
4269
+ bm.set(a, (bm.get(a) || 0) + 1);
4270
+ }
4271
+ const label = order.map((_, i) => i);
4272
+ for (let iter = 0; iter < MAX_ITERS; iter++) {
4273
+ let changed = false;
4274
+ for (let i = 0; i < order.length; i++) {
4275
+ const neigh = adj[i];
4276
+ if (neigh.size === 0) continue;
4277
+ const tally = /* @__PURE__ */ new Map();
4278
+ for (const [j, w] of neigh) {
4279
+ const lj = label[j];
4280
+ tally.set(lj, (tally.get(lj) || 0) + w);
4281
+ }
4282
+ let best = label[i];
4283
+ let bestW = -1;
4284
+ for (const [lab, w] of tally) {
4285
+ if (w > bestW || w === bestW && lab < best) {
4286
+ best = lab;
4287
+ bestW = w;
4288
+ }
4289
+ }
4290
+ if (best !== label[i]) {
4291
+ label[i] = best;
4292
+ changed = true;
4293
+ }
4294
+ }
4295
+ if (!changed) break;
4296
+ }
4297
+ const remap = /* @__PURE__ */ new Map();
4298
+ const community = /* @__PURE__ */ new Map();
4299
+ const members = /* @__PURE__ */ new Map();
4300
+ for (let i = 0; i < order.length; i++) {
4301
+ const raw = label[i];
4302
+ let cid = remap.get(raw);
4303
+ if (cid === void 0) {
4304
+ cid = String(remap.size);
4305
+ remap.set(raw, cid);
4306
+ }
4307
+ const id = order[i];
4308
+ community.set(id, cid);
4309
+ const arr = members.get(cid);
4310
+ if (arr) arr.push(id);
4311
+ else members.set(cid, [id]);
4312
+ }
4313
+ const sizes = /* @__PURE__ */ new Map();
4314
+ for (const [cid, arr] of members) sizes.set(cid, arr.length);
4315
+ return { community, members, sizes, count: members.size };
4316
+ }
2398
4317
 
2399
4318
  // src/graphview.ts
2400
4319
  var STOPWORDS = /* @__PURE__ */ new Set([
@@ -2469,7 +4388,7 @@ var STOPWORDS = /* @__PURE__ */ new Set([
2469
4388
  "thanks",
2470
4389
  "lets"
2471
4390
  ]);
2472
- function cosine2(a, b) {
4391
+ function cosine3(a, b) {
2473
4392
  let dot = 0;
2474
4393
  let na = 0;
2475
4394
  let nb = 0;
@@ -2529,7 +4448,7 @@ function buildGraph(turns) {
2529
4448
  const a = withEmb[i];
2530
4449
  for (let j = i + 1; j < withEmb.length; j++) {
2531
4450
  const b = withEmb[j];
2532
- const sim = cosine2(a.emb, b.emb);
4451
+ const sim = cosine3(a.emb, b.emb);
2533
4452
  if (sim >= SEMANTIC_MIN) pairs.push({ source: a.id, target: b.id, sim });
2534
4453
  }
2535
4454
  }
@@ -2557,6 +4476,82 @@ function buildGraph(turns) {
2557
4476
  }
2558
4477
  return { nodes, edges };
2559
4478
  }
4479
+ function buildWikiGraph(pages) {
4480
+ const nodes = [];
4481
+ const edges = [];
4482
+ const byKey = /* @__PURE__ */ new Map();
4483
+ for (const p of pages) {
4484
+ byKey.set(p.slug.toLowerCase(), p);
4485
+ byKey.set(p.title.toLowerCase(), p);
4486
+ }
4487
+ for (const p of pages) {
4488
+ nodes.push({ id: "wiki:" + p.slug, kind: "wiki", label: p.title });
4489
+ }
4490
+ for (const p of pages) {
4491
+ const seen = /* @__PURE__ */ new Set();
4492
+ for (const raw of p.links) {
4493
+ const target = byKey.get(raw.toLowerCase());
4494
+ if (!target || target.slug === p.slug) continue;
4495
+ const key = target.slug;
4496
+ if (seen.has(key)) continue;
4497
+ seen.add(key);
4498
+ edges.push({ source: "wiki:" + p.slug, target: "wiki:" + target.slug, kind: "wikilink", width: 2 });
4499
+ }
4500
+ }
4501
+ return { nodes, edges };
4502
+ }
4503
+ function mergeGraphs(a, b) {
4504
+ const seen = new Set(a.nodes.map((n) => n.id));
4505
+ const nodes = [...a.nodes];
4506
+ for (const n of b.nodes) if (!seen.has(n.id)) {
4507
+ nodes.push(n);
4508
+ seen.add(n.id);
4509
+ }
4510
+ return { nodes, edges: [...a.edges, ...b.edges] };
4511
+ }
4512
+ function buildCodeGraph(files, edges) {
4513
+ const nodes = [];
4514
+ const ids = new Set(files.map((f) => f.id));
4515
+ for (const f of files) {
4516
+ const snippet = f.content.replace(/\s+/g, " ").trim().slice(0, 180);
4517
+ nodes.push({
4518
+ id: f.id,
4519
+ kind: f.kind === "code" ? "file" : "doc",
4520
+ label: shortPath(f.id),
4521
+ path: f.id,
4522
+ ext: f.ext,
4523
+ topDir: f.topDir,
4524
+ snippet
4525
+ });
4526
+ }
4527
+ const out = [];
4528
+ for (const e of edges) {
4529
+ if (!ids.has(e.source) || !ids.has(e.target)) continue;
4530
+ out.push({ source: e.source, target: e.target, kind: e.kind, width: e.kind === "dep" ? 1.5 : 1.5 });
4531
+ }
4532
+ return { nodes, edges: out };
4533
+ }
4534
+ function shortPath(p) {
4535
+ const parts = p.split("/");
4536
+ return parts.length <= 2 ? p : parts.slice(-2).join("/");
4537
+ }
4538
+ function assignCommunities(graph2) {
4539
+ const nodeIds = graph2.nodes.map((n) => n.id);
4540
+ const idSet = new Set(nodeIds);
4541
+ const edges = graph2.edges.filter((e) => idSet.has(e.source) && idSet.has(e.target)).map((e) => ({ source: e.source, target: e.target }));
4542
+ const { community } = detectCommunities({ nodeIds, edges });
4543
+ const degree = /* @__PURE__ */ new Map();
4544
+ for (const id of nodeIds) degree.set(id, 0);
4545
+ for (const e of edges) {
4546
+ degree.set(e.source, (degree.get(e.source) || 0) + 1);
4547
+ degree.set(e.target, (degree.get(e.target) || 0) + 1);
4548
+ }
4549
+ for (const n of graph2.nodes) {
4550
+ n.community = community.get(n.id);
4551
+ n.degree = degree.get(n.id) || 0;
4552
+ }
4553
+ return graph2;
4554
+ }
2560
4555
  function escapeForScript(json) {
2561
4556
  return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\u0021--");
2562
4557
  }
@@ -2662,7 +4657,10 @@ function renderGraphHtml(graph2, meta) {
2662
4657
  <div class="row"><span class="swatch" style="background:var(--amber)"></span>you</div>
2663
4658
  <div class="row"><span class="swatch" style="background:var(--cyan)"></span>assistant</div>
2664
4659
  <div class="row"><span class="swatch" style="background:var(--violet)"></span>concept</div>
2665
- <div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session</div>
4660
+ <div class="row"><span class="swatch" style="background:#5bd66f"></span>wiki page</div>
4661
+ <div class="row"><span class="swatch" style="background:#e2704a"></span>code file</div>
4662
+ <div class="row"><span class="swatch" style="background:#4a90d9"></span>doc file</div>
4663
+ <div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session / community</div>
2666
4664
  </div>
2667
4665
  <div id="hint">drag to pan &middot; wheel to zoom &middot; click a node &middot; Esc to clear</div>
2668
4666
  <div id="empty">This graph has no nodes yet.</div>
@@ -2712,10 +4710,17 @@ function renderGraphHtml(graph2, meta) {
2712
4710
  });
2713
4711
 
2714
4712
  function radiusOf(n) {
2715
- var base = n.kind === "concept" ? 3 : 5;
4713
+ var base = n.kind === "concept" ? 3 : n.kind === "wiki" ? 6 : (n.kind === "file" || n.kind === "doc") ? 5 : 5;
2716
4714
  return base + Math.min(9, Math.sqrt(n._deg) * 1.6);
2717
4715
  }
2718
4716
 
4717
+ // ---- community tint: a stable hue per community id ----
4718
+ function communityHue(cid) {
4719
+ var k = parseInt(cid, 10);
4720
+ if (isNaN(k)) k = 0;
4721
+ return (k * 137.508) % 360; // golden-angle spread, same trick as sessions
4722
+ }
4723
+
2719
4724
  // ---- initial layout: spread on a spiral so physics has somewhere to start ----
2720
4725
  nodes.forEach(function (n, i) {
2721
4726
  var ang = i * 2.399963;
@@ -2727,7 +4732,7 @@ function renderGraphHtml(graph2, meta) {
2727
4732
 
2728
4733
  // ---- physics ----
2729
4734
  var REPULSION = 5200, SPRING = 0.02, GRAVITY = 0.015, DAMP = 0.86;
2730
- var restLen = { sequential: 60, semantic: 45, concept: 70 };
4735
+ var restLen = { sequential: 60, semantic: 45, concept: 70, dep: 55, link: 55, wikilink: 55 };
2731
4736
 
2732
4737
  function step() {
2733
4738
  var i, j, n, m, dx, dy, d2, d, f;
@@ -2791,13 +4796,16 @@ function renderGraphHtml(graph2, meta) {
2791
4796
  var query = "";
2792
4797
 
2793
4798
  function roleColor(n) {
4799
+ if (n.kind === "wiki") return "#5bd66f";
2794
4800
  if (n.kind === "concept") return "#9a8cff";
4801
+ if (n.kind === "file") return "#e2704a";
4802
+ if (n.kind === "doc") return "#4a90d9";
2795
4803
  return n.role === "user" ? "#f0b91e" : "#35d0d6";
2796
4804
  }
2797
4805
 
2798
4806
  function matchesQuery(n) {
2799
4807
  if (!query) return false;
2800
- var hay = (n.label || "") + " " + (n.content || "");
4808
+ var hay = (n.label || "") + " " + (n.content || "") + " " + (n.path || "") + " " + (n.snippet || "");
2801
4809
  return hay.toLowerCase().indexOf(query) >= 0;
2802
4810
  }
2803
4811
 
@@ -2817,7 +4825,7 @@ function renderGraphHtml(graph2, meta) {
2817
4825
  }
2818
4826
  ctx.globalAlpha = dim;
2819
4827
  ctx.lineWidth = Math.max(0.4, lk.width * view.scale * 0.5);
2820
- ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : "#4a5262";
4828
+ ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : lk.kind === "wikilink" ? "#5bd66f" : lk.kind === "dep" ? "#e2704a" : lk.kind === "link" ? "#4a90d9" : "#4a5262";
2821
4829
  ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
2822
4830
  }
2823
4831
  ctx.globalAlpha = 1;
@@ -2831,12 +4839,17 @@ function renderGraphHtml(graph2, meta) {
2831
4839
  var faded = (highlightSet && !highlightSet.has(n.id)) || (query && !matchesQuery(n));
2832
4840
  ctx.globalAlpha = faded ? 0.18 : 1;
2833
4841
 
2834
- // session ring
4842
+ // session ring (turns) or community ring (files/docs)
2835
4843
  if (n.kind === "turn") {
2836
4844
  ctx.beginPath();
2837
4845
  ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
2838
4846
  ctx.strokeStyle = "hsl(" + sessionHue(n.session) + ",70%,58%)";
2839
4847
  ctx.lineWidth = 1.6; ctx.stroke();
4848
+ } else if ((n.kind === "file" || n.kind === "doc") && n.community != null) {
4849
+ ctx.beginPath();
4850
+ ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
4851
+ ctx.strokeStyle = "hsl(" + communityHue(n.community) + ",70%,58%)";
4852
+ ctx.lineWidth = 2; ctx.stroke();
2840
4853
  }
2841
4854
 
2842
4855
  ctx.beginPath();
@@ -2848,11 +4861,14 @@ function renderGraphHtml(graph2, meta) {
2848
4861
  ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.stroke();
2849
4862
  }
2850
4863
 
2851
- // concept labels always; turn labels when zoomed in
2852
- if (n.kind === "concept" || view.scale > 1.4) {
4864
+ // concept + wiki labels always; file/doc when zoomed a little; turn labels when zoomed in
4865
+ var showLabel = n.kind === "concept" || n.kind === "wiki"
4866
+ || ((n.kind === "file" || n.kind === "doc") && view.scale > 0.9)
4867
+ || view.scale > 1.4;
4868
+ if (showLabel) {
2853
4869
  ctx.globalAlpha = faded ? 0.25 : 0.9;
2854
- ctx.fillStyle = "#e7e9ee";
2855
- ctx.font = (n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
4870
+ ctx.fillStyle = n.kind === "wiki" ? "#5bd66f" : n.kind === "file" ? "#e2704a" : n.kind === "doc" ? "#4a90d9" : "#e7e9ee";
4871
+ ctx.font = (n.kind === "wiki" ? 12 : n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
2856
4872
  ctx.fillText(n.label, p.x + r + 4, p.y + 3);
2857
4873
  }
2858
4874
  }
@@ -2916,6 +4932,10 @@ function renderGraphHtml(graph2, meta) {
2916
4932
  var when = hov.ts ? new Date(hov.ts).toISOString().slice(0, 10) : "";
2917
4933
  var meta = hov.kind === "concept"
2918
4934
  ? ("concept &middot; in " + (hov.freq || 0) + " turns")
4935
+ : hov.kind === "wiki"
4936
+ ? "wiki page"
4937
+ : (hov.kind === "file" || hov.kind === "doc")
4938
+ ? (esc(hov.path || "") + " &middot; " + hov._deg + " links" + (hov.community != null ? " &middot; community " + esc(String(hov.community)) : ""))
2919
4939
  : (esc(hov.session || "") + (when ? " &middot; " + when : ""));
2920
4940
  tooltip.innerHTML = "<div>" + esc(hov.label) + "</div><div class='t-meta'>" + meta + "</div>";
2921
4941
  tooltip.style.display = "block";
@@ -2959,9 +4979,12 @@ function renderGraphHtml(graph2, meta) {
2959
4979
  }
2960
4980
  function onNodeClick(n) {
2961
4981
  selected = n.id;
2962
- if (n.kind === "concept") {
2963
- highlightSet = neighbors(n.id); // light up its connected turns
4982
+ if (n.kind === "concept" || n.kind === "wiki") {
4983
+ highlightSet = neighbors(n.id); // light up its connected nodes
2964
4984
  closePanel();
4985
+ } else if (n.kind === "file" || n.kind === "doc") {
4986
+ highlightSet = neighbors(n.id); // light up dependencies/links
4987
+ openPanel(n);
2965
4988
  } else {
2966
4989
  highlightSet = null;
2967
4990
  openPanel(n);
@@ -2975,6 +4998,18 @@ function renderGraphHtml(graph2, meta) {
2975
4998
 
2976
4999
  // ---- side panel ----
2977
5000
  function openPanel(n) {
5001
+ if (n.kind === "file" || n.kind === "doc") {
5002
+ document.getElementById("p-session").textContent = (n.kind === "file" ? "code" : "doc") + (n.ext ? " (." + n.ext + ")" : "");
5003
+ document.getElementById("p-dot").style.background = roleColor(n);
5004
+ document.getElementById("p-role").textContent = n.path || n.label || "";
5005
+ document.getElementById("p-role").style.color = roleColor(n);
5006
+ // textContent keeps any <script>/<img onerror> in the path or snippet inert.
5007
+ document.getElementById("p-content").textContent = n.snippet || "";
5008
+ document.getElementById("p-time").textContent =
5009
+ (n.community != null ? "community " + n.community + " | " : "") + (n._deg || 0) + " links";
5010
+ panel.classList.add("open");
5011
+ return;
5012
+ }
2978
5013
  document.getElementById("p-session").textContent = n.session || "";
2979
5014
  document.getElementById("p-dot").style.background = "hsl(" + sessionHue(n.session) + ",70%,58%)";
2980
5015
  document.getElementById("p-role").textContent = n.role || "";
@@ -3009,17 +5044,384 @@ function renderGraphHtml(graph2, meta) {
3009
5044
  `;
3010
5045
  }
3011
5046
 
5047
+ // src/ingest.ts
5048
+ import { readdirSync as readdirSync4, readFileSync as readFileSync14, statSync as statSync6 } from "fs";
5049
+ import { join as join13, relative, sep, extname, dirname as dirname2, basename as basename2 } from "path";
5050
+ var CODE_EXT = /* @__PURE__ */ new Set([
5051
+ "js",
5052
+ "jsx",
5053
+ "ts",
5054
+ "tsx",
5055
+ "mjs",
5056
+ "cjs",
5057
+ "py",
5058
+ "go",
5059
+ "rb",
5060
+ "rs",
5061
+ "java",
5062
+ "kt",
5063
+ "kts",
5064
+ "swift",
5065
+ "c",
5066
+ "h",
5067
+ "cc",
5068
+ "cpp",
5069
+ "hpp",
5070
+ "cs",
5071
+ "php",
5072
+ "scala",
5073
+ "sh",
5074
+ "bash"
5075
+ ]);
5076
+ var DOC_EXT = /* @__PURE__ */ new Set(["md", "mdx", "txt", "rst"]);
5077
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
5078
+ "node_modules",
5079
+ ".git",
5080
+ ".holt",
5081
+ "dist",
5082
+ "build",
5083
+ ".next",
5084
+ "coverage",
5085
+ "out",
5086
+ "target",
5087
+ "vendor",
5088
+ "__pycache__",
5089
+ ".venv",
5090
+ "venv",
5091
+ ".cache",
5092
+ "tmp",
5093
+ ".idea",
5094
+ ".vscode"
5095
+ ]);
5096
+ var MAX_FILES = 2e3;
5097
+ var MAX_FILE_BYTES = 400 * 1024;
5098
+ var MAX_TOTAL_BYTES = 64 * 1024 * 1024;
5099
+ function toPosix(p) {
5100
+ return sep === "/" ? p : p.split(sep).join("/");
5101
+ }
5102
+ function ingestFolder(root, want = { code: true, docs: true }) {
5103
+ const files = [];
5104
+ const skipped = { dirs: 0, tooBig: 0, unreadable: 0, hitFileCap: false, hitByteCap: false };
5105
+ let totalBytes = 0;
5106
+ const stack = [root];
5107
+ while (stack.length) {
5108
+ const dir = stack.pop();
5109
+ let entries;
5110
+ try {
5111
+ entries = readdirSync4(dir).sort((a, b) => a.localeCompare(b));
5112
+ } catch {
5113
+ skipped.unreadable++;
5114
+ continue;
5115
+ }
5116
+ const subdirs = [];
5117
+ for (const name of entries) {
5118
+ if (files.length >= MAX_FILES) {
5119
+ skipped.hitFileCap = true;
5120
+ break;
5121
+ }
5122
+ const full = join13(dir, name);
5123
+ let st;
5124
+ try {
5125
+ st = statSync6(full);
5126
+ } catch {
5127
+ skipped.unreadable++;
5128
+ continue;
5129
+ }
5130
+ if (st.isDirectory()) {
5131
+ if (name.startsWith(".") || SKIP_DIRS.has(name)) {
5132
+ skipped.dirs++;
5133
+ continue;
5134
+ }
5135
+ subdirs.push(full);
5136
+ continue;
5137
+ }
5138
+ if (!st.isFile()) continue;
5139
+ const ext = extname(name).slice(1).toLowerCase();
5140
+ const isCode = CODE_EXT.has(ext);
5141
+ const isDoc = DOC_EXT.has(ext);
5142
+ if (!isCode && !isDoc) continue;
5143
+ if (isCode && !want.code) continue;
5144
+ if (isDoc && !want.docs) continue;
5145
+ if (st.size > MAX_FILE_BYTES) {
5146
+ skipped.tooBig++;
5147
+ continue;
5148
+ }
5149
+ if (totalBytes + st.size > MAX_TOTAL_BYTES) {
5150
+ skipped.hitByteCap = true;
5151
+ continue;
5152
+ }
5153
+ let content;
5154
+ try {
5155
+ content = readFileSync14(full, "utf8");
5156
+ } catch {
5157
+ skipped.unreadable++;
5158
+ continue;
5159
+ }
5160
+ totalBytes += st.size;
5161
+ const rel = toPosix(relative(root, full)) || name;
5162
+ const firstSlash = rel.indexOf("/");
5163
+ const topDir = firstSlash < 0 ? "." : rel.slice(0, firstSlash);
5164
+ files.push({
5165
+ id: rel,
5166
+ path: rel,
5167
+ kind: isCode ? "code" : "doc",
5168
+ ext,
5169
+ topDir,
5170
+ content
5171
+ });
5172
+ }
5173
+ if (files.length >= MAX_FILES) {
5174
+ skipped.hitFileCap = true;
5175
+ }
5176
+ for (let i = subdirs.length - 1; i >= 0; i--) stack.push(subdirs[i]);
5177
+ }
5178
+ const byId = new Set(files.map((f) => f.id));
5179
+ const edges = extractEdges(files, byId);
5180
+ return { files, edges, skipped };
5181
+ }
5182
+ function extractEdges(files, byId) {
5183
+ const edges = [];
5184
+ const seen = /* @__PURE__ */ new Set();
5185
+ const push = (source, target, kind) => {
5186
+ if (source === target) return;
5187
+ const key = source + "\0" + target + "\0" + kind;
5188
+ if (seen.has(key)) return;
5189
+ seen.add(key);
5190
+ edges.push({ source, target, kind });
5191
+ };
5192
+ for (const f of files) {
5193
+ if (f.kind === "code") {
5194
+ for (const spec of codeSpecifiers(f)) {
5195
+ const resolved = resolveCodeSpec(f, spec, byId);
5196
+ if (resolved) push(f.id, resolved, "dep");
5197
+ }
5198
+ } else {
5199
+ for (const spec of docLinks(f.content)) {
5200
+ const resolved = resolveDocLink(f, spec, byId, files);
5201
+ if (resolved) push(f.id, resolved, "link");
5202
+ }
5203
+ }
5204
+ }
5205
+ return edges;
5206
+ }
5207
+ var JS_FROM_RE = /(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
5208
+ var JS_BARE_IMPORT_RE = /\bimport\s*['"]([^'"]+)['"]/g;
5209
+ var JS_REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
5210
+ var JS_DYN_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
5211
+ var PY_IMPORT_RE = /^[ \t]*import\s+([A-Za-z0-9_.]+)/gm;
5212
+ var PY_FROM_RE = /^[ \t]*from\s+(\.*[A-Za-z0-9_.]*)\s+import\b/gm;
5213
+ function matchAll(re, text) {
5214
+ const out = [];
5215
+ re.lastIndex = 0;
5216
+ let m;
5217
+ while ((m = re.exec(text)) !== null) {
5218
+ if (m[1]) out.push(m[1]);
5219
+ }
5220
+ return out;
5221
+ }
5222
+ function codeSpecifiers(f) {
5223
+ if (f.ext === "py") {
5224
+ return [...matchAll(PY_IMPORT_RE, f.content), ...matchAll(PY_FROM_RE, f.content)];
5225
+ }
5226
+ if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(f.ext)) {
5227
+ return [
5228
+ ...matchAll(JS_FROM_RE, f.content),
5229
+ ...matchAll(JS_BARE_IMPORT_RE, f.content),
5230
+ ...matchAll(JS_REQUIRE_RE, f.content),
5231
+ ...matchAll(JS_DYN_IMPORT_RE, f.content)
5232
+ ];
5233
+ }
5234
+ return [];
5235
+ }
5236
+ var JS_TRY_EXT = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts"];
5237
+ var JS_INDEX = ["/index.ts", "/index.tsx", "/index.js", "/index.jsx", "/index.mjs"];
5238
+ function joinPosix(fromDir, spec) {
5239
+ const parts = (fromDir === "." ? [] : fromDir.split("/")).concat(spec.split("/"));
5240
+ const out = [];
5241
+ for (const part of parts) {
5242
+ if (part === "" || part === ".") continue;
5243
+ if (part === "..") {
5244
+ out.pop();
5245
+ continue;
5246
+ }
5247
+ out.push(part);
5248
+ }
5249
+ return out.join("/");
5250
+ }
5251
+ function resolveCodeSpec(f, spec, byId) {
5252
+ if (f.ext === "py") return resolvePySpec(f, spec, byId);
5253
+ if (!(spec.startsWith("./") || spec.startsWith("../"))) return null;
5254
+ const fromDir = toPosixDir(f.id);
5255
+ const base = joinPosix(fromDir, spec);
5256
+ for (const ext of JS_TRY_EXT) {
5257
+ const cand = base + ext;
5258
+ if (cand && byId.has(cand)) return cand;
5259
+ }
5260
+ for (const idx of JS_INDEX) {
5261
+ const cand = base + idx;
5262
+ if (byId.has(cand)) return cand;
5263
+ }
5264
+ return null;
5265
+ }
5266
+ function resolvePySpec(f, spec, byId) {
5267
+ let mod = spec;
5268
+ let up = 0;
5269
+ while (mod.startsWith(".")) {
5270
+ up++;
5271
+ mod = mod.slice(1);
5272
+ }
5273
+ const segs = mod ? mod.split(".") : [];
5274
+ let baseDir = toPosixDir(f.id);
5275
+ if (up > 0) {
5276
+ for (let i = 1; i < up; i++) baseDir = toPosixDir(baseDir + "/x");
5277
+ }
5278
+ const relBase = up > 0 ? joinPosix(baseDir, segs.join("/")) : segs.join("/");
5279
+ const candidates = up > 0 ? [relBase + ".py", relBase + "/__init__.py"] : [
5280
+ relBase + ".py",
5281
+ relBase + "/__init__.py",
5282
+ // absolute import may still be relative to the file's own dir
5283
+ joinPosix(toPosixDir(f.id), segs.join("/")) + ".py"
5284
+ ];
5285
+ for (const cand of candidates) if (cand && byId.has(cand)) return cand;
5286
+ return null;
5287
+ }
5288
+ function toPosixDir(id) {
5289
+ const d = toPosix(dirname2(id));
5290
+ return d === "" || d === "." ? "." : d;
5291
+ }
5292
+ var MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
5293
+ var WIKILINK_RE2 = /\[\[([^\]]+)\]\]/g;
5294
+ function docLinks(content) {
5295
+ const out = [];
5296
+ for (const raw of matchAll(MD_LINK_RE, content)) out.push(raw);
5297
+ WIKILINK_RE2.lastIndex = 0;
5298
+ let m;
5299
+ while ((m = WIKILINK_RE2.exec(content)) !== null) {
5300
+ const target = (m[1] || "").split("|")[0];
5301
+ if (target && target.trim()) out.push(target.trim());
5302
+ }
5303
+ return out;
5304
+ }
5305
+ function resolveDocLink(f, spec, byId, files) {
5306
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec) || spec.startsWith("#") || spec.startsWith("mailto:")) return null;
5307
+ const clean = spec.split("#")[0].split("?")[0].trim();
5308
+ if (!clean) return null;
5309
+ if (clean.includes("/") || clean.includes(".")) {
5310
+ const fromDir = toPosixDir(f.id);
5311
+ const base = joinPosix(fromDir, clean);
5312
+ for (const ext of ["", ".md", ".mdx", ".txt", ".rst"]) {
5313
+ const cand = base + ext;
5314
+ if (cand && byId.has(cand)) return cand;
5315
+ }
5316
+ }
5317
+ const wantName = clean.toLowerCase();
5318
+ for (const cand of files) {
5319
+ if (cand.kind !== "doc") continue;
5320
+ const stem = basename2(cand.id).replace(/\.[^.]+$/, "").toLowerCase();
5321
+ if (stem === wantName) return cand.id;
5322
+ }
5323
+ return null;
5324
+ }
5325
+
5326
+ // src/graphreport.ts
5327
+ var TOP_GOD_NODES = 12;
5328
+ var REP_PER_COMMUNITY = 6;
5329
+ function degrees(graph2) {
5330
+ const idSet = new Set(graph2.nodes.map((n) => n.id));
5331
+ const deg = /* @__PURE__ */ new Map();
5332
+ for (const n of graph2.nodes) deg.set(n.id, 0);
5333
+ for (const e of graph2.edges) {
5334
+ if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
5335
+ deg.set(e.source, (deg.get(e.source) || 0) + 1);
5336
+ deg.set(e.target, (deg.get(e.target) || 0) + 1);
5337
+ }
5338
+ return deg;
5339
+ }
5340
+ function reportLabel(n) {
5341
+ return n.path || n.label || n.id;
5342
+ }
5343
+ function renderReport(graph2, meta) {
5344
+ const date = new Date(meta.generatedAt ?? Date.now()).toISOString().slice(0, 10);
5345
+ const lines = [];
5346
+ lines.push(`# Graph Report - ${meta.workspace} (${date})`);
5347
+ lines.push("");
5348
+ if (graph2.nodes.length === 0) {
5349
+ lines.push("## Summary");
5350
+ lines.push("- 0 nodes, 0 edges, 0 communities.");
5351
+ lines.push("- Nothing to ingest here yet. Run `holt graph --code` or `holt graph --docs`");
5352
+ lines.push(" in a folder with source or docs, then `holt graph report`.");
5353
+ lines.push("");
5354
+ return lines.join("\n") + "\n";
5355
+ }
5356
+ const deg = degrees(graph2);
5357
+ const idSet = new Set(graph2.nodes.map((n) => n.id));
5358
+ const validEdges = graph2.edges.filter((e) => idSet.has(e.source) && idSet.has(e.target));
5359
+ const commMembers = /* @__PURE__ */ new Map();
5360
+ for (const n of graph2.nodes) {
5361
+ const cid = n.community ?? "0";
5362
+ const arr = commMembers.get(cid);
5363
+ if (arr) arr.push(n);
5364
+ else commMembers.set(cid, [n]);
5365
+ }
5366
+ const communityCount = commMembers.size;
5367
+ const fileCount = graph2.nodes.filter((n) => n.kind === "file").length;
5368
+ const docCount = graph2.nodes.filter((n) => n.kind === "doc").length;
5369
+ lines.push("## Summary");
5370
+ lines.push(`- ${graph2.nodes.length} nodes, ${validEdges.length} edges, ${communityCount} communities detected`);
5371
+ lines.push(`- Ingested: ${fileCount} code files, ${docCount} docs`);
5372
+ lines.push("- Community algorithm: label propagation (deterministic, zero dependency)");
5373
+ lines.push("");
5374
+ lines.push("## God Nodes (most connected - your core files)");
5375
+ const ranked = graph2.nodes.slice().sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || reportLabel(a).localeCompare(reportLabel(b))).filter((n) => (deg.get(n.id) || 0) > 0).slice(0, TOP_GOD_NODES);
5376
+ if (ranked.length === 0) {
5377
+ lines.push("- None: no nodes have any edges yet (nothing links to anything).");
5378
+ } else {
5379
+ ranked.forEach((n, i) => {
5380
+ const d = deg.get(n.id) || 0;
5381
+ const cid = n.community ?? "0";
5382
+ lines.push(`${i + 1}. \`${reportLabel(n)}\` - ${d} edges (community ${cid})`);
5383
+ });
5384
+ }
5385
+ lines.push("");
5386
+ lines.push("## Communities");
5387
+ const sortedComms = [...commMembers.entries()].sort(
5388
+ (a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])
5389
+ );
5390
+ for (const [cid, members] of sortedComms) {
5391
+ lines.push("");
5392
+ lines.push(`### Community ${cid} (${members.length} nodes)`);
5393
+ const reps = members.slice().sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || reportLabel(a).localeCompare(reportLabel(b))).slice(0, REP_PER_COMMUNITY).map((n) => reportLabel(n));
5394
+ const extra = members.length - reps.length;
5395
+ const shown = reps.join(", ");
5396
+ lines.push(`Representative: ${shown}${extra > 0 ? ` (+${extra} more)` : ""}`);
5397
+ }
5398
+ lines.push("");
5399
+ return lines.join("\n") + "\n";
5400
+ }
5401
+
3012
5402
  // src/commands/graph.ts
3013
5403
  function parseArgs(args) {
3014
- const opts = { open: true };
5404
+ const opts = { open: true, code: false, docs: false, report: false };
3015
5405
  for (let i = 0; i < args.length; i++) {
3016
5406
  const a = args[i];
3017
- if (a === "--no-open") opts.open = false;
5407
+ if (a === "report") opts.report = true;
5408
+ else if (a === "--no-open") opts.open = false;
5409
+ else if (a === "--wiki") opts.wiki = true;
5410
+ else if (a === "--no-wiki") opts.wiki = false;
5411
+ else if (a === "--code") opts.code = true;
5412
+ else if (a === "--docs") opts.docs = true;
5413
+ else if (a === "--all") {
5414
+ opts.code = true;
5415
+ opts.docs = true;
5416
+ } else if (a === "--report") opts.report = true;
3018
5417
  else if (a === "--out") opts.out = args[++i];
3019
5418
  else if (a && a.startsWith("--out=")) opts.out = a.slice("--out=".length);
3020
5419
  }
3021
5420
  return opts;
3022
5421
  }
5422
+ function wikiGraphPages() {
5423
+ return loadPages().map((p) => ({ slug: p.slug, title: p.title, links: extractLinks(p.body) }));
5424
+ }
3023
5425
  function openInBrowser(path) {
3024
5426
  try {
3025
5427
  let cmd;
@@ -3034,7 +5436,7 @@ function openInBrowser(path) {
3034
5436
  cmd = "xdg-open";
3035
5437
  cmdArgs = [path];
3036
5438
  }
3037
- const child = spawn3(cmd, cmdArgs, { detached: true, stdio: "ignore" });
5439
+ const child = spawn4(cmd, cmdArgs, { detached: true, stdio: "ignore" });
3038
5440
  child.on("error", () => {
3039
5441
  });
3040
5442
  child.unref();
@@ -3049,13 +5451,57 @@ async function graph(args = []) {
3049
5451
  }
3050
5452
  close();
3051
5453
  const opts = parseArgs(args);
5454
+ if (opts.report && !opts.code && !opts.docs) {
5455
+ opts.code = true;
5456
+ opts.docs = true;
5457
+ }
5458
+ const ingestCode = opts.code;
5459
+ const ingestDocs = opts.docs;
3052
5460
  const turns = loadTurns();
3053
- if (turns.length === 0) {
5461
+ const includeWiki = opts.wiki !== void 0 ? opts.wiki : existsSync13(wikiDir());
5462
+ const wikiPages = includeWiki ? wikiGraphPages() : [];
5463
+ let fileNodeCount = 0;
5464
+ let docNodeCount = 0;
5465
+ let codeGraph = null;
5466
+ if (ingestCode || ingestDocs) {
5467
+ const res = ingestFolder(workspace(), { code: ingestCode, docs: ingestDocs });
5468
+ codeGraph = buildCodeGraph(res.files, res.edges);
5469
+ fileNodeCount = codeGraph.nodes.filter((n) => n.kind === "file").length;
5470
+ docNodeCount = codeGraph.nodes.filter((n) => n.kind === "doc").length;
5471
+ const skipNotes = [];
5472
+ if (res.skipped.dirs) skipNotes.push(`${res.skipped.dirs} dirs`);
5473
+ if (res.skipped.tooBig) skipNotes.push(`${res.skipped.tooBig} too-big`);
5474
+ if (res.skipped.unreadable) skipNotes.push(`${res.skipped.unreadable} unreadable`);
5475
+ if (res.skipped.hitFileCap) skipNotes.push("file cap hit");
5476
+ if (res.skipped.hitByteCap) skipNotes.push("byte cap hit");
5477
+ if (skipNotes.length) console.log(c.dim(` ingest skipped: ${skipNotes.join(", ")}`));
5478
+ }
5479
+ if (turns.length === 0 && wikiPages.length === 0 && !codeGraph) {
3054
5480
  console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
5481
+ console.log(c.dim(` Or ingest this folder's code/docs: "holt graph --code" / "holt graph --docs".
5482
+ `));
5483
+ return;
5484
+ }
5485
+ let g = buildGraph(turns);
5486
+ if (wikiPages.length) g = mergeGraphs(g, buildWikiGraph(wikiPages));
5487
+ if (codeGraph) g = mergeGraphs(g, codeGraph);
5488
+ if (codeGraph) assignCommunities(g);
5489
+ if (opts.report) {
5490
+ const md = renderReport(g, { workspace: workspace() });
5491
+ const reportPath = opts.out ? resolve4(opts.out) : join14(workspace(), "GRAPH_REPORT.md");
5492
+ mkdirSync10(dirname3(reportPath), { recursive: true });
5493
+ writeFileSync11(reportPath, md, "utf8");
5494
+ const communities = new Set(g.nodes.map((n) => n.community ?? "0")).size;
5495
+ console.log("\n" + c.accent("Graph report written") + c.dim(" (this folder)"));
5496
+ console.log(` nodes ${g.nodes.length} (${fileNodeCount} files, ${docNodeCount} docs)`);
5497
+ console.log(` edges ${g.edges.length}`);
5498
+ console.log(` communities ${g.nodes.length ? communities : 0}`);
5499
+ console.log(` file ${reportPath}
5500
+ `);
3055
5501
  return;
3056
5502
  }
3057
- const g = buildGraph(turns);
3058
5503
  const conceptCount = g.nodes.filter((n) => n.kind === "concept").length;
5504
+ const wikiCount = g.nodes.filter((n) => n.kind === "wiki").length;
3059
5505
  const sessions = new Set(turns.map((t) => t.session)).size;
3060
5506
  const html = renderGraphHtml(g, {
3061
5507
  workspace: workspace(),
@@ -3064,11 +5510,15 @@ async function graph(args = []) {
3064
5510
  concepts: conceptCount,
3065
5511
  edges: g.edges.length
3066
5512
  });
3067
- const outPath = opts.out ? resolve2(opts.out) : join9(wsHoltDir(), "graph.html");
3068
- mkdirSync7(dirname2(outPath), { recursive: true });
3069
- writeFileSync8(outPath, html, "utf8");
5513
+ const outPath = opts.out ? resolve4(opts.out) : join14(wsHoltDir(), "graph.html");
5514
+ mkdirSync10(dirname3(outPath), { recursive: true });
5515
+ writeFileSync11(outPath, html, "utf8");
3070
5516
  console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
3071
- console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
5517
+ const parts = [`${turns.length} turns`, `${conceptCount} concepts`];
5518
+ if (wikiCount) parts.push(`${wikiCount} wiki pages`);
5519
+ if (fileNodeCount) parts.push(`${fileNodeCount} code files`);
5520
+ if (docNodeCount) parts.push(`${docNodeCount} docs`);
5521
+ console.log(` nodes ${g.nodes.length} (${parts.join(", ")})`);
3072
5522
  console.log(` edges ${g.edges.length}`);
3073
5523
  console.log(` file ${outPath}`);
3074
5524
  if (opts.open) {
@@ -3174,7 +5624,7 @@ function recoverId(line) {
3174
5624
  return null;
3175
5625
  }
3176
5626
  function runMcpServer(opts) {
3177
- return new Promise((resolve4) => {
5627
+ return new Promise((resolve8) => {
3178
5628
  const stdin = process.stdin;
3179
5629
  let buffer = "";
3180
5630
  let chain = Promise.resolve();
@@ -3226,11 +5676,11 @@ function runMcpServer(opts) {
3226
5676
  processLine(buffer);
3227
5677
  buffer = "";
3228
5678
  }
3229
- chain.then(() => resolve4()).catch(() => resolve4());
5679
+ chain.then(() => resolve8()).catch(() => resolve8());
3230
5680
  });
3231
5681
  stdin.on("error", (e) => {
3232
5682
  console.error(`[mcp] stdin error: ${e.message}`);
3233
- chain.then(() => resolve4()).catch(() => resolve4());
5683
+ chain.then(() => resolve8()).catch(() => resolve8());
3234
5684
  });
3235
5685
  stdin.resume();
3236
5686
  });
@@ -3350,7 +5800,7 @@ function holtTools() {
3350
5800
  }
3351
5801
 
3352
5802
  // src/version.ts
3353
- var VERSION = "0.8.4";
5803
+ var VERSION = "0.10.0";
3354
5804
 
3355
5805
  // src/commands/mcp.ts
3356
5806
  function printSetup() {
@@ -3395,10 +5845,379 @@ async function mcp(sub, rest = []) {
3395
5845
  }
3396
5846
  }
3397
5847
 
5848
+ // src/commands/hook.ts
5849
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync12, existsSync as existsSync14, mkdirSync as mkdirSync11, copyFileSync, realpathSync as realpathSync2 } from "fs";
5850
+ import { join as join15, dirname as dirname4 } from "path";
5851
+ import { homedir as homedir3 } from "os";
5852
+ var EVENT = {
5853
+ inject: "UserPromptSubmit",
5854
+ capture: "Stop"
5855
+ };
5856
+ var HOOK_CMD = {
5857
+ inject: "holt hook inject",
5858
+ capture: "holt hook capture"
5859
+ };
5860
+ function globalSettingsPath() {
5861
+ return join15(homedir3(), ".claude", "settings.json");
5862
+ }
5863
+ function projectSettingsPath() {
5864
+ return join15(process.cwd(), ".claude", "settings.json");
5865
+ }
5866
+ function readSettings(path) {
5867
+ if (!existsSync14(path)) return {};
5868
+ try {
5869
+ const parsed = JSON.parse(readFileSync15(path, "utf8"));
5870
+ return parsed && typeof parsed === "object" ? parsed : {};
5871
+ } catch {
5872
+ throw new Error(`could not parse ${path} (not valid JSON) - leaving it untouched`);
5873
+ }
5874
+ }
5875
+ function writeSettings(path, settings) {
5876
+ mkdirSync11(dirname4(path), { recursive: true });
5877
+ writeFileSync12(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
5878
+ }
5879
+ function backup(path) {
5880
+ if (!existsSync14(path)) return null;
5881
+ const dest = `${path}.holt-bak`;
5882
+ try {
5883
+ copyFileSync(path, dest);
5884
+ return dest;
5885
+ } catch {
5886
+ return null;
5887
+ }
5888
+ }
5889
+ function isHoltEntry(m, dir) {
5890
+ return Array.isArray(m.hooks) && m.hooks.some((h) => typeof h?.command === "string" && h.command.includes(HOOK_CMD[dir]));
5891
+ }
5892
+ function addHook(settings, dir) {
5893
+ settings.hooks = settings.hooks ?? {};
5894
+ const event = EVENT[dir];
5895
+ const list = settings.hooks[event] = settings.hooks[event] ?? [];
5896
+ if (list.some((m) => isHoltEntry(m, dir))) return false;
5897
+ list.push({ hooks: [{ type: "command", command: HOOK_CMD[dir] }] });
5898
+ return true;
5899
+ }
5900
+ function removeHook(settings, dir) {
5901
+ const event = EVENT[dir];
5902
+ const list = settings.hooks?.[event];
5903
+ if (!Array.isArray(list)) return false;
5904
+ let changed = false;
5905
+ const kept = [];
5906
+ for (const m of list) {
5907
+ if (!isHoltEntry(m, dir)) {
5908
+ kept.push(m);
5909
+ continue;
5910
+ }
5911
+ const others = (m.hooks || []).filter((h) => !(typeof h?.command === "string" && h.command.includes(HOOK_CMD[dir])));
5912
+ if (others.length) kept.push({ ...m, hooks: others });
5913
+ changed = true;
5914
+ }
5915
+ if (!changed) return false;
5916
+ if (kept.length) settings.hooks[event] = kept;
5917
+ else delete settings.hooks[event];
5918
+ return true;
5919
+ }
5920
+ function isHoltInstalled(settings, dir) {
5921
+ const list = settings.hooks?.[EVENT[dir]];
5922
+ return Array.isArray(list) && list.some((m) => isHoltEntry(m, dir));
5923
+ }
5924
+ function parseFlags(rest) {
5925
+ let injectOnly = false;
5926
+ let captureOnly = false;
5927
+ let project = false;
5928
+ for (const a of rest) {
5929
+ if (a === "--inject-only") injectOnly = true;
5930
+ else if (a === "--capture-only") captureOnly = true;
5931
+ else if (a === "--project") project = true;
5932
+ }
5933
+ const inject2 = injectOnly || !captureOnly;
5934
+ const capture2 = captureOnly || !injectOnly;
5935
+ return { inject: inject2, capture: capture2, project };
5936
+ }
5937
+ function install(rest) {
5938
+ const flags = parseFlags(rest);
5939
+ const path = flags.project ? projectSettingsPath() : globalSettingsPath();
5940
+ const target = flags.project ? "project (./.claude/settings.json)" : "global (~/.claude/settings.json)";
5941
+ let settings;
5942
+ try {
5943
+ settings = readSettings(path);
5944
+ } catch (e) {
5945
+ console.error(c.red(" " + e.message));
5946
+ process.exitCode = 1;
5947
+ return;
5948
+ }
5949
+ const bak = backup(path);
5950
+ const changed = [];
5951
+ if (flags.inject && addHook(settings, "inject")) changed.push("inject");
5952
+ if (flags.capture && addHook(settings, "capture")) changed.push("capture");
5953
+ writeSettings(path, settings);
5954
+ const wanted = [flags.inject ? "inject" : "", flags.capture ? "capture" : ""].filter(Boolean).join(" + ");
5955
+ if (changed.length) {
5956
+ console.log(c.green(` installed ${changed.join(" + ")} into ${target}`));
5957
+ } else {
5958
+ console.log(c.dim(` ${wanted} already installed in ${target} (no change)`));
5959
+ }
5960
+ if (bak) console.log(c.dim(` backup: ${bak}`));
5961
+ console.log(c.dim(" Holt now injects folder memory before each prompt and captures facts when a session ends,"));
5962
+ console.log(c.dim(" but only in folders you have trusted with an existing .holt/memory."));
5963
+ console.log(c.dim(" Undo any time with: holt hook remove" + (flags.project ? " --project" : "")));
5964
+ }
5965
+ function remove(rest) {
5966
+ const project = rest.includes("--project");
5967
+ const paths = project ? [projectSettingsPath()] : [globalSettingsPath(), projectSettingsPath()];
5968
+ let touchedAny = false;
5969
+ for (const path of paths) {
5970
+ if (!existsSync14(path)) continue;
5971
+ let settings;
5972
+ try {
5973
+ settings = readSettings(path);
5974
+ } catch (e) {
5975
+ console.error(c.red(" " + e.message));
5976
+ continue;
5977
+ }
5978
+ const bak = backup(path);
5979
+ let changed = false;
5980
+ if (removeHook(settings, "inject")) changed = true;
5981
+ if (removeHook(settings, "capture")) changed = true;
5982
+ if (changed) {
5983
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
5984
+ writeSettings(path, settings);
5985
+ console.log(c.green(` removed Holt hooks from ${path}`));
5986
+ if (bak) console.log(c.dim(` backup: ${bak}`));
5987
+ touchedAny = true;
5988
+ }
5989
+ }
5990
+ if (!touchedAny) console.log(c.dim(" no Holt hooks found to remove."));
5991
+ }
5992
+ function status(rest) {
5993
+ const project = rest.includes("--project");
5994
+ const entries = project ? [{ label: "project", path: projectSettingsPath() }] : [
5995
+ { label: "global", path: globalSettingsPath() },
5996
+ { label: "project", path: projectSettingsPath() }
5997
+ ];
5998
+ console.log(c.accent(" Holt ambient hooks"));
5999
+ for (const e of entries) {
6000
+ if (!existsSync14(e.path)) {
6001
+ console.log(c.dim(` ${e.label.padEnd(8)} ${e.path} (no settings file)`));
6002
+ continue;
6003
+ }
6004
+ let settings;
6005
+ try {
6006
+ settings = readSettings(e.path);
6007
+ } catch {
6008
+ console.log(c.red(` ${e.label.padEnd(8)} ${e.path} (unreadable JSON)`));
6009
+ continue;
6010
+ }
6011
+ const inj = isHoltInstalled(settings, "inject");
6012
+ const cap = isHoltInstalled(settings, "capture");
6013
+ const active = [inj ? "inject" : "", cap ? "capture" : ""].filter(Boolean).join(" + ") || "none";
6014
+ const mark = inj || cap ? c.green("on ") : c.dim("off");
6015
+ console.log(` ${e.label.padEnd(8)} ${mark} ${c.dim(e.path)} ${c.dim("[" + active + "]")}`);
6016
+ }
6017
+ console.log(c.dim(" Runtime hooks only act in trusted Holt folders that already have .holt/memory."));
6018
+ }
6019
+ function readStdin(timeoutMs) {
6020
+ return new Promise((resolve8) => {
6021
+ let input = "";
6022
+ let done = false;
6023
+ const finish = () => {
6024
+ if (done) return;
6025
+ done = true;
6026
+ resolve8(input);
6027
+ };
6028
+ try {
6029
+ process.stdin.setEncoding("utf8");
6030
+ process.stdin.on("data", (chunk2) => {
6031
+ input += chunk2;
6032
+ });
6033
+ process.stdin.on("end", finish);
6034
+ process.stdin.on("error", finish);
6035
+ } catch {
6036
+ finish();
6037
+ }
6038
+ setTimeout(finish, timeoutMs);
6039
+ });
6040
+ }
6041
+ function parseHookInput(raw) {
6042
+ try {
6043
+ const j = JSON.parse(raw || "{}");
6044
+ return j && typeof j === "object" ? j : {};
6045
+ } catch {
6046
+ return {};
6047
+ }
6048
+ }
6049
+ function enterFolder(cwd) {
6050
+ if (!cwd || !existsSync14(cwd)) return null;
6051
+ try {
6052
+ const real = realpathSync2(cwd);
6053
+ process.chdir(real);
6054
+ return real;
6055
+ } catch {
6056
+ return null;
6057
+ }
6058
+ }
6059
+ function guardTrusted(real) {
6060
+ if (!isTrusted(real)) return false;
6061
+ if (!existsSync14(memDir())) return false;
6062
+ return true;
6063
+ }
6064
+ async function inject() {
6065
+ try {
6066
+ const raw = await readStdin(2e3);
6067
+ const data = parseHookInput(raw);
6068
+ const prompt = (data.prompt || "").trim();
6069
+ if (prompt.length < 3) return;
6070
+ const real = enterFolder(data.cwd);
6071
+ if (!real) return;
6072
+ if (!guardTrusted(real)) return;
6073
+ const session = data.session_id || newSessionId();
6074
+ const hits = await recall(prompt, session, 4);
6075
+ if (!hits.length) return;
6076
+ const lines = hits.map((h) => `- ${h.turn.content.replace(/\s+/g, " ").trim().slice(0, 500)}`);
6077
+ process.stdout.write(
6078
+ ["[Holt memory - relevant notes from earlier sessions in this folder]", ...lines, ""].join("\n")
6079
+ );
6080
+ } catch (e) {
6081
+ process.stderr.write("holt hook inject: " + (e instanceof Error ? e.message : String(e)) + "\n");
6082
+ }
6083
+ }
6084
+ function readTranscript(path) {
6085
+ const out = [];
6086
+ let raw;
6087
+ try {
6088
+ raw = readFileSync15(path, "utf8");
6089
+ } catch {
6090
+ return out;
6091
+ }
6092
+ for (const line of raw.split("\n")) {
6093
+ if (!line.trim()) continue;
6094
+ let rec;
6095
+ try {
6096
+ rec = JSON.parse(line);
6097
+ } catch {
6098
+ continue;
6099
+ }
6100
+ const d = rec;
6101
+ if (d.type !== "user" && d.type !== "assistant") continue;
6102
+ const m = d.message;
6103
+ if (!m || m.role !== "user" && m.role !== "assistant") continue;
6104
+ let text = "";
6105
+ if (typeof m.content === "string") {
6106
+ text = m.content;
6107
+ } else if (Array.isArray(m.content)) {
6108
+ text = m.content.map((b) => {
6109
+ if (typeof b === "string") return b;
6110
+ if (b && typeof b.text === "string") return b.text;
6111
+ return "";
6112
+ }).join(" ").trim();
6113
+ }
6114
+ text = text.trim();
6115
+ if (text.length < 3) continue;
6116
+ out.push({ role: m.role, content: text });
6117
+ }
6118
+ return out;
6119
+ }
6120
+ function resolveExtractionBrain(cfg) {
6121
+ const id = cfg.defaultBrain;
6122
+ if (!id) return null;
6123
+ if (BRAIN_IDS.includes(id)) {
6124
+ const bid = id;
6125
+ if (!cfg.brains[bid].enabled) return null;
6126
+ if (!isInstalled(cfg.brains[bid].command)) return null;
6127
+ return { kind: "cli", id: bid };
6128
+ }
6129
+ const api = findApiBrain(cfg, id);
6130
+ if (api && resolveApiKey(api)) return { kind: "api", brain: api };
6131
+ return null;
6132
+ }
6133
+ async function capture() {
6134
+ try {
6135
+ const raw = await readStdin(3e3);
6136
+ const data = parseHookInput(raw);
6137
+ const real = enterFolder(data.cwd);
6138
+ if (!real) return;
6139
+ if (!guardTrusted(real)) return;
6140
+ const cfg = loadConfig();
6141
+ if (!cfg) return;
6142
+ if (cfg.memory?.extractFacts === false) return;
6143
+ const brain = resolveExtractionBrain(cfg);
6144
+ if (!brain) return;
6145
+ if (!data.transcript_path) return;
6146
+ const history = readTranscript(data.transcript_path);
6147
+ if (!history.length) return;
6148
+ const session = "hook-" + (data.session_id || newSessionId());
6149
+ const n = await extractAndSaveFacts(brain, cfg, history, session);
6150
+ if (n > 0) process.stderr.write(`holt hook capture: saved ${n} fact${n === 1 ? "" : "s"} to ${memDir()}
6151
+ `);
6152
+ if (cfg.wiki?.autoSync) {
6153
+ const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
6154
+ if (res.status === "ok" && res.changed) {
6155
+ process.stderr.write(
6156
+ `holt hook capture: updated the wiki (${res.created} created, ${res.updated} updated) in ${wikiDir()}
6157
+ `
6158
+ );
6159
+ }
6160
+ }
6161
+ } catch (e) {
6162
+ process.stderr.write("holt hook capture: " + (e instanceof Error ? e.message : String(e)) + "\n");
6163
+ }
6164
+ }
6165
+ var USAGE2 = [
6166
+ " holt hook <command>",
6167
+ "",
6168
+ " Wire Holt into Claude Code so folder memory works ambiently: it injects",
6169
+ " relevant remembered notes before each prompt and captures durable facts",
6170
+ " when a session ends. Both run only in trusted Holt folders that already",
6171
+ " have .holt/memory.",
6172
+ "",
6173
+ " management:",
6174
+ " install [flags] write the hooks into Claude Code settings",
6175
+ " --inject-only install only the before-prompt recall hook",
6176
+ " --capture-only install only the end-of-session fact hook",
6177
+ " --project write ./.claude/settings.json (default: ~/.claude)",
6178
+ " remove [--project] remove ONLY Holt hooks, leaving other settings intact",
6179
+ " status [--project] show what is installed and which directions are active",
6180
+ "",
6181
+ " runtime (invoked by Claude Code, not you):",
6182
+ " inject UserPromptSubmit body: recall + print a context block",
6183
+ " capture Stop body: distill facts from the transcript and save them",
6184
+ "",
6185
+ " example: holt hook install # both hooks, global settings"
6186
+ ].join("\n");
6187
+ async function hook(sub, rest = []) {
6188
+ switch (sub) {
6189
+ case "install":
6190
+ install(rest);
6191
+ return;
6192
+ case "remove":
6193
+ case "uninstall":
6194
+ remove(rest);
6195
+ return;
6196
+ case "status":
6197
+ status(rest);
6198
+ return;
6199
+ case "inject":
6200
+ await inject();
6201
+ return;
6202
+ case "capture":
6203
+ await capture();
6204
+ return;
6205
+ case void 0:
6206
+ case "help":
6207
+ case "-h":
6208
+ case "--help":
6209
+ console.log(USAGE2);
6210
+ return;
6211
+ default:
6212
+ console.error(` Unknown hook command: "${sub}". Run "holt hook help".`);
6213
+ process.exitCode = 1;
6214
+ }
6215
+ }
6216
+
3398
6217
  // src/commands/run.ts
3399
- import { writeFileSync as writeFileSync9 } from "fs";
3400
- import { resolve as resolve3 } from "path";
3401
- var USAGE = [
6218
+ import { writeFileSync as writeFileSync13 } from "fs";
6219
+ import { resolve as resolve5 } from "path";
6220
+ var USAGE3 = [
3402
6221
  " holt run <task> [options]",
3403
6222
  "",
3404
6223
  " Run a single task against your default brain and stream the reply.",
@@ -3448,7 +6267,7 @@ function parseArgs2(args) {
3448
6267
  async function run(args) {
3449
6268
  const p = parseArgs2(args);
3450
6269
  if (!p.task) {
3451
- console.log(USAGE);
6270
+ console.log(USAGE3);
3452
6271
  return;
3453
6272
  }
3454
6273
  if (!isTrusted()) {
@@ -3486,9 +6305,9 @@ async function run(args) {
3486
6305
  process.stdout.write("\n");
3487
6306
  }
3488
6307
  if (p.out) {
3489
- const target = resolve3(process.cwd(), p.out);
6308
+ const target = resolve5(process.cwd(), p.out);
3490
6309
  try {
3491
- writeFileSync9(target, result.text, "utf8");
6310
+ writeFileSync13(target, result.text, "utf8");
3492
6311
  process.stderr.write(c.dim(`saved to ${target}`) + "\n");
3493
6312
  } catch (e) {
3494
6313
  process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
@@ -3499,38 +6318,38 @@ async function run(args) {
3499
6318
 
3500
6319
  // src/commands/schedule.ts
3501
6320
  import { spawnSync as spawnSync4 } from "child_process";
3502
- import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync9, rmSync as rmSync4, existsSync as existsSync9 } from "fs";
3503
- import { dirname as dirname3 } from "path";
6321
+ import { writeFileSync as writeFileSync15, mkdirSync as mkdirSync13, rmSync as rmSync6, existsSync as existsSync15 } from "fs";
6322
+ import { dirname as dirname5 } from "path";
3504
6323
 
3505
6324
  // src/scheduler.ts
3506
- import { homedir as homedir3 } from "os";
3507
- import { join as join10 } from "path";
3508
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync10, mkdirSync as mkdirSync8 } from "fs";
6325
+ import { homedir as homedir4 } from "os";
6326
+ import { join as join16 } from "path";
6327
+ import { readFileSync as readFileSync16, writeFileSync as writeFileSync14, mkdirSync as mkdirSync12 } from "fs";
3509
6328
  import { randomUUID as randomUUID4 } from "crypto";
3510
6329
  import { spawnSync as spawnSync3 } from "child_process";
3511
6330
  function schedulesPath() {
3512
- return join10(GLOBAL_DIR, "schedules.json");
6331
+ return join16(GLOBAL_DIR, "schedules.json");
3513
6332
  }
3514
6333
  function logsDir() {
3515
- return join10(GLOBAL_DIR, "logs");
6334
+ return join16(GLOBAL_DIR, "logs");
3516
6335
  }
3517
6336
  function logPath(id) {
3518
- return join10(logsDir(), `${id}.log`);
6337
+ return join16(logsDir(), `${id}.log`);
3519
6338
  }
3520
6339
  function plistPath(id) {
3521
- return join10(homedir3(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
6340
+ return join16(homedir4(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
3522
6341
  }
3523
6342
  function loadJobs() {
3524
6343
  try {
3525
- const raw = JSON.parse(readFileSync9(schedulesPath(), "utf8"));
6344
+ const raw = JSON.parse(readFileSync16(schedulesPath(), "utf8"));
3526
6345
  return Array.isArray(raw) ? raw : [];
3527
6346
  } catch {
3528
6347
  return [];
3529
6348
  }
3530
6349
  }
3531
6350
  function saveJobs(jobs) {
3532
- mkdirSync8(GLOBAL_DIR, { recursive: true });
3533
- writeFileSync10(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
6351
+ mkdirSync12(GLOBAL_DIR, { recursive: true });
6352
+ writeFileSync14(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
3534
6353
  }
3535
6354
  function addJob(job) {
3536
6355
  const jobs = loadJobs();
@@ -3575,6 +6394,10 @@ function resolveHoltPath() {
3575
6394
  function buildCommand(job, holtPath) {
3576
6395
  const holt = shQuote(holtPath);
3577
6396
  const ws = shQuote(job.workspace);
6397
+ if (job.runArgs && job.runArgs.length > 0) {
6398
+ const parts = job.runArgs.map(shQuote).join(" ");
6399
+ return `cd ${ws} && ${holt} ${parts} --quiet`;
6400
+ }
3578
6401
  const log = shQuote(logPath(job.id));
3579
6402
  let cmd = `cd ${ws} && ${holt} run ${shQuote(job.task)}`;
3580
6403
  if (job.brain) cmd += ` --brain ${shQuote(job.brain)}`;
@@ -3634,7 +6457,7 @@ function appendCronLine(crontab, line) {
3634
6457
  return (base ? base + "\n" : "") + line + "\n";
3635
6458
  }
3636
6459
  function ensureLogsDir() {
3637
- mkdirSync8(logsDir(), { recursive: true });
6460
+ mkdirSync12(logsDir(), { recursive: true });
3638
6461
  }
3639
6462
 
3640
6463
  // src/commands/schedule.ts
@@ -3660,8 +6483,8 @@ function parseAdd(rest) {
3660
6483
  }
3661
6484
  function installDarwin(job, holtPath) {
3662
6485
  const path = plistPath(job.id);
3663
- mkdirSync9(dirname3(path), { recursive: true });
3664
- writeFileSync11(path, buildLaunchdPlist(job, holtPath), "utf8");
6486
+ mkdirSync13(dirname5(path), { recursive: true });
6487
+ writeFileSync15(path, buildLaunchdPlist(job, holtPath), "utf8");
3665
6488
  const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
3666
6489
  if (res.status === 0) {
3667
6490
  console.log(c.green(" Loaded into launchd."));
@@ -3690,114 +6513,576 @@ function installLinux(job, holtPath) {
3690
6513
  console.log(c.dim(" Could not update the crontab automatically. Add this line yourself:"));
3691
6514
  console.log(" " + line);
3692
6515
  }
3693
- }
3694
- async function addCmd(rest) {
3695
- const { task, when, opts } = parseAdd(rest);
3696
- if (!task || !when) {
3697
- console.log(c.dim('\n Usage: holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]\n'));
6516
+ }
6517
+ async function addCmd(rest) {
6518
+ const { task, when, opts } = parseAdd(rest);
6519
+ if (!task || !when) {
6520
+ console.log(c.dim('\n Usage: holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]\n'));
6521
+ process.exitCode = 1;
6522
+ return;
6523
+ }
6524
+ try {
6525
+ parseWhen(when);
6526
+ } catch (e) {
6527
+ console.error("\n " + c.red(e.message) + "\n");
6528
+ process.exitCode = 1;
6529
+ return;
6530
+ }
6531
+ const ws = workspace();
6532
+ const holtPath = resolveHoltPath();
6533
+ const job = {
6534
+ id: newJobId(),
6535
+ name: opts.name || task.slice(0, 40),
6536
+ task,
6537
+ when,
6538
+ workspace: ws,
6539
+ notify: opts.notify,
6540
+ ...opts.brain ? { brain: opts.brain } : {}
6541
+ };
6542
+ ensureLogsDir();
6543
+ addJob(job);
6544
+ console.log("\n" + c.accent("Scheduled."));
6545
+ console.log(` id ${job.id}`);
6546
+ console.log(` when daily at ${job.when}`);
6547
+ console.log(` workspace ${job.workspace}`);
6548
+ if (job.notify) console.log(` notify on (pushes output to Telegram)`);
6549
+ console.log(` log ${logPath(job.id)}`);
6550
+ if (process.platform === "darwin") installDarwin(job, holtPath);
6551
+ else if (process.platform === "linux") installLinux(job, holtPath);
6552
+ else {
6553
+ console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
6554
+ console.log(" " + buildCronLine(job, holtPath));
6555
+ }
6556
+ console.log("");
6557
+ }
6558
+ function listCmd2() {
6559
+ const jobs = loadJobs();
6560
+ if (jobs.length === 0) {
6561
+ console.log(c.dim("\n No scheduled tasks yet. Add one with:"));
6562
+ console.log(c.dim(' holt schedule add "<task>" <HH:MM> [--notify]\n'));
6563
+ return;
6564
+ }
6565
+ console.log("\n" + c.accent("Scheduled tasks"));
6566
+ for (const j of jobs) {
6567
+ console.log(` ${c.bold(j.id)} daily ${j.when} ${c.dim(j.notify ? "(notify)" : "")}`);
6568
+ console.log(` ${j.name}`);
6569
+ console.log(c.dim(` ${j.workspace}`));
6570
+ }
6571
+ console.log("");
6572
+ }
6573
+ function removeDarwin(id) {
6574
+ const path = plistPath(id);
6575
+ if (existsSync15(path)) {
6576
+ spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
6577
+ try {
6578
+ rmSync6(path);
6579
+ } catch {
6580
+ }
6581
+ }
6582
+ }
6583
+ function removeLinux(id) {
6584
+ const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
6585
+ const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6586
+ const next = stripCronLines(body, id);
6587
+ spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
6588
+ }
6589
+ function removeCmd(rest) {
6590
+ const id = rest[0];
6591
+ if (!id) {
6592
+ console.log(c.dim("\n Usage: holt schedule remove <id> (see ids with: holt schedule list)\n"));
6593
+ process.exitCode = 1;
6594
+ return;
6595
+ }
6596
+ const before = loadJobs();
6597
+ const existed = before.some((j) => j.id === id);
6598
+ removeJob(id);
6599
+ if (process.platform === "darwin") removeDarwin(id);
6600
+ else if (process.platform === "linux") removeLinux(id);
6601
+ if (existed) console.log("\n" + c.green(` Removed schedule ${id}.`) + "\n");
6602
+ else {
6603
+ console.error(c.dim(`
6604
+ No schedule with id ${id}. Nothing to remove.
6605
+ `));
6606
+ process.exitCode = 1;
6607
+ }
6608
+ }
6609
+ function usage2() {
6610
+ console.log("\n" + c.accent("holt schedule") + c.dim(" run a task automatically on a timer"));
6611
+ console.log(c.dim('\n holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]'));
6612
+ console.log(c.dim(" holt schedule list"));
6613
+ console.log(c.dim(" holt schedule remove <id>\n"));
6614
+ }
6615
+ async function schedule(sub, rest = []) {
6616
+ const action = (sub || "").toLowerCase();
6617
+ if (action === "list") {
6618
+ listCmd2();
6619
+ return;
6620
+ }
6621
+ if (action !== "add" && action !== "remove" && action !== "rm") {
6622
+ if (action) process.exitCode = 1;
6623
+ usage2();
6624
+ return;
6625
+ }
6626
+ const { ask, close } = createReader();
6627
+ if (!await ensureTrusted(ask)) {
6628
+ close();
6629
+ return;
6630
+ }
6631
+ close();
6632
+ if (action === "add") await addCmd(rest);
6633
+ else removeCmd(rest);
6634
+ }
6635
+
6636
+ // src/commands/routine.ts
6637
+ import { spawnSync as spawnSync5 } from "child_process";
6638
+ import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync15, rmSync as rmSync7, existsSync as existsSync16 } from "fs";
6639
+ import { dirname as dirname6, resolve as resolve6 } from "path";
6640
+
6641
+ // src/routines.ts
6642
+ import { readFileSync as readFileSync17, writeFileSync as writeFileSync16, mkdirSync as mkdirSync14 } from "fs";
6643
+ import { join as join17 } from "path";
6644
+ function sanitizeRoutineName(name) {
6645
+ return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
6646
+ }
6647
+ function routinesPath() {
6648
+ return join17(GLOBAL_DIR, "routines.json");
6649
+ }
6650
+ function loadRoutines() {
6651
+ try {
6652
+ const raw = JSON.parse(readFileSync17(routinesPath(), "utf8"));
6653
+ return Array.isArray(raw) ? raw : [];
6654
+ } catch {
6655
+ return [];
6656
+ }
6657
+ }
6658
+ function saveRoutines(routines) {
6659
+ mkdirSync14(GLOBAL_DIR, { recursive: true });
6660
+ writeFileSync16(routinesPath(), JSON.stringify(routines, null, 2) + "\n", "utf8");
6661
+ }
6662
+ function findRoutine(name) {
6663
+ const want = sanitizeRoutineName(name);
6664
+ return loadRoutines().find((r) => r.name === want);
6665
+ }
6666
+ function upsertRoutine(routine2) {
6667
+ const routines = loadRoutines().filter((r) => r.name !== routine2.name);
6668
+ routines.push(routine2);
6669
+ saveRoutines(routines);
6670
+ return routines;
6671
+ }
6672
+ function removeRoutine(name) {
6673
+ const want = sanitizeRoutineName(name);
6674
+ const routines = loadRoutines().filter((r) => r.name !== want);
6675
+ saveRoutines(routines);
6676
+ return routines;
6677
+ }
6678
+ var ROUTINE_TEMPLATES = {
6679
+ "daily-brief": {
6680
+ describe: "A short daily brief of what changed and what is open in this folder, pushed to Telegram.",
6681
+ source: {
6682
+ kind: "task",
6683
+ value: "Summarize what changed and what is open in this folder recently, as a short brief. Lead with the most important item. Keep it to a few tight bullets."
6684
+ },
6685
+ when: "08:00",
6686
+ notify: true
6687
+ },
6688
+ standup: {
6689
+ describe: "A daily standup: what got done, what is next, and any blockers in this folder.",
6690
+ source: {
6691
+ kind: "task",
6692
+ value: "Write a short standup for this folder: what got done recently, what is next, and any blockers. Three short sections, plain text."
6693
+ },
6694
+ when: "09:00",
6695
+ notify: true
6696
+ }
6697
+ };
6698
+ function templateNames() {
6699
+ return Object.keys(ROUTINE_TEMPLATES).sort();
6700
+ }
6701
+
6702
+ // src/commands/routine.ts
6703
+ function jobForRoutine(r) {
6704
+ return {
6705
+ id: routineJobId(r.name),
6706
+ name: `routine:${r.name}`,
6707
+ task: r.source.kind === "task" ? r.source.value : `skill:${r.source.value}`,
6708
+ when: r.when,
6709
+ workspace: r.workspace,
6710
+ notify: r.notify,
6711
+ ...r.brain ? { brain: r.brain } : {},
6712
+ runArgs: ["routine", "run", r.name]
6713
+ };
6714
+ }
6715
+ function routineJobId(name) {
6716
+ return `routine-${sanitizeRoutineName(name)}`;
6717
+ }
6718
+ function installDarwin2(job, holtPath) {
6719
+ const path = plistPath(job.id);
6720
+ mkdirSync15(dirname6(path), { recursive: true });
6721
+ writeFileSync17(path, buildLaunchdPlist(job, holtPath), "utf8");
6722
+ const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
6723
+ if (res.status === 0) {
6724
+ console.log(c.green(" Loaded into launchd."));
6725
+ } else {
6726
+ console.log(c.dim(" Wrote the plist, but launchctl load did not confirm."));
6727
+ console.log(c.dim(` Load it yourself with: launchctl load -w ${path}`));
6728
+ if (res.stderr && res.stderr.trim()) console.log(c.dim(` (${res.stderr.trim()})`));
6729
+ }
6730
+ console.log(c.dim(` plist: ${path}`));
6731
+ }
6732
+ function currentCrontab2() {
6733
+ const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
6734
+ return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6735
+ }
6736
+ function writeCrontab2(body) {
6737
+ const res = spawnSync5("crontab", ["-"], { input: body, encoding: "utf8" });
6738
+ return res.status === 0;
6739
+ }
6740
+ function installLinux2(job, holtPath) {
6741
+ const line = buildCronLine(job, holtPath);
6742
+ const next = appendCronLine(currentCrontab2(), line);
6743
+ if (writeCrontab2(next)) {
6744
+ console.log(c.green(" Added to your crontab."));
6745
+ console.log(c.dim(` line: ${line}`));
6746
+ } else {
6747
+ console.log(c.dim(" Could not update the crontab automatically. Add this line yourself:"));
6748
+ console.log(" " + line);
6749
+ }
6750
+ }
6751
+ function installSchedule(r) {
6752
+ const holtPath = resolveHoltPath();
6753
+ const job = jobForRoutine(r);
6754
+ removeSchedule(r.name);
6755
+ addJob(job);
6756
+ if (process.platform === "darwin") installDarwin2(job, holtPath);
6757
+ else if (process.platform === "linux") installLinux2(job, holtPath);
6758
+ else {
6759
+ console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
6760
+ console.log(" " + buildCronLine(job, holtPath));
6761
+ }
6762
+ }
6763
+ function removeScheduleDarwin(id) {
6764
+ const path = plistPath(id);
6765
+ if (existsSync16(path)) {
6766
+ spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
6767
+ try {
6768
+ rmSync7(path);
6769
+ } catch {
6770
+ }
6771
+ }
6772
+ }
6773
+ function removeScheduleLinux(id) {
6774
+ const body = currentCrontab2();
6775
+ const next = stripCronLines(body, id);
6776
+ writeCrontab2(next);
6777
+ }
6778
+ function removeSchedule(name) {
6779
+ const id = routineJobId(name);
6780
+ const existed = loadJobs().some((j) => j.id === id);
6781
+ removeJob(id);
6782
+ if (!existed) return;
6783
+ if (process.platform === "darwin") removeScheduleDarwin(id);
6784
+ else if (process.platform === "linux") removeScheduleLinux(id);
6785
+ }
6786
+ function parseAdd2(rest) {
6787
+ const opts = { notify: false };
6788
+ let name;
6789
+ for (let i = 0; i < rest.length; i++) {
6790
+ const a = rest[i];
6791
+ if (a === void 0) continue;
6792
+ if (a === "--notify") opts.notify = true;
6793
+ else if (a === "--skill") opts.skill = rest[++i];
6794
+ else if (a === "--task") opts.task = rest[++i];
6795
+ else if (a === "--template") opts.template = rest[++i];
6796
+ else if (a === "--at") opts.when = rest[++i];
6797
+ else if (a === "--out") opts.out = rest[++i];
6798
+ else if (a === "--brain") opts.brain = rest[++i];
6799
+ else if (a.startsWith("--")) {
6800
+ const eq = a.indexOf("=");
6801
+ if (eq > 2) {
6802
+ const k = a.slice(2, eq);
6803
+ const v = a.slice(eq + 1);
6804
+ if (k === "skill") opts.skill = v;
6805
+ else if (k === "task") opts.task = v;
6806
+ else if (k === "template") opts.template = v;
6807
+ else if (k === "at") opts.when = v;
6808
+ else if (k === "out") opts.out = v;
6809
+ else if (k === "brain") opts.brain = v;
6810
+ }
6811
+ } else if (name === void 0) name = a;
6812
+ }
6813
+ return { name, opts };
6814
+ }
6815
+ async function addCmd2(rest) {
6816
+ const { name: rawName, opts } = parseAdd2(rest);
6817
+ const name = sanitizeRoutineName(rawName || "");
6818
+ if (!name) {
6819
+ usage3();
6820
+ process.exitCode = 1;
6821
+ return;
6822
+ }
6823
+ let source;
6824
+ let when = opts.when;
6825
+ let notify2 = opts.notify;
6826
+ if (opts.template) {
6827
+ const tpl = ROUTINE_TEMPLATES[opts.template];
6828
+ if (!tpl) {
6829
+ console.error(
6830
+ "\n " + c.red(`Unknown template "${opts.template}".`) + c.dim(` Available: ${templateNames().join(", ")}`) + "\n"
6831
+ );
6832
+ process.exitCode = 1;
6833
+ return;
6834
+ }
6835
+ source = { ...tpl.source };
6836
+ if (when === void 0) when = tpl.when;
6837
+ if (!opts.notify) notify2 = tpl.notify;
6838
+ } else if (opts.skill !== void 0 && opts.task !== void 0) {
6839
+ console.error("\n " + c.red("Give either --skill or --task, not both.") + "\n");
6840
+ process.exitCode = 1;
6841
+ return;
6842
+ } else if (opts.skill !== void 0) {
6843
+ const loaded = loadSkill(opts.skill);
6844
+ if (!loaded) {
6845
+ console.error("\n " + c.red(`No skill named "${opts.skill}".`) + c.dim(' Try "holt skill list".') + "\n");
6846
+ process.exitCode = 1;
6847
+ return;
6848
+ }
6849
+ source = { kind: "skill", value: loaded.skill.name };
6850
+ } else if (opts.task !== void 0 && opts.task.trim()) {
6851
+ source = { kind: "task", value: opts.task.trim() };
6852
+ } else {
6853
+ console.error("\n " + c.red('A routine needs a source: give --skill <s>, --task "<p>", or --template <t>.') + "\n");
6854
+ process.exitCode = 1;
6855
+ return;
6856
+ }
6857
+ if (when !== void 0) {
6858
+ try {
6859
+ parseWhen(when);
6860
+ } catch (e) {
6861
+ console.error("\n " + c.red(e.message) + "\n");
6862
+ process.exitCode = 1;
6863
+ return;
6864
+ }
6865
+ }
6866
+ const routine2 = {
6867
+ name,
6868
+ source,
6869
+ notify: notify2,
6870
+ workspace: workspace(),
6871
+ ...when !== void 0 ? { when } : {},
6872
+ ...opts.out ? { out: opts.out } : {},
6873
+ ...opts.brain ? { brain: opts.brain } : {}
6874
+ };
6875
+ upsertRoutine(routine2);
6876
+ if (routine2.when) installSchedule(routine2);
6877
+ console.log("\n" + c.accent("Routine saved."));
6878
+ printRoutine(routine2, " ");
6879
+ if (!routine2.when) console.log(c.dim(`
6880
+ Run it with: holt routine run ${routine2.name}`));
6881
+ console.log("");
6882
+ }
6883
+ function buildRoutinePrompt(r) {
6884
+ if (r.source.kind === "task") return { prompt: r.source.value };
6885
+ const loaded = loadSkill(r.source.value);
6886
+ if (!loaded) return { error: `Skill "${r.source.value}" is no longer installed. Try "holt skill list".` };
6887
+ const prompt = [
6888
+ loaded.body,
6889
+ "",
6890
+ "Apply the skill above to this request:",
6891
+ "Run this skill now for the current folder."
6892
+ ].join("\n");
6893
+ return { prompt };
6894
+ }
6895
+ async function runCmd(rest) {
6896
+ let quiet = false;
6897
+ let outOverride;
6898
+ let notifyOverride = false;
6899
+ let brainOverride;
6900
+ let name = "";
6901
+ for (let i = 0; i < rest.length; i++) {
6902
+ const a = rest[i];
6903
+ if (a === void 0) continue;
6904
+ if (a === "--quiet") quiet = true;
6905
+ else if (a === "--notify") notifyOverride = true;
6906
+ else if (a === "--out") outOverride = rest[++i];
6907
+ else if (a === "--brain") brainOverride = rest[++i];
6908
+ else if (!a.startsWith("--") && !name) name = sanitizeRoutineName(a);
6909
+ }
6910
+ if (!name) {
6911
+ console.log(c.dim("\n Usage: holt routine run <name> [--quiet] [--out <file>] [--notify] [--brain <id>]\n"));
6912
+ process.exitCode = 1;
6913
+ return;
6914
+ }
6915
+ const routine2 = findRoutine(name);
6916
+ if (!routine2) {
6917
+ process.stderr.write(c.red(`No routine named "${name}". See "holt routine list".`) + "\n");
6918
+ process.exitCode = 1;
6919
+ return;
6920
+ }
6921
+ if (!isTrusted()) {
6922
+ if (process.stdin.isTTY) {
6923
+ const { ask, close } = createReader();
6924
+ const ok2 = await ensureTrusted(ask);
6925
+ close();
6926
+ if (!ok2) return;
6927
+ } else {
6928
+ process.stderr.write("This folder is not trusted. Run holt init or holt chat here once, then run the routine.\n");
6929
+ process.exitCode = 1;
6930
+ return;
6931
+ }
6932
+ }
6933
+ const built = buildRoutinePrompt(routine2);
6934
+ if ("error" in built) {
6935
+ process.stderr.write(c.red(built.error) + "\n");
3698
6936
  process.exitCode = 1;
3699
6937
  return;
3700
6938
  }
3701
- try {
3702
- parseWhen(when);
3703
- } catch (e) {
3704
- console.error("\n " + c.red(e.message) + "\n");
6939
+ const brainId = brainOverride ?? routine2.brain;
6940
+ const outTarget = outOverride ?? routine2.out;
6941
+ const wantNotify = notifyOverride || routine2.notify;
6942
+ let streamed = false;
6943
+ const result = await runTask(built.prompt, {
6944
+ ...brainId ? { brainId } : {},
6945
+ onChunk: quiet ? void 0 : (ch) => {
6946
+ streamed = true;
6947
+ process.stdout.write(ch);
6948
+ }
6949
+ });
6950
+ if (!result.ok) {
6951
+ if (streamed) process.stdout.write("\n");
6952
+ process.stderr.write(c.red(result.text) + "\n");
3705
6953
  process.exitCode = 1;
3706
6954
  return;
3707
6955
  }
3708
- const ws = workspace();
3709
- const holtPath = resolveHoltPath();
3710
- const job = {
3711
- id: newJobId(),
3712
- name: opts.name || task.slice(0, 40),
3713
- task,
3714
- when,
3715
- workspace: ws,
3716
- notify: opts.notify,
3717
- ...opts.brain ? { brain: opts.brain } : {}
3718
- };
3719
- ensureLogsDir();
3720
- addJob(job);
3721
- console.log("\n" + c.accent("Scheduled."));
3722
- console.log(` id ${job.id}`);
3723
- console.log(` when daily at ${job.when}`);
3724
- console.log(` workspace ${job.workspace}`);
3725
- if (job.notify) console.log(` notify on (pushes output to Telegram)`);
3726
- console.log(` log ${logPath(job.id)}`);
3727
- if (process.platform === "darwin") installDarwin(job, holtPath);
3728
- else if (process.platform === "linux") installLinux(job, holtPath);
3729
- else {
3730
- console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
3731
- console.log(" " + buildCronLine(job, holtPath));
6956
+ if (!quiet && !streamed) process.stdout.write(result.text);
6957
+ if (!quiet && (streamed || result.text) && !result.text.endsWith("\n")) process.stdout.write("\n");
6958
+ if (outTarget) {
6959
+ const target = resolve6(routine2.workspace, outTarget);
6960
+ try {
6961
+ writeFileSync17(target, result.text, "utf8");
6962
+ if (!quiet) process.stderr.write(c.dim(`saved to ${target}`) + "\n");
6963
+ } catch (e) {
6964
+ process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
6965
+ process.exitCode = 1;
6966
+ }
6967
+ }
6968
+ if (wantNotify) {
6969
+ if (!loadTelegramConfig()) {
6970
+ process.stderr.write(c.dim('Telegram is not set up; skipping notify. Run "holt telegram setup".') + "\n");
6971
+ } else {
6972
+ const sent = await sendMessage(result.text);
6973
+ if (!sent) process.stderr.write(c.dim("Could not push the result to Telegram.") + "\n");
6974
+ }
3732
6975
  }
3733
- console.log("");
3734
6976
  }
3735
- function listCmd() {
3736
- const jobs = loadJobs();
3737
- if (jobs.length === 0) {
3738
- console.log(c.dim("\n No scheduled tasks yet. Add one with:"));
3739
- console.log(c.dim(' holt schedule add "<task>" <HH:MM> [--notify]\n'));
6977
+ function sourceLabel(s) {
6978
+ return s.kind === "skill" ? `skill:${s.value}` : `task: ${s.value.length > 46 ? s.value.slice(0, 45) + "\u2026" : s.value}`;
6979
+ }
6980
+ function outputsLabel(r) {
6981
+ const outs = [];
6982
+ if (r.notify) outs.push("notify");
6983
+ if (r.out) outs.push(`out:${r.out}`);
6984
+ if (outs.length === 0) outs.push("stdout");
6985
+ return outs.join(" ");
6986
+ }
6987
+ function listCmd3() {
6988
+ const routines = loadRoutines();
6989
+ if (routines.length === 0) {
6990
+ console.log(c.dim("\n No routines yet. Add one with:"));
6991
+ console.log(c.dim(' holt routine add <name> (--skill <s> | --task "<p>") [--at HH:MM] [--notify] [--out <file>]\n'));
3740
6992
  return;
3741
6993
  }
3742
- console.log("\n" + c.accent("Scheduled tasks"));
3743
- for (const j of jobs) {
3744
- console.log(` ${c.bold(j.id)} daily ${j.when} ${c.dim(j.notify ? "(notify)" : "")}`);
3745
- console.log(` ${j.name}`);
3746
- console.log(c.dim(` ${j.workspace}`));
6994
+ console.log("\n" + c.accent("Routines"));
6995
+ for (const r of routines) {
6996
+ const sched = r.when ? `daily ${r.when}` : "manual";
6997
+ console.log(` ${c.bold(r.name)} ${c.dim(sched)} ${c.dim(outputsLabel(r))}`);
6998
+ console.log(c.dim(` ${sourceLabel(r.source)}`));
6999
+ console.log(c.dim(` ${r.workspace}`));
7000
+ }
7001
+ console.log(c.dim("\n holt routine show <name> full detail"));
7002
+ console.log(c.dim(" holt routine run <name> run it now\n"));
7003
+ }
7004
+ function printRoutine(r, pad) {
7005
+ console.log(`${pad}name ${r.name}`);
7006
+ console.log(`${pad}source ${sourceLabel(r.source)}`);
7007
+ console.log(`${pad}schedule ${r.when ? `daily at ${r.when}` : "manual (run on demand)"}`);
7008
+ console.log(`${pad}outputs ${outputsLabel(r)}`);
7009
+ if (r.brain) console.log(`${pad}brain ${r.brain}`);
7010
+ console.log(`${pad}workspace ${r.workspace}`);
7011
+ }
7012
+ function showCmd2(rest) {
7013
+ const name = sanitizeRoutineName(rest.find((a) => !a.startsWith("--")) || "");
7014
+ if (!name) {
7015
+ console.log(c.dim("\n Usage: holt routine show <name>\n"));
7016
+ process.exitCode = 1;
7017
+ return;
3747
7018
  }
3748
- console.log("");
3749
- }
3750
- function removeDarwin(id) {
3751
- const path = plistPath(id);
3752
- if (existsSync9(path)) {
3753
- spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
3754
- try {
3755
- rmSync4(path);
3756
- } catch {
3757
- }
7019
+ const r = findRoutine(name);
7020
+ if (!r) {
7021
+ console.error(c.dim(`
7022
+ No routine named "${name}". See "holt routine list".
7023
+ `));
7024
+ process.exitCode = 1;
7025
+ return;
3758
7026
  }
7027
+ console.log("\n" + c.accent(r.name));
7028
+ printRoutine(r, " ");
7029
+ if (r.source.kind === "task") {
7030
+ console.log(c.dim("\n task:"));
7031
+ console.log(" " + r.source.value);
7032
+ }
7033
+ console.log("");
3759
7034
  }
3760
- function removeLinux(id) {
3761
- const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
3762
- const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
3763
- const next = stripCronLines(body, id);
3764
- spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
3765
- }
3766
- function removeCmd(rest) {
3767
- const id = rest[0];
3768
- if (!id) {
3769
- console.log(c.dim("\n Usage: holt schedule remove <id> (see ids with: holt schedule list)\n"));
7035
+ function removeCmd2(rest) {
7036
+ const name = sanitizeRoutineName(rest.find((a) => !a.startsWith("--")) || "");
7037
+ if (!name) {
7038
+ console.log(c.dim("\n Usage: holt routine remove <name>\n"));
3770
7039
  process.exitCode = 1;
3771
7040
  return;
3772
7041
  }
3773
- const before = loadJobs();
3774
- const existed = before.some((j) => j.id === id);
3775
- removeJob(id);
3776
- if (process.platform === "darwin") removeDarwin(id);
3777
- else if (process.platform === "linux") removeLinux(id);
3778
- if (existed) console.log("\n" + c.green(` Removed schedule ${id}.`) + "\n");
7042
+ const existed = loadRoutines().some((r) => r.name === name);
7043
+ removeSchedule(name);
7044
+ removeRoutine(name);
7045
+ if (existed) console.log("\n" + c.green(` Removed routine ${name}.`) + "\n");
3779
7046
  else {
3780
7047
  console.error(c.dim(`
3781
- No schedule with id ${id}. Nothing to remove.
7048
+ No routine named "${name}". Nothing to remove.
3782
7049
  `));
3783
7050
  process.exitCode = 1;
3784
7051
  }
3785
7052
  }
3786
- function usage2() {
3787
- console.log("\n" + c.accent("holt schedule") + c.dim(" run a task automatically on a timer"));
3788
- console.log(c.dim('\n holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]'));
3789
- console.log(c.dim(" holt schedule list"));
3790
- console.log(c.dim(" holt schedule remove <id>\n"));
7053
+ function usage3() {
7054
+ console.log("\n" + c.accent("holt routine") + c.dim(" named, reusable, scheduled jobs"));
7055
+ console.log(c.dim('\n holt routine add <name> (--skill <s> | --task "<p>") [--at HH:MM] [--notify] [--out <file>] [--brain <id>]'));
7056
+ console.log(c.dim(` holt routine add <name> --template <t> (templates: ${templateNames().join(", ")})`));
7057
+ console.log(c.dim(" holt routine run <name> [--quiet]"));
7058
+ console.log(c.dim(" holt routine list"));
7059
+ console.log(c.dim(" holt routine show <name>"));
7060
+ console.log(c.dim(" holt routine remove <name>"));
7061
+ console.log(c.dim("\n A routine = a task source (skill or inline prompt) + an optional daily"));
7062
+ console.log(c.dim(" schedule + output routing (stdout, --out file, or --notify to Telegram).\n"));
3791
7063
  }
3792
- async function schedule(sub, rest = []) {
7064
+ async function routine(sub, rest = []) {
3793
7065
  const action = (sub || "").toLowerCase();
3794
- if (action === "list") {
3795
- listCmd();
7066
+ if (action === "list" || action === "ls") {
7067
+ listCmd3();
7068
+ return;
7069
+ }
7070
+ if (action === "show" || action === "view") {
7071
+ showCmd2(rest);
7072
+ return;
7073
+ }
7074
+ if (!action) {
7075
+ usage3();
7076
+ listCmd3();
7077
+ return;
7078
+ }
7079
+ if (action === "run") {
7080
+ await runCmd(rest);
3796
7081
  return;
3797
7082
  }
3798
7083
  if (action !== "add" && action !== "remove" && action !== "rm") {
3799
- if (action) process.exitCode = 1;
3800
- usage2();
7084
+ process.exitCode = 1;
7085
+ usage3();
3801
7086
  return;
3802
7087
  }
3803
7088
  const { ask, close } = createReader();
@@ -3806,12 +7091,12 @@ async function schedule(sub, rest = []) {
3806
7091
  return;
3807
7092
  }
3808
7093
  close();
3809
- if (action === "add") await addCmd(rest);
3810
- else removeCmd(rest);
7094
+ if (action === "add") await addCmd2(rest);
7095
+ else removeCmd2(rest);
3811
7096
  }
3812
7097
 
3813
7098
  // src/commands/notify.ts
3814
- import { readFileSync as readFileSync10 } from "fs";
7099
+ import { readFileSync as readFileSync18 } from "fs";
3815
7100
  async function notify(args) {
3816
7101
  const nonFlag = args.filter((a) => a !== "-" && !a.startsWith("--"));
3817
7102
  const wantsStdin = args.includes("-") || !process.stdin.isTTY;
@@ -3819,7 +7104,7 @@ async function notify(args) {
3819
7104
  if (nonFlag.length > 0 && !args.includes("-")) {
3820
7105
  message = nonFlag.join(" ").trim();
3821
7106
  } else if (wantsStdin) {
3822
- message = readStdin().trim();
7107
+ message = readStdin2().trim();
3823
7108
  } else if (nonFlag.length > 0) {
3824
7109
  message = nonFlag.join(" ").trim();
3825
7110
  }
@@ -3842,14 +7127,409 @@ async function notify(args) {
3842
7127
  process.exitCode = 1;
3843
7128
  }
3844
7129
  }
3845
- function readStdin() {
7130
+ function readStdin2() {
3846
7131
  try {
3847
- return readFileSync10(0, "utf8");
7132
+ return readFileSync18(0, "utf8");
3848
7133
  } catch {
3849
7134
  return "";
3850
7135
  }
3851
7136
  }
3852
7137
 
7138
+ // src/specs.ts
7139
+ import os from "os";
7140
+ var BYTES_PER_GB = 1024 ** 3;
7141
+ function toGB(bytes) {
7142
+ if (!Number.isFinite(bytes) || bytes <= 0) return 0;
7143
+ return Math.round(bytes / BYTES_PER_GB);
7144
+ }
7145
+ function detect() {
7146
+ let platform = "unknown";
7147
+ let arch = "unknown";
7148
+ let cpuModel = "unknown";
7149
+ let cpuCount = 0;
7150
+ let totalRamGB = 0;
7151
+ let freeRamGB = 0;
7152
+ let nodeVersion = "unknown";
7153
+ try {
7154
+ platform = os.platform();
7155
+ } catch {
7156
+ }
7157
+ try {
7158
+ arch = os.arch();
7159
+ } catch {
7160
+ }
7161
+ try {
7162
+ const cpus = os.cpus();
7163
+ if (Array.isArray(cpus) && cpus.length) {
7164
+ cpuCount = cpus.length;
7165
+ const first = cpus[0];
7166
+ if (first && typeof first.model === "string" && first.model.trim()) {
7167
+ cpuModel = first.model.trim();
7168
+ }
7169
+ }
7170
+ } catch {
7171
+ }
7172
+ try {
7173
+ totalRamGB = toGB(os.totalmem());
7174
+ } catch {
7175
+ }
7176
+ try {
7177
+ freeRamGB = toGB(os.freemem());
7178
+ } catch {
7179
+ }
7180
+ try {
7181
+ if (typeof process.version === "string" && process.version) nodeVersion = process.version;
7182
+ } catch {
7183
+ }
7184
+ return { platform, arch, cpuModel, cpuCount, totalRamGB, freeRamGB, nodeVersion };
7185
+ }
7186
+ var LOCAL_MODEL_RECS = [
7187
+ {
7188
+ minRamGB: 48,
7189
+ rec: {
7190
+ local: true,
7191
+ model: "qwen2.5:32b",
7192
+ size: "~20GB",
7193
+ note: "Plenty of headroom. A 32B model runs comfortably and gives the best local quality."
7194
+ }
7195
+ },
7196
+ {
7197
+ minRamGB: 24,
7198
+ rec: {
7199
+ local: true,
7200
+ model: "qwen2.5:14b",
7201
+ size: "~9GB",
7202
+ note: "A 14B model fits well here and is a solid local maintainer."
7203
+ }
7204
+ },
7205
+ {
7206
+ minRamGB: 16,
7207
+ rec: {
7208
+ local: true,
7209
+ model: "qwen2.5:7b",
7210
+ alt: "llama3.1:8b",
7211
+ size: "~4.7GB",
7212
+ note: "A 7B model works but is tight alongside the embed model and your editor. Prefer running it on your always-on machine."
7213
+ }
7214
+ },
7215
+ {
7216
+ minRamGB: 0,
7217
+ rec: {
7218
+ // Discouraged below 16GB: local generation is too modest to be worth it
7219
+ // for the wiki maintainer. The small model stays named as the only viable
7220
+ // option if you insist, but local is off by default here.
7221
+ local: false,
7222
+ model: "llama3.2:3b",
7223
+ size: "~2GB",
7224
+ note: 'Under 16GB, local generation is modest. A small 3B model is the safe ceiling; the hosted "brain" maintainer will be noticeably better, so it is the recommendation here.'
7225
+ }
7226
+ }
7227
+ ];
7228
+ function recommendLocalModel2(totalRamGB) {
7229
+ const ram = Number.isFinite(totalRamGB) && totalRamGB > 0 ? totalRamGB : 0;
7230
+ for (const tier of LOCAL_MODEL_RECS) {
7231
+ if (ram >= tier.minRamGB) return tier.rec;
7232
+ }
7233
+ return {
7234
+ local: false,
7235
+ model: null,
7236
+ note: 'Could not read RAM. Use the hosted "brain" maintainer to be safe.'
7237
+ };
7238
+ }
7239
+
7240
+ // src/commands/doctor.ts
7241
+ var OLLAMA_URL3 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
7242
+ function header(title) {
7243
+ console.log("\n" + c.accent(title));
7244
+ }
7245
+ function row(label, value) {
7246
+ console.log(` ${label.padEnd(12)}${value}`);
7247
+ }
7248
+ function note(text) {
7249
+ console.log(" " + c.dim(text));
7250
+ }
7251
+ function good(text) {
7252
+ console.log(" " + c.green(text));
7253
+ }
7254
+ function warn(text) {
7255
+ console.log(" " + c.red(text));
7256
+ }
7257
+ async function doctor() {
7258
+ const specs = detect();
7259
+ const steps = [];
7260
+ console.log("\n" + c.bold("Holt doctor") + c.dim(" a look at this machine and how best to run Holt on it"));
7261
+ header("Machine");
7262
+ row("platform", `${specs.platform} / ${specs.arch}`);
7263
+ row("cpu", `${specs.cpuModel}${specs.cpuCount ? ` (${specs.cpuCount} cores)` : ""}`);
7264
+ row(
7265
+ "ram",
7266
+ specs.totalRamGB ? `${specs.totalRamGB} GB total, ${specs.freeRamGB} GB free` : "unknown"
7267
+ );
7268
+ row("node", specs.nodeVersion);
7269
+ header("Brains");
7270
+ const installed = [];
7271
+ for (const id of BRAIN_IDS) {
7272
+ const def = BRAIN_DEFS[id];
7273
+ const here = isInstalled(def.command);
7274
+ if (here) installed.push(def.label);
7275
+ console.log(` ${here ? c.green("installed") : c.dim("missing ")} ${def.label} ${c.dim(`(${def.command})`)}`);
7276
+ }
7277
+ const cfg = loadConfig();
7278
+ const apiBrains = cfg?.apiBrains ?? [];
7279
+ if (apiBrains.length) {
7280
+ note(`API brains configured here: ${apiBrains.map((b) => b.id).join(", ")}`);
7281
+ }
7282
+ if (installed.length) {
7283
+ good(`Recommendation: use an installed CLI brain (${installed[0]}). No API keys needed.`);
7284
+ } else if (apiBrains.length) {
7285
+ good("Recommendation: no CLI brain installed, but you have an API brain configured. That works.");
7286
+ } else {
7287
+ warn("Recommendation: no brain available. Install one CLI brain (claude, codex, or gemini) or add an API brain.");
7288
+ steps.push('Install a brain: run "holt init" (or add an API brain in "holt setting").');
7289
+ }
7290
+ header("Semantic memory");
7291
+ let embedOk = false;
7292
+ try {
7293
+ embedOk = await embeddingsAvailable();
7294
+ } catch {
7295
+ embedOk = false;
7296
+ }
7297
+ if (embedOk) {
7298
+ good(`Ollama reachable and "${EMBED_MODEL}" is present. Memory recall is semantic, private, and local.`);
7299
+ } else {
7300
+ note(`Ollama with "${EMBED_MODEL}" not reachable at ${OLLAMA_URL3}.`);
7301
+ note("Without it, Holt still remembers, but recall falls back to keyword match.");
7302
+ warn(`Recommendation: enable local embeddings. Install Ollama, then: ollama pull ${EMBED_MODEL}`);
7303
+ steps.push(`Enable semantic memory: ollama pull ${EMBED_MODEL}`);
7304
+ }
7305
+ header("Knowledge wiki maintainer");
7306
+ const rec = recommendLocalModel2(specs.totalRamGB);
7307
+ note('Holt can keep your knowledge wiki with a hosted "brain" (rides your Claude plan) or a local model.');
7308
+ good('Default recommendation: "brain" (best quality, no extra RAM, uses the brain you already have).');
7309
+ if (rec.local && rec.model) {
7310
+ const alt = rec.alt ? ` (or ${rec.alt})` : "";
7311
+ console.log(
7312
+ " " + c.cyan("local option: ") + `${rec.model}${alt}${rec.size ? c.dim(` ${rec.size}`) : ""} for this ${specs.totalRamGB || "?"} GB machine.`
7313
+ );
7314
+ note(rec.note);
7315
+ note(`To use it locally: ollama pull ${rec.model}`);
7316
+ } else {
7317
+ note(rec.note);
7318
+ if (rec.model) note(`If you still want local: ollama pull ${rec.model} (${rec.size ?? "small"}).`);
7319
+ }
7320
+ header("Always-on / Telegram");
7321
+ const tg = loadTelegramConfig();
7322
+ if (tg) {
7323
+ good("Telegram bot is configured. You can chat with Holt from your phone.");
7324
+ } else {
7325
+ note("No Telegram bot configured. Set one up to reach Holt from your phone: holt telegram setup");
7326
+ }
7327
+ note("Hosting tip: a low-power always-on machine (an old laptop, a Pi) is ideal for the bot and scheduled runs.");
7328
+ note("Heavy local models want more RAM, so keep those on your bigger machine and let the small box relay.");
7329
+ header("Recommended next steps");
7330
+ if (!cfg) {
7331
+ steps.unshift('Set up this folder: run "holt init".');
7332
+ }
7333
+ if (steps.length === 0) {
7334
+ good('You are in good shape. Run "holt chat" to start.');
7335
+ } else {
7336
+ let n = 1;
7337
+ for (const s of steps) console.log(` ${c.accent(String(n++) + ".")} ${s}`);
7338
+ }
7339
+ console.log("");
7340
+ }
7341
+
7342
+ // src/commands/write.ts
7343
+ import { writeFileSync as writeFileSync18 } from "fs";
7344
+ import { resolve as resolve7 } from "path";
7345
+
7346
+ // src/antiai.ts
7347
+ var EM_DASH = String.fromCharCode(8212);
7348
+ var ANTI_AI_RULES = [
7349
+ "No em-dashes anywhere. Use a comma, colon, period, or parentheses instead.",
7350
+ 'No "rule of three" tricolons or parallel three-beat lists for drama.',
7351
+ `No "in today's fast-paced world" or "in an era of" style openers.`,
7352
+ 'No "it is not X, it is Y" or "not just X but Y" reframes.',
7353
+ "No tidy bow-tie conclusion that restates the thesis in one neat takeaway.",
7354
+ "No callback closer where the last line echoes or inverts the opening.",
7355
+ "Vary sentence length. Mix short and long. Avoid uniform rhythm.",
7356
+ "Prefer concrete specifics, names, and numbers over abstractions.",
7357
+ 'Cut hedging filler ("it is worth noting", "needless to say", "the truth is").',
7358
+ "Avoid corporate buzzwords (leverage, unlock, seamless, robust, holistic, synergy, elevate, streamline, empower, game-changer).",
7359
+ "Avoid performed emotion (thrilled, humbled, honored, excited to share).",
7360
+ "No emoji unless the voice profile explicitly allows it.",
7361
+ "No ALL CAPS for emphasis and no bold on adjectives or adverbs.",
7362
+ "Leave at least one thought slightly open. Do not resolve everything neatly.",
7363
+ "Write like a person talking to one smart reader, not a press release."
7364
+ ];
7365
+ function antiAiPromptBlock() {
7366
+ return [
7367
+ "ANTI-AI WRITING RULES (follow all, they matter):",
7368
+ ...ANTI_AI_RULES.map((r) => `- ${r}`),
7369
+ "",
7370
+ "The single hardest rule: the output must contain zero em-dash characters.",
7371
+ "Write plainly. If a sentence sounds like a LinkedIn influencer or a brochure, rewrite it."
7372
+ ].join("\n");
7373
+ }
7374
+ function antiAiCheckInstruction() {
7375
+ return [
7376
+ "Review the draft below against these anti-AI rules and the voice profile.",
7377
+ "Rewrite any sentence that violates them. Keep the meaning and length similar.",
7378
+ "Remove every em-dash. Output ONLY the corrected draft, no commentary.",
7379
+ "",
7380
+ antiAiPromptBlock()
7381
+ ].join("\n");
7382
+ }
7383
+ function scanAntiAi(text) {
7384
+ const flags = [];
7385
+ const t = text || "";
7386
+ if (t.includes(EM_DASH)) {
7387
+ flags.push({ rule: "no-em-dash", detail: "contains an em-dash character" });
7388
+ }
7389
+ const openers = [
7390
+ /in today'?s (fast[- ]paced|ever[- ]changing) world/i,
7391
+ /in an era of/i,
7392
+ /without further ado/i,
7393
+ /let'?s (dive|get started)/i
7394
+ ];
7395
+ for (const re of openers) {
7396
+ if (re.test(t)) flags.push({ rule: "stock-opener", detail: `matches ${re.source}` });
7397
+ }
7398
+ const buzz = ["leverage", "seamless", "seamlessly", "unlock", "game-changer", "synergy", "holistic", "elevate", "empower", "streamline"];
7399
+ for (const w of buzz) {
7400
+ const re = new RegExp(`\\b${w.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\b`, "i");
7401
+ if (re.test(t)) flags.push({ rule: "buzzword", detail: `uses "${w}"` });
7402
+ }
7403
+ return flags;
7404
+ }
7405
+ function stripEmDash(text) {
7406
+ return (text || "").split(EM_DASH).join(", ");
7407
+ }
7408
+
7409
+ // src/commands/write.ts
7410
+ var TYPES = ["linkedin", "email", "tweet", "blog", "generic"];
7411
+ var TYPE_SHAPE = {
7412
+ linkedin: "Format: a LinkedIn post. Keep it tight, a few short paragraphs. No hashtag wall.",
7413
+ email: "Format: an email. Include a subject line, then a short body. Plain and direct.",
7414
+ tweet: "Format: a single short post under 280 characters. One clear idea.",
7415
+ blog: 'Format: a short blog post with a title and a few paragraphs. No "conclusion" header.',
7416
+ generic: "Format: whatever best fits the request. Keep it concise unless asked otherwise."
7417
+ };
7418
+ var USAGE4 = [
7419
+ ' holt write "<what to write>" [options]',
7420
+ "",
7421
+ " Draft content in your saved writing voice, with anti-AI checks.",
7422
+ "",
7423
+ " options:",
7424
+ " --type <t> linkedin | email | tweet | blog | generic (default generic)",
7425
+ " --out <file> also write the draft to a file (relative to this folder)",
7426
+ " --brain <id> use a specific brain (CLI or API brain id)",
7427
+ " --fast skip the anti-AI self-check second pass",
7428
+ "",
7429
+ ' example: holt write "a linkedin post about shipping Holt" --type linkedin',
7430
+ "",
7431
+ ' Tip: build your voice first with "holt voice". Without it, a plain voice is used.'
7432
+ ].join("\n");
7433
+ function parseArgs3(args) {
7434
+ const words2 = [];
7435
+ let type = "generic";
7436
+ let out;
7437
+ let brainId;
7438
+ let fast = false;
7439
+ for (let i = 0; i < args.length; i++) {
7440
+ const a = args[i];
7441
+ if (a === "--type") {
7442
+ const t = (args[++i] ?? "").toLowerCase();
7443
+ if (TYPES.includes(t)) type = t;
7444
+ continue;
7445
+ }
7446
+ if (a === "--out") {
7447
+ out = args[++i];
7448
+ continue;
7449
+ }
7450
+ if (a === "--brain") {
7451
+ brainId = args[++i];
7452
+ continue;
7453
+ }
7454
+ if (a === "--fast") {
7455
+ fast = true;
7456
+ continue;
7457
+ }
7458
+ words2.push(a);
7459
+ }
7460
+ return { task: words2.join(" ").trim(), type, out, brainId, fast };
7461
+ }
7462
+ function buildWritePrompt(p, voiceBlock) {
7463
+ const parts = [antiAiPromptBlock(), ""];
7464
+ if (voiceBlock) {
7465
+ parts.push(voiceBlock, "");
7466
+ } else {
7467
+ parts.push("No saved voice profile. Write in a plain, natural, first-person voice.", "");
7468
+ }
7469
+ parts.push(TYPE_SHAPE[p.type], "");
7470
+ parts.push("Write the following. Output ONLY the finished piece, no preamble and no notes:");
7471
+ parts.push(p.task);
7472
+ return parts.join("\n");
7473
+ }
7474
+ async function write(args) {
7475
+ const p = parseArgs3(args);
7476
+ if (!p.task) {
7477
+ console.log(USAGE4);
7478
+ return;
7479
+ }
7480
+ if (!isTrusted()) {
7481
+ if (process.stdin.isTTY) {
7482
+ const { ask, close } = createReader();
7483
+ const ok2 = await ensureTrusted(ask);
7484
+ close();
7485
+ if (!ok2) return;
7486
+ } else {
7487
+ process.stderr.write("This folder is not trusted. Run holt init or holt chat here once first.\n");
7488
+ process.exitCode = 1;
7489
+ return;
7490
+ }
7491
+ }
7492
+ const v = loadVoice();
7493
+ const voiceBlock = voicePromptBlock(v);
7494
+ if (!v || !v.style && v.samples.length === 0 && v.answers.every((a) => !a.answer.trim())) {
7495
+ process.stderr.write(c.dim('No voice profile found, using a plain voice. Build one with "holt voice".') + "\n");
7496
+ }
7497
+ const prompt = buildWritePrompt(p, voiceBlock);
7498
+ const gen = await runTask(prompt, { brainId: p.brainId, recall: false, store: false });
7499
+ if (!gen.ok) {
7500
+ process.stderr.write(c.red(gen.text) + "\n");
7501
+ process.exitCode = 1;
7502
+ return;
7503
+ }
7504
+ let draft = gen.text;
7505
+ if (!p.fast) {
7506
+ const flagsBefore = scanAntiAi(draft);
7507
+ const checkPrompt = [
7508
+ antiAiCheckInstruction(),
7509
+ voiceBlock ? "\n" + voiceBlock : "",
7510
+ "\nDRAFT:",
7511
+ draft
7512
+ ].join("\n");
7513
+ const checked = await runTask(checkPrompt, { brainId: p.brainId, recall: false, store: false });
7514
+ if (checked.ok && checked.text.trim()) {
7515
+ const flagsAfter = scanAntiAi(checked.text);
7516
+ if (flagsAfter.length <= flagsBefore.length) draft = checked.text;
7517
+ }
7518
+ }
7519
+ draft = stripEmDash(draft).trimEnd();
7520
+ process.stdout.write(draft + "\n");
7521
+ if (p.out) {
7522
+ const target = resolve7(process.cwd(), p.out);
7523
+ try {
7524
+ writeFileSync18(target, draft + "\n", "utf8");
7525
+ process.stderr.write(c.dim(`saved to ${target}`) + "\n");
7526
+ } catch (e) {
7527
+ process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
7528
+ process.exitCode = 1;
7529
+ }
7530
+ }
7531
+ }
7532
+
3853
7533
  // src/cli.ts
3854
7534
  var BANNER = `
3855
7535
  \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557
@@ -3868,12 +7548,18 @@ Commands:
3868
7548
  chat Start a session. It remembers past sessions in this folder
3869
7549
  run <task> Run one task non-interactively: recall, brain executes, remember
3870
7550
  schedule Fire "holt run" on a timer: holt schedule [add | list | remove]
7551
+ routine Named, reusable, scheduled jobs: holt routine [add | run | list | remove]
3871
7552
  telegram Chat with Holt from your phone: holt telegram [setup]
3872
7553
  notify [msg] Push a message to your phone over Telegram (stdin-friendly)
7554
+ voice Teach Holt how you write: holt voice [add <file> | show | edit | clear]
7555
+ write <what> Draft content in your voice, with anti-AI checks: holt write "..." [--type]
3873
7556
  memory Inspect memory: holt memory [search <query> | embed | clear]
7557
+ wiki Your derived knowledge wiki: holt wiki [sync | rebuild | lint | list | show]
3874
7558
  graph See your memory as an interactive knowledge graph in the browser
3875
7559
  skill Manage skills: holt skill [list | show | create | add | remove]
7560
+ doctor Check this machine and recommend how best to run Holt here
3876
7561
  mcp Serve this folder's memory to Claude Code, Cursor, Codex (holt mcp setup)
7562
+ hook Ambient memory for Claude Code: holt hook [install | remove | status]
3877
7563
  setting Configure brains, API brains, and your launch command (per folder)
3878
7564
  login <brain> Sign in to a brain: claude, codex, or gemini
3879
7565
  version Print the Holt version
@@ -3916,6 +7602,9 @@ async function main() {
3916
7602
  case "memory":
3917
7603
  await memoryCmd(process.argv[3], process.argv.slice(4));
3918
7604
  break;
7605
+ case "wiki":
7606
+ await wikiCmd(process.argv[3], process.argv.slice(4));
7607
+ break;
3919
7608
  case "skill":
3920
7609
  case "skills":
3921
7610
  await skillCmd(process.argv[3], process.argv.slice(4));
@@ -3926,18 +7615,33 @@ async function main() {
3926
7615
  case "mcp":
3927
7616
  await mcp(process.argv[3], process.argv.slice(4));
3928
7617
  break;
7618
+ case "hook":
7619
+ await hook(process.argv[3], process.argv.slice(4));
7620
+ break;
3929
7621
  case "run":
3930
7622
  await run(process.argv.slice(3));
3931
7623
  break;
3932
7624
  case "schedule":
3933
7625
  await schedule(process.argv[3], process.argv.slice(4));
3934
7626
  break;
7627
+ case "routine":
7628
+ await routine(process.argv[3], process.argv.slice(4));
7629
+ break;
3935
7630
  case "telegram":
3936
7631
  await telegram(process.argv[3], process.argv.slice(4));
3937
7632
  break;
3938
7633
  case "notify":
3939
7634
  await notify(process.argv.slice(3));
3940
7635
  break;
7636
+ case "doctor":
7637
+ await doctor();
7638
+ break;
7639
+ case "voice":
7640
+ await voice(process.argv[3], process.argv.slice(4));
7641
+ break;
7642
+ case "write":
7643
+ await write(process.argv.slice(3));
7644
+ break;
3941
7645
  default:
3942
7646
  console.error(`
3943
7647
  Unknown command: "${cmd}"`);