@holt-os/holt 0.8.4 → 0.9.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 +181 -2
  2. package/dist/cli.js +2944 -224
  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((resolve7) => {
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) resolve7(buffer.shift());
47
+ else if (closed) resolve7(null);
48
+ else waiters.push(resolve7);
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((resolve7) => {
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
+ resolve7({ 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) => resolve7({ 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) resolve7({ ok: true, text });
268
+ else resolve7({ 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((resolve7) => {
423
439
  let child;
424
440
  try {
425
441
  child = spawn2(cmd, args, { stdio: "inherit" });
426
442
  } catch {
427
- resolve4(-1);
443
+ resolve7(-1);
428
444
  return;
429
445
  }
430
- child.on("error", () => resolve4(-1));
431
- child.on("close", (code) => resolve4(code ?? -1));
446
+ child.on("error", () => resolve7(-1));
447
+ child.on("close", (code) => resolve7(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
@@ -1606,8 +2225,8 @@ async function extractAndSaveFacts(active, cfg, history, session) {
1606
2225
  }
1607
2226
 
1608
2227
  // src/output.ts
1609
- import { writeFileSync as writeFileSync6 } from "fs";
1610
- import { join as join7, isAbsolute } from "path";
2228
+ import { writeFileSync as writeFileSync7 } from "fs";
2229
+ import { join as join8, isAbsolute } from "path";
1611
2230
  function escapeHtml(s) {
1612
2231
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1613
2232
  }
@@ -1747,8 +2366,8 @@ function saveReply(reply, format, name) {
1747
2366
  throw new Error('name must be a plain file name in this folder (no "/", "\\", or "..").');
1748
2367
  }
1749
2368
  if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
1750
- const path = join7(process.cwd(), base);
1751
- writeFileSync6(path, renderReply(reply, format), "utf8");
2369
+ const path = join8(process.cwd(), base);
2370
+ writeFileSync7(path, renderReply(reply, format), "utf8");
1752
2371
  return path;
1753
2372
  }
1754
2373
 
@@ -2008,7 +2627,8 @@ async function login(which) {
2008
2627
  }
2009
2628
 
2010
2629
  // src/commands/memory.ts
2011
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
2630
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
2631
+ import { basename } from "path";
2012
2632
  async function memoryCmd(sub, rest = []) {
2013
2633
  const { ask, close } = createReader();
2014
2634
  if (!await ensureTrusted(ask)) {
@@ -2055,12 +2675,12 @@ async function memoryCmd(sub, rest = []) {
2055
2675
  return;
2056
2676
  }
2057
2677
  if (action === "facts") {
2058
- if (!existsSync7(factsMdPath())) {
2678
+ if (!existsSync8(factsMdPath())) {
2059
2679
  console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
2060
2680
  close();
2061
2681
  return;
2062
2682
  }
2063
- const body = readFileSync7(factsMdPath(), "utf8").trim();
2683
+ const body = readFileSync9(factsMdPath(), "utf8").trim();
2064
2684
  console.log("\n" + body + "\n");
2065
2685
  close();
2066
2686
  return;
@@ -2077,15 +2697,23 @@ async function memoryCmd(sub, rest = []) {
2077
2697
  if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
2078
2698
  else for (const h of hits) {
2079
2699
  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, " ")}`);
2700
+ const src = h.workspace ? c.dim(` [${basename(h.workspace)}]`) : "";
2701
+ console.log(
2702
+ ` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role})${src} ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`
2703
+ );
2081
2704
  }
2082
2705
  console.log("");
2083
2706
  close();
2084
2707
  return;
2085
2708
  }
2086
- if (action && !["clear", "embed", "facts", "search"].includes(action)) {
2709
+ if (action === "global") {
2710
+ globalCmd((rest[0] || "").toLowerCase(), rest.slice(1));
2711
+ close();
2712
+ return;
2713
+ }
2714
+ if (action && !["clear", "embed", "facts", "search", "global"].includes(action)) {
2087
2715
  console.error(`
2088
- Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | clear
2716
+ Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | global | clear
2089
2717
  `);
2090
2718
  process.exitCode = 1;
2091
2719
  close();
@@ -2105,49 +2733,848 @@ async function memoryCmd(sub, rest = []) {
2105
2733
  console.log(c.dim(`
2106
2734
  ${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
2107
2735
  }
2736
+ const gEnabled = isGlobalEnabled(workspace());
2737
+ console.log(` global ${gEnabled ? c.green("on") + c.dim(" (contributes + reads shared facts)") : c.dim("off (this folder is isolated, the default)")}`);
2108
2738
  console.log(c.dim("\n holt memory search <query> find remembered moments"));
2109
2739
  console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
2110
2740
  console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
2741
+ console.log(c.dim(" holt memory global share high-value facts across your folders"));
2111
2742
  console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
2112
2743
  close();
2113
2744
  }
2745
+ function globalCmd(act, rest) {
2746
+ const ws = workspace();
2747
+ if (act === "on") {
2748
+ const already = isGlobalEnabled(ws);
2749
+ const added = enableGlobal(ws);
2750
+ const g = globalStats();
2751
+ if (already) console.log("\n" + c.green(" Global memory already on for this folder."));
2752
+ else console.log("\n" + c.green(" Global memory ON for this folder."));
2753
+ console.log(c.dim(` Backfilled ${added} fact${added === 1 ? "" : "s"} into the shared store.`));
2754
+ console.log(c.dim(` New facts here mirror automatically; recall now spans ${g.workspaces} contributing folder${g.workspaces === 1 ? "" : "s"}.
2755
+ `));
2756
+ return;
2757
+ }
2758
+ if (act === "off") {
2759
+ const purge = rest.includes("--purge");
2760
+ if (!isGlobalEnabled(ws) && !purge) {
2761
+ console.log("\n" + c.dim(" Global memory is already off for this folder.\n"));
2762
+ return;
2763
+ }
2764
+ const { purged } = disableGlobal(ws, purge);
2765
+ console.log("\n" + c.green(" Global memory OFF for this folder.") + c.dim(" (recall is local-only again)"));
2766
+ if (purge) console.log(c.dim(` Purged ${purged} of this folder's row${purged === 1 ? "" : "s"} from the shared store.`));
2767
+ else console.log(c.dim(" This folder's rows stay in the shared store. Re-run with --purge to remove them."));
2768
+ console.log("");
2769
+ return;
2770
+ }
2771
+ if (act === "" || act === "status") {
2772
+ const enabled = isGlobalEnabled(ws);
2773
+ const g = globalStats();
2774
+ const contributors = globalWorkspaces();
2775
+ console.log("\n" + c.accent("Holt global memory") + c.dim(" (opt-in, shared facts across your folders)"));
2776
+ console.log(` this folder ${enabled ? c.green("on") : c.dim("off (isolated, the default)")}`);
2777
+ console.log(` contributing ${g.workspaces} folder${g.workspaces === 1 ? "" : "s"}`);
2778
+ console.log(` shared facts ${g.facts}`);
2779
+ console.log(` size ${(g.bytes / 1024).toFixed(1)} KB (${globalMemPath()})`);
2780
+ console.log(` registry ${memoryScopesPath()}`);
2781
+ if (contributors.length) {
2782
+ console.log(c.dim("\n Folders sharing facts:"));
2783
+ for (const w of contributors) console.log(c.dim(` ${basename(w)} ${w === ws ? "(this folder)" : w}`));
2784
+ }
2785
+ console.log(c.dim("\n holt memory global on share this folder's facts + read others"));
2786
+ console.log(c.dim(" holt memory global off stop sharing + reading (add --purge to delete rows)"));
2787
+ console.log(c.dim(" holt memory global status this view\n"));
2788
+ return;
2789
+ }
2790
+ console.error(`
2791
+ Unknown "memory global" action: "${act}". Use: on | off [--purge] | status
2792
+ `);
2793
+ process.exitCode = 1;
2794
+ }
2114
2795
 
2115
- // src/commands/skill.ts
2796
+ // src/commands/wiki.ts
2797
+ import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
2798
+ import { join as join10 } from "path";
2799
+ import { spawn as spawn3 } from "child_process";
2800
+
2801
+ // src/localmodel.ts
2802
+ var OLLAMA_URL2 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
2803
+ async function localModelStatus(model) {
2804
+ try {
2805
+ const res = await fetch(`${OLLAMA_URL2}/api/tags`, { signal: AbortSignal.timeout(1500) });
2806
+ if (!res.ok) return { reachable: false, hasModel: false };
2807
+ const data = await res.json();
2808
+ const want = model.includes(":") ? model : `${model}:latest`;
2809
+ const hasModel = !!data.models?.some((m) => {
2810
+ const n = m.name || "";
2811
+ return n === model || n === want || n.startsWith(`${model}:`);
2812
+ });
2813
+ return { reachable: true, hasModel };
2814
+ } catch {
2815
+ return { reachable: false, hasModel: false };
2816
+ }
2817
+ }
2818
+ async function localGenerate(model, prompt, timeoutMs = 12e4) {
2819
+ let res;
2820
+ try {
2821
+ res = await fetch(`${OLLAMA_URL2}/api/generate`, {
2822
+ method: "POST",
2823
+ headers: { "content-type": "application/json" },
2824
+ body: JSON.stringify({ model, prompt, stream: false }),
2825
+ signal: AbortSignal.timeout(timeoutMs)
2826
+ });
2827
+ } catch (e) {
2828
+ return { ok: false, text: `Could not reach local Ollama at ${OLLAMA_URL2}: ${e.message}` };
2829
+ }
2830
+ if (!res.ok) {
2831
+ let body = "";
2832
+ try {
2833
+ body = await res.text();
2834
+ } catch {
2835
+ }
2836
+ return { ok: false, text: `Ollama error ${res.status}: ${body.slice(0, 200)}` };
2837
+ }
2838
+ try {
2839
+ const data = await res.json();
2840
+ const text = (data.response || "").trim();
2841
+ if (text) return { ok: true, text };
2842
+ return { ok: false, text: "Local model returned an empty reply." };
2843
+ } catch (e) {
2844
+ return { ok: false, text: `Could not parse Ollama reply: ${e.message}` };
2845
+ }
2846
+ }
2847
+ function pullHint(model) {
2848
+ return `ollama pull ${model}`;
2849
+ }
2850
+
2851
+ // src/wiki.ts
2116
2852
  import {
2117
- mkdirSync as mkdirSync6,
2118
- writeFileSync as writeFileSync7,
2119
- readFileSync as readFileSync8,
2120
- existsSync as existsSync8,
2853
+ readFileSync as readFileSync10,
2854
+ writeFileSync as writeFileSync8,
2855
+ existsSync as existsSync9,
2856
+ mkdirSync as mkdirSync7,
2857
+ rmSync as rmSync4,
2121
2858
  readdirSync as readdirSync2,
2122
- statSync as statSync3,
2123
- rmSync as rmSync3,
2124
- cpSync,
2125
- mkdtempSync
2859
+ statSync as statSync3
2126
2860
  } 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.",
2143
- ""
2144
- ].join("\n")));
2861
+ import { join as join9 } from "path";
2862
+ import { totalmem } from "os";
2863
+ function wikiDir() {
2864
+ return join9(wsHoltDir(), "wiki");
2145
2865
  }
2146
- var SKILL_TEMPLATE = (name) => [
2147
- "---",
2148
- `name: ${name}`,
2149
- "description: One sentence on what this skill does and when to use it.",
2150
- "---",
2866
+ function wikiStatePath() {
2867
+ return join9(wikiDir(), ".state.json");
2868
+ }
2869
+ function wikiIndexPath() {
2870
+ return join9(wikiDir(), "index.md");
2871
+ }
2872
+ function pagePath(slug) {
2873
+ return join9(wikiDir(), `${slug}.md`);
2874
+ }
2875
+ function loadState() {
2876
+ try {
2877
+ const s = JSON.parse(readFileSync10(wikiStatePath(), "utf8"));
2878
+ return { lastSyncTs: typeof s.lastSyncTs === "number" ? s.lastSyncTs : 0, updatedAt: s.updatedAt ?? 0 };
2879
+ } catch {
2880
+ return { lastSyncTs: 0, updatedAt: 0 };
2881
+ }
2882
+ }
2883
+ function saveState(s) {
2884
+ mkdirSync7(wikiDir(), { recursive: true });
2885
+ writeFileSync8(wikiStatePath(), JSON.stringify(s, null, 2) + "\n", "utf8");
2886
+ }
2887
+ function saveSyncMarker(ts) {
2888
+ saveState({ lastSyncTs: ts, updatedAt: Date.now() });
2889
+ }
2890
+ function isPageFile(name) {
2891
+ return name.endsWith(".md") && name !== "index.md";
2892
+ }
2893
+ function listPageSlugs() {
2894
+ const dir = wikiDir();
2895
+ if (!existsSync9(dir)) return [];
2896
+ try {
2897
+ return readdirSync2(dir).filter(isPageFile).map((f) => f.slice(0, -3)).sort();
2898
+ } catch {
2899
+ return [];
2900
+ }
2901
+ }
2902
+ function readPage(slug) {
2903
+ const p = pagePath(slug);
2904
+ if (!existsSync9(p)) return null;
2905
+ let raw = "";
2906
+ try {
2907
+ raw = readFileSync10(p, "utf8");
2908
+ } catch {
2909
+ return null;
2910
+ }
2911
+ const { data, body } = parseFrontmatter(raw);
2912
+ return {
2913
+ slug,
2914
+ title: data.title || slug,
2915
+ updated: data.updated || "",
2916
+ sources: parseSources(data.sources),
2917
+ body
2918
+ };
2919
+ }
2920
+ function loadPages() {
2921
+ const out = [];
2922
+ for (const slug of listPageSlugs()) {
2923
+ const pg = readPage(slug);
2924
+ if (pg) out.push(pg);
2925
+ }
2926
+ return out;
2927
+ }
2928
+ function parseSources(raw) {
2929
+ if (!raw) return [];
2930
+ return raw.replace(/^\[|\]$/g, "").split(/[\s,]+/).map((s) => s.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
2931
+ }
2932
+ var PAGE_HEADER_NOTE = "Derived page, maintained by Holt. Safe to edit; a rebuild will regenerate it from facts.";
2933
+ function renderPage(pg) {
2934
+ const fm = [
2935
+ "---",
2936
+ `title: ${pg.title}`,
2937
+ `updated: ${pg.updated}`,
2938
+ `sources: ${pg.sources.join(", ")}`,
2939
+ "---",
2940
+ "",
2941
+ `> ${PAGE_HEADER_NOTE}`,
2942
+ "",
2943
+ pg.body.trim(),
2944
+ ""
2945
+ ].join("\n");
2946
+ return fm;
2947
+ }
2948
+ function writePage(pg) {
2949
+ mkdirSync7(wikiDir(), { recursive: true });
2950
+ writeFileSync8(pagePath(pg.slug), renderPage(pg), "utf8");
2951
+ }
2952
+ var WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
2953
+ function extractLinks(body) {
2954
+ const out = [];
2955
+ let m;
2956
+ WIKILINK_RE.lastIndex = 0;
2957
+ while ((m = WIKILINK_RE.exec(body)) !== null) {
2958
+ const target = (m[1] || "").split("|")[0].trim();
2959
+ if (target) out.push(target);
2960
+ }
2961
+ return out;
2962
+ }
2963
+ function cosine2(a, b) {
2964
+ let dot = 0;
2965
+ let na = 0;
2966
+ let nb = 0;
2967
+ const n = Math.min(a.length, b.length);
2968
+ for (let i = 0; i < n; i++) {
2969
+ const x = a[i];
2970
+ const y = b[i];
2971
+ dot += x * y;
2972
+ na += x * x;
2973
+ nb += y * y;
2974
+ }
2975
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
2976
+ }
2977
+ var ROUTE_SIMILARITY_THRESHOLD = 0.55;
2978
+ async function pageEmbeddings(pages) {
2979
+ const out = [];
2980
+ for (const page of pages) {
2981
+ const emb = await embed(`${page.title}
2982
+ ${page.body.slice(0, 1500)}`);
2983
+ if (emb) out.push({ page, emb });
2984
+ }
2985
+ return out;
2986
+ }
2987
+ async function routeFact(content, targets) {
2988
+ const e = await embed(content);
2989
+ if (!e) {
2990
+ return { kind: targets.length ? "existing" : "new", slug: targets[0]?.page.slug ?? "notes", score: 0 };
2991
+ }
2992
+ let best = null;
2993
+ let bestScore = -1;
2994
+ for (const t of targets) {
2995
+ const s = cosine2(e, t.emb);
2996
+ if (s > bestScore) {
2997
+ bestScore = s;
2998
+ best = t;
2999
+ }
3000
+ }
3001
+ if (best && bestScore >= ROUTE_SIMILARITY_THRESHOLD) {
3002
+ return { kind: "existing", slug: best.page.slug, score: bestScore };
3003
+ }
3004
+ return { kind: "new", slug: proposeSlug(content), score: bestScore < 0 ? 0 : bestScore };
3005
+ }
3006
+ function proposeSlug(content) {
3007
+ const words2 = content.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !ROUTE_STOPWORDS.has(w)).slice(0, 4);
3008
+ const slug = sanitizeName(words2.join("-"));
3009
+ return slug || "notes";
3010
+ }
3011
+ var ROUTE_STOPWORDS = /* @__PURE__ */ new Set([
3012
+ "the",
3013
+ "and",
3014
+ "that",
3015
+ "this",
3016
+ "with",
3017
+ "from",
3018
+ "for",
3019
+ "was",
3020
+ "are",
3021
+ "has",
3022
+ "have",
3023
+ "will",
3024
+ "user",
3025
+ "their",
3026
+ "they",
3027
+ "about",
3028
+ "into",
3029
+ "per",
3030
+ "via"
3031
+ ]);
3032
+ async function resolveMaintainer(cfg, resolveBrain) {
3033
+ if (cfg.wiki.maintainer === "local") {
3034
+ const model = cfg.wiki.localModel;
3035
+ const status2 = await localModelStatus(model);
3036
+ if (status2.reachable && status2.hasModel) {
3037
+ const m = { kind: "local", model, label: `local (${model})` };
3038
+ return { maintainer: m, report: { maintainer: m, fellBack: false } };
3039
+ }
3040
+ const why = status2.reachable ? `local model "${model}" is not pulled. Run: ${pullHint(model)}` : "local Ollama is not reachable.";
3041
+ const brain2 = resolveBrain();
3042
+ if (brain2) {
3043
+ return {
3044
+ maintainer: brain2,
3045
+ report: { maintainer: brain2, fellBack: true, note: `${why} Falling back to ${brain2.label}.` }
3046
+ };
3047
+ }
3048
+ return { maintainer: null, report: null };
3049
+ }
3050
+ const brain = resolveBrain();
3051
+ if (!brain) return { maintainer: null, report: null };
3052
+ return { maintainer: brain, report: { maintainer: brain, fellBack: false } };
3053
+ }
3054
+ async function runMaintainer(m, cfg, prompt) {
3055
+ if (m.kind === "cli") return runBrain(cfg.brains[m.id], prompt);
3056
+ if (m.kind === "api") return runApiBrain(m.brain, prompt);
3057
+ const r = await localGenerate(m.model, prompt);
3058
+ return { ok: r.ok, text: r.text };
3059
+ }
3060
+ function buildPagePrompt(title, existingBody, facts, otherTitles) {
3061
+ const lines = [
3062
+ `You maintain a personal knowledge wiki. Write or update ONE page titled "${title}".`,
3063
+ "Output ONLY the page body in Markdown. No frontmatter, no code fences, no preamble.",
3064
+ "",
3065
+ "Rules:",
3066
+ "- Synthesize the facts into concise, self-contained prose (a few short paragraphs or tight bullets).",
3067
+ "- Merge new facts into the existing text; do not just append. Resolve overlaps. Keep it current.",
3068
+ "- Do not invent anything beyond the facts given.",
3069
+ '- End with a line "## Related" followed by [[Wiki Page]] links to other pages that connect.',
3070
+ "- Use [[double-bracket]] links for any other page you reference."
3071
+ ];
3072
+ if (otherTitles.length) {
3073
+ lines.push("", "Other existing pages you may link to:", ...otherTitles.map((t) => `- ${t}`));
3074
+ }
3075
+ if (existingBody.trim()) {
3076
+ lines.push("", "Current page body:", existingBody.trim());
3077
+ }
3078
+ lines.push("", "New facts to integrate:", ...facts.map((f) => `- ${f}`), "", "Updated page body:");
3079
+ return lines.join("\n");
3080
+ }
3081
+ function renderIndex(pages) {
3082
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3083
+ const lines = [
3084
+ "---",
3085
+ "title: Wiki Index",
3086
+ `updated: ${today}`,
3087
+ "---",
3088
+ "",
3089
+ "# Wiki",
3090
+ "",
3091
+ "Derived knowledge for this folder. Pages are synthesized from `facts.md` and",
3092
+ "`turns.jsonl` and are regenerable with `holt wiki rebuild`. Open in Obsidian to",
3093
+ "browse the [[links]].",
3094
+ ""
3095
+ ];
3096
+ if (pages.length === 0) {
3097
+ lines.push("_No pages yet. Run `holt wiki sync` after chatting._", "");
3098
+ return lines.join("\n");
3099
+ }
3100
+ lines.push("## Pages", "");
3101
+ for (const pg of [...pages].sort((a, b) => a.title.localeCompare(b.title))) {
3102
+ const when = pg.updated ? ` _(updated ${pg.updated})_` : "";
3103
+ lines.push(`- [[${pg.title}]]${when}`);
3104
+ }
3105
+ lines.push("");
3106
+ return lines.join("\n");
3107
+ }
3108
+ function writeIndex(pages) {
3109
+ mkdirSync7(wikiDir(), { recursive: true });
3110
+ writeFileSync8(wikiIndexPath(), renderIndex(pages), "utf8");
3111
+ }
3112
+ var WIKI_SESSION = "wiki";
3113
+ function wikiRowId(slug) {
3114
+ return "wiki-" + sanitizeName(slug).slice(0, 24);
3115
+ }
3116
+ function dropWikiRows(slugs) {
3117
+ const keep = loadTurns().filter((t) => !(t.session === WIKI_SESSION && slugs.has(t.id.replace(/^wiki-/, ""))));
3118
+ return keep;
3119
+ }
3120
+ async function indexPagesForRecall(pages) {
3121
+ try {
3122
+ if (pages.length === 0) return;
3123
+ const slugs = new Set(pages.map((p) => sanitizeName(p.slug).slice(0, 24)));
3124
+ const kept = dropWikiRows(slugs);
3125
+ mkdirSync7(join9(wsHoltDir(), "memory"), { recursive: true });
3126
+ writeFileSync8(
3127
+ join9(wsHoltDir(), "memory", "turns.jsonl"),
3128
+ kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
3129
+ "utf8"
3130
+ );
3131
+ for (const pg of pages) {
3132
+ const content = `${pg.title}
3133
+ ${pg.body}`.slice(0, 4e3);
3134
+ appendTurn({
3135
+ id: wikiRowId(pg.slug),
3136
+ ts: Date.now(),
3137
+ session: WIKI_SESSION,
3138
+ role: "fact",
3139
+ content,
3140
+ emb: await embed(content) ?? void 0
3141
+ });
3142
+ }
3143
+ } catch {
3144
+ }
3145
+ }
3146
+ function clearWikiRecallRows() {
3147
+ try {
3148
+ const kept = loadTurns().filter((t) => t.session !== WIKI_SESSION);
3149
+ mkdirSync7(join9(wsHoltDir(), "memory"), { recursive: true });
3150
+ writeFileSync8(
3151
+ join9(wsHoltDir(), "memory", "turns.jsonl"),
3152
+ kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
3153
+ "utf8"
3154
+ );
3155
+ } catch {
3156
+ }
3157
+ }
3158
+ function wipeWiki() {
3159
+ if (existsSync9(wikiDir())) rmSync4(wikiDir(), { recursive: true, force: true });
3160
+ }
3161
+ function wikiStats() {
3162
+ const slugs = listPageSlugs();
3163
+ let bytes = 0;
3164
+ for (const slug of slugs) {
3165
+ try {
3166
+ bytes += statSync3(pagePath(slug)).size;
3167
+ } catch {
3168
+ }
3169
+ }
3170
+ return { pages: slugs.length, bytes, lastSyncTs: loadState().lastSyncTs };
3171
+ }
3172
+ function factRows() {
3173
+ return loadTurns().filter((t) => t.role === "fact" && t.session !== WIKI_SESSION).map((t) => ({ id: t.id, content: t.content, ts: t.ts }));
3174
+ }
3175
+ var RAM_RECOMMENDATIONS = [
3176
+ { minGiB: 48, model: "qwen2.5:32b", note: "plenty of headroom for a large local model (~20GB)." },
3177
+ { minGiB: 24, model: "qwen2.5:14b", note: "a 14B model (~9GB) fits comfortably." },
3178
+ { minGiB: 16, model: "qwen2.5:7b", note: "default. ~4.7GB; tight alongside the embed model + editor. An always-on machine is a better host. Alternative: llama3.1:8b." },
3179
+ { minGiB: 0, model: "llama3.2:3b", note: "under 16GB, local is discouraged; if you insist, this ~2GB model is modest quality. The brain maintainer is the better default." }
3180
+ ];
3181
+ function totalGiB() {
3182
+ return Math.round(totalmem() / 1024 ** 3 * 10) / 10;
3183
+ }
3184
+ function recommendLocalModel(gib = totalGiB()) {
3185
+ const tier = RAM_RECOMMENDATIONS.find((r) => gib >= r.minGiB) ?? RAM_RECOMMENDATIONS[RAM_RECOMMENDATIONS.length - 1];
3186
+ return {
3187
+ gib,
3188
+ model: tier.model,
3189
+ note: tier.note,
3190
+ pull: pullHint(tier.model),
3191
+ discourageLocal: gib < 16
3192
+ };
3193
+ }
3194
+
3195
+ // src/commands/wiki.ts
3196
+ function resolveBrainMaintainer(cfg) {
3197
+ const id = cfg.defaultBrain;
3198
+ if (!id) return null;
3199
+ if (BRAIN_IDS.includes(id)) {
3200
+ const b = cfg.brains[id];
3201
+ if (!isInstalled(b.command)) return null;
3202
+ return { kind: "cli", id, label: b.label };
3203
+ }
3204
+ const api = findApiBrain(cfg, id);
3205
+ if (api && resolveApiKey(api)) return { kind: "api", brain: api, label: `${id} (api: ${api.provider}/${api.model})` };
3206
+ return null;
3207
+ }
3208
+ function titleToSlug(title) {
3209
+ return proposeSlug(title);
3210
+ }
3211
+ async function foldFacts(cfg, maintainer, facts) {
3212
+ const summary = { created: 0, updated: 0, factsIntegrated: 0 };
3213
+ if (facts.length === 0) return summary;
3214
+ let pages = loadPages();
3215
+ const targets = await pageEmbeddings(pages);
3216
+ const groups = /* @__PURE__ */ new Map();
3217
+ for (const f of facts) {
3218
+ const decision = await routeFact(f.content, targets);
3219
+ let slug = decision.slug;
3220
+ let isNew = decision.kind === "new";
3221
+ const existingGroup = groups.get(slug);
3222
+ if (existingGroup) {
3223
+ existingGroup.facts.push(f);
3224
+ } else {
3225
+ if (isNew && listPageSlugs().includes(slug)) isNew = false;
3226
+ groups.set(slug, { slug, isNew, facts: [f] });
3227
+ }
3228
+ }
3229
+ const knownTitles = new Set(pages.map((p) => p.title));
3230
+ for (const group of groups.values()) {
3231
+ knownTitles.add(readPage(group.slug)?.title ?? deriveTitle(group.facts[0].content, group.slug));
3232
+ }
3233
+ const changed = [];
3234
+ for (const group of groups.values()) {
3235
+ const existing = readPage(group.slug);
3236
+ const title = existing?.title ?? deriveTitle(group.facts[0].content, group.slug);
3237
+ const prompt = buildPagePrompt(
3238
+ title,
3239
+ existing?.body ?? "",
3240
+ group.facts.map((f) => f.content),
3241
+ [...knownTitles].filter((t) => t !== title)
3242
+ );
3243
+ const res = await runMaintainer(maintainer, cfg, prompt);
3244
+ if (!res.ok || !res.text.trim()) {
3245
+ continue;
3246
+ }
3247
+ const body = stripFrontmatterEcho(res.text);
3248
+ const prevSources = existing?.sources ?? [];
3249
+ const page = {
3250
+ slug: group.slug,
3251
+ title,
3252
+ updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3253
+ sources: dedupe([...prevSources, ...group.facts.map((f) => f.id)]),
3254
+ body
3255
+ };
3256
+ writePage(page);
3257
+ changed.push(page);
3258
+ if (existing) summary.updated++;
3259
+ else summary.created++;
3260
+ summary.factsIntegrated += group.facts.length;
3261
+ }
3262
+ pages = loadPages();
3263
+ writeIndex(pages);
3264
+ await indexPagesForRecall(changed);
3265
+ return summary;
3266
+ }
3267
+ function dedupe(a) {
3268
+ return [...new Set(a)];
3269
+ }
3270
+ function deriveTitle(content, slug) {
3271
+ const fromSlug = slug.split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
3272
+ return fromSlug || content.slice(0, 40);
3273
+ }
3274
+ function stripFrontmatterEcho(text) {
3275
+ let s = text.trim();
3276
+ s = s.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
3277
+ if (s.startsWith("---")) {
3278
+ const end = s.indexOf("\n---", 3);
3279
+ if (end >= 0) s = s.slice(end + 4).trim();
3280
+ }
3281
+ return s;
3282
+ }
3283
+ async function syncCmd(cfg) {
3284
+ const state = loadState();
3285
+ const all = factRows();
3286
+ const fresh = all.filter((f) => f.ts > state.lastSyncTs);
3287
+ if (fresh.length === 0) {
3288
+ console.log(c.dim("\n No new facts since the last sync. Nothing to do.\n"));
3289
+ return;
3290
+ }
3291
+ const { maintainer, report } = await resolveMaintainer(cfg, () => resolveBrainMaintainer(cfg));
3292
+ if (!maintainer) {
3293
+ console.log(c.red("\n No maintainer available."));
3294
+ console.log(c.dim(" Configure a brain (holt init) or set wiki.maintainer to local with a pulled model.\n"));
3295
+ return;
3296
+ }
3297
+ if (report?.note) console.log(c.dim("\n " + report.note));
3298
+ console.log("\n" + c.dim(` Integrating ${fresh.length} new fact${fresh.length === 1 ? "" : "s"} via ${maintainer.label}...`));
3299
+ const summary = await foldFacts(cfg, maintainer, fresh);
3300
+ const newest = Math.max(state.lastSyncTs, ...fresh.map((f) => f.ts));
3301
+ saveSyncMarker(newest);
3302
+ console.log("\n" + c.accent("Wiki synced") + c.dim(" (this folder)"));
3303
+ console.log(` facts integrated ${summary.factsIntegrated}`);
3304
+ console.log(` pages created ${summary.created}`);
3305
+ console.log(` pages updated ${summary.updated}`);
3306
+ console.log(` maintainer ${maintainer.label}${report?.fellBack ? c.dim(" (fell back)") : ""}`);
3307
+ console.log(c.dim(`
3308
+ Wiki: ${wikiDir()} (open in Obsidian to browse [[links]])
3309
+ `));
3310
+ }
3311
+ async function rebuildCmd(cfg, ask) {
3312
+ const facts = factRows();
3313
+ if (facts.length === 0) {
3314
+ console.log(c.dim("\n No facts to build from yet. Chat first so facts distill, then sync.\n"));
3315
+ return;
3316
+ }
3317
+ const a = (await ask(`
3318
+ Wipe and regenerate the wiki from ${facts.length} facts? This discards hand-edits. [y/N] `) ?? "").trim().toLowerCase();
3319
+ if (a !== "y" && a !== "yes") {
3320
+ console.log(c.dim(" Kept.\n"));
3321
+ return;
3322
+ }
3323
+ const { maintainer, report } = await resolveMaintainer(cfg, () => resolveBrainMaintainer(cfg));
3324
+ if (!maintainer) {
3325
+ console.log(c.red("\n No maintainer available. Configure a brain or a local model first.\n"));
3326
+ return;
3327
+ }
3328
+ if (report?.note) console.log(c.dim(" " + report.note));
3329
+ wipeWiki();
3330
+ clearWikiRecallRows();
3331
+ console.log(c.dim(`
3332
+ Rebuilding from ${facts.length} facts via ${maintainer.label}...`));
3333
+ const summary = await foldFacts(cfg, maintainer, facts);
3334
+ saveSyncMarker(Math.max(0, ...facts.map((f) => f.ts)));
3335
+ console.log("\n" + c.accent("Wiki rebuilt"));
3336
+ console.log(` pages ${summary.created}`);
3337
+ console.log(` facts folded ${summary.factsIntegrated}`);
3338
+ console.log(c.dim(`
3339
+ Wiki: ${wikiDir()}
3340
+ `));
3341
+ }
3342
+ function buildLintPrompt(pages) {
3343
+ const lines = [
3344
+ "You are auditing a personal knowledge wiki for quality. Review the pages below.",
3345
+ "Report, concisely and as plain text (no code fences):",
3346
+ "- CONTRADICTIONS: statements across pages that conflict.",
3347
+ "- DUPLICATES: pages or passages that overlap and should merge.",
3348
+ "- GAPS: obvious missing links ([[...]]) or topics implied but not written.",
3349
+ "If a category is clean, say so in one line. Do not rewrite the pages.",
3350
+ "",
3351
+ "Pages:"
3352
+ ];
3353
+ for (const pg of pages) {
3354
+ lines.push("", `### ${pg.title} (${pg.slug})`, pg.body.slice(0, 1200));
3355
+ }
3356
+ return lines.join("\n");
3357
+ }
3358
+ async function lintCmd(cfg, fix) {
3359
+ const pages = loadPages();
3360
+ if (pages.length === 0) {
3361
+ console.log(c.dim('\n No pages to lint. Run "holt wiki sync" first.\n'));
3362
+ return;
3363
+ }
3364
+ const { maintainer, report } = await resolveMaintainer(cfg, () => resolveBrainMaintainer(cfg));
3365
+ if (!maintainer) {
3366
+ console.log(c.red("\n No maintainer available. Configure a brain or a local model first.\n"));
3367
+ return;
3368
+ }
3369
+ if (report?.note) console.log(c.dim("\n " + report.note));
3370
+ if (fix) {
3371
+ console.log(c.dim("\n Note: --fix can rewrite pages. Put .holt/wiki under git or back it up first."));
3372
+ console.log(c.dim(" For now, lint proposes only; applying edits is left to a future release. Showing the report.\n"));
3373
+ }
3374
+ console.log(c.dim(`
3375
+ Auditing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
3376
+ `));
3377
+ const res = await runMaintainer(maintainer, cfg, buildLintPrompt(pages));
3378
+ if (!res.ok || !res.text.trim()) {
3379
+ console.log(c.dim(" Lint could not produce a report (maintainer returned nothing).\n"));
3380
+ return;
3381
+ }
3382
+ console.log(res.text.trim() + "\n");
3383
+ }
3384
+ function listCmd() {
3385
+ const slugs = listPageSlugs();
3386
+ if (slugs.length === 0) {
3387
+ console.log(c.dim('\n No wiki pages yet. Run "holt wiki sync" after chatting.\n'));
3388
+ return;
3389
+ }
3390
+ console.log("\n" + c.accent("Wiki pages") + c.dim(" (this folder)"));
3391
+ for (const slug of slugs) {
3392
+ const pg = readPage(slug);
3393
+ if (!pg) continue;
3394
+ const size = existsSync10(join10(wikiDir(), slug + ".md")) ? (readFileSync11(join10(wikiDir(), slug + ".md"), "utf8").length / 1024).toFixed(1) : "0.0";
3395
+ console.log(` ${c.cyan(pg.title.padEnd(28).slice(0, 28))} ${c.dim(pg.updated || " ")} ${size} KB`);
3396
+ }
3397
+ console.log("");
3398
+ }
3399
+ function showCmd(name) {
3400
+ if (!name) {
3401
+ console.log(c.dim("\n Usage: holt wiki show <page>\n"));
3402
+ return;
3403
+ }
3404
+ const want = name.toLowerCase();
3405
+ const slug = listPageSlugs().find((s) => s.toLowerCase() === want || s.toLowerCase() === titleToSlug(name)) ?? loadPages().find((p) => p.title.toLowerCase() === want)?.slug;
3406
+ if (!slug) {
3407
+ console.log(c.dim(`
3408
+ No page "${name}". Try "holt wiki list".
3409
+ `));
3410
+ return;
3411
+ }
3412
+ const raw = readFileSync11(join10(wikiDir(), slug + ".md"), "utf8");
3413
+ console.log("\n" + raw.trim() + "\n");
3414
+ }
3415
+ async function statusCmd(cfg) {
3416
+ const stats = wikiStats();
3417
+ const rec = recommendLocalModel();
3418
+ const state = loadState();
3419
+ const lastSync = state.lastSyncTs ? new Date(state.lastSyncTs).toISOString().slice(0, 10) : "never";
3420
+ console.log("\n" + c.accent("Holt wiki") + c.dim(" (this folder)"));
3421
+ console.log(` maintainer ${cfg.wiki.maintainer}`);
3422
+ console.log(` localModel ${cfg.wiki.localModel}`);
3423
+ console.log(` pages ${stats.pages}`);
3424
+ console.log(` size ${(stats.bytes / 1024).toFixed(1)} KB (./.holt/wiki/)`);
3425
+ console.log(` last sync ${lastSync}`);
3426
+ if (cfg.wiki.maintainer === "local") {
3427
+ const st = await localModelStatus(cfg.wiki.localModel);
3428
+ 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");
3429
+ console.log(` local status ${line}`);
3430
+ }
3431
+ console.log(c.dim(`
3432
+ RAM ${rec.gib} GiB -> recommended local model: ${rec.model}`));
3433
+ console.log(c.dim(` ${rec.note}`));
3434
+ if (rec.discourageLocal) console.log(c.dim(" Local maintenance is discouraged at this RAM; the brain maintainer is recommended."));
3435
+ console.log(c.dim(` Pull it with: ${rec.pull}`));
3436
+ console.log(c.dim("\n holt wiki sync fold new facts into pages"));
3437
+ console.log(c.dim(" holt wiki rebuild regenerate the whole wiki from facts"));
3438
+ console.log(c.dim(" holt wiki lint audit for contradictions, duplicates, gaps"));
3439
+ console.log(c.dim(" holt wiki list / show <p> browse pages"));
3440
+ console.log(c.dim(" holt wiki open open in your default app (Obsidian reads it natively)\n"));
3441
+ }
3442
+ function setupCmd(cfg) {
3443
+ const rec = recommendLocalModel();
3444
+ console.log("\n" + c.accent("Local wiki maintainer: model recommendation"));
3445
+ console.log(` detected RAM ${rec.gib} GiB`);
3446
+ console.log(` recommended ${c.cyan(rec.model)}`);
3447
+ console.log(` why ${rec.note}`);
3448
+ if (rec.discourageLocal) {
3449
+ console.log(c.dim("\n At under 16 GiB, keep wiki.maintainer = brain (rides your Claude plan, no marginal RAM)."));
3450
+ }
3451
+ console.log(c.dim(`
3452
+ To use it locally:`));
3453
+ console.log(c.dim(` 1. ${rec.pull}`));
3454
+ console.log(c.dim(` 2. set wiki.maintainer = "local" and wiki.localModel = "${rec.model}" in .holt/config.json`));
3455
+ console.log(c.dim(` Current: maintainer=${cfg.wiki.maintainer}, localModel=${cfg.wiki.localModel}
3456
+ `));
3457
+ }
3458
+ function openCmd() {
3459
+ const target = existsSync10(wikiIndexPath()) ? wikiIndexPath() : wikiDir();
3460
+ if (!existsSync10(target)) {
3461
+ console.log(c.dim('\n No wiki yet. Run "holt wiki sync" first.\n'));
3462
+ return;
3463
+ }
3464
+ try {
3465
+ let cmd;
3466
+ let args;
3467
+ if (process.platform === "darwin") {
3468
+ cmd = "open";
3469
+ args = [target];
3470
+ } else if (process.platform === "win32") {
3471
+ cmd = "cmd";
3472
+ args = ["/c", "start", "", target];
3473
+ } else {
3474
+ cmd = "xdg-open";
3475
+ args = [target];
3476
+ }
3477
+ const child = spawn3(cmd, args, { detached: true, stdio: "ignore" });
3478
+ child.on("error", () => {
3479
+ });
3480
+ child.unref();
3481
+ console.log(c.dim(`
3482
+ Opening ${target}`));
3483
+ console.log(c.dim(" Tip: Obsidian can open .holt/wiki as a vault and read the [[links]] natively.\n"));
3484
+ } catch {
3485
+ console.log(c.dim(`
3486
+ Could not open it. Path: ${target}
3487
+ `));
3488
+ }
3489
+ }
3490
+ async function wikiCmd(sub, rest = []) {
3491
+ const action = (sub || "").toLowerCase();
3492
+ const { ask, close } = createReader();
3493
+ if (!await ensureTrusted(ask)) {
3494
+ close();
3495
+ return;
3496
+ }
3497
+ const cfg = loadConfig();
3498
+ if (!cfg) {
3499
+ console.log(c.dim('\n No Holt setup in this folder. Run "holt init" first.\n'));
3500
+ close();
3501
+ return;
3502
+ }
3503
+ try {
3504
+ switch (action) {
3505
+ case "sync":
3506
+ await syncCmd(cfg);
3507
+ break;
3508
+ case "rebuild":
3509
+ await rebuildCmd(cfg, ask);
3510
+ break;
3511
+ case "lint":
3512
+ await lintCmd(cfg, rest.includes("--fix"));
3513
+ break;
3514
+ case "list":
3515
+ listCmd();
3516
+ break;
3517
+ case "show":
3518
+ showCmd(rest.join(" ").trim());
3519
+ break;
3520
+ case "open":
3521
+ openCmd();
3522
+ break;
3523
+ case "setup":
3524
+ case "recommend":
3525
+ setupCmd(cfg);
3526
+ break;
3527
+ case "":
3528
+ case "status":
3529
+ await statusCmd(cfg);
3530
+ break;
3531
+ default:
3532
+ console.error(`
3533
+ Unknown wiki subcommand: "${sub}". Use: sync | rebuild | lint | list | show <page> | open | status | setup
3534
+ `);
3535
+ process.exitCode = 1;
3536
+ }
3537
+ } finally {
3538
+ close();
3539
+ }
3540
+ }
3541
+
3542
+ // src/commands/skill.ts
3543
+ import {
3544
+ mkdirSync as mkdirSync8,
3545
+ writeFileSync as writeFileSync9,
3546
+ readFileSync as readFileSync12,
3547
+ existsSync as existsSync11,
3548
+ readdirSync as readdirSync3,
3549
+ statSync as statSync4,
3550
+ rmSync as rmSync5,
3551
+ cpSync,
3552
+ mkdtempSync
3553
+ } from "fs";
3554
+ import { join as join11, resolve as resolve2 } from "path";
3555
+ import { tmpdir } from "os";
3556
+ import { spawnSync as spawnSync2 } from "child_process";
3557
+ function usage() {
3558
+ console.log(c.dim([
3559
+ "",
3560
+ " " + c.accent("holt skill") + c.dim(" - manage SKILL.md skills"),
3561
+ "",
3562
+ " holt skill list list installed skills",
3563
+ " holt skill show <name> print a skill",
3564
+ " holt skill create <name> [--global] scaffold a new skill",
3565
+ " holt skill add <source> [--global] install from a git URL or local path",
3566
+ " holt skill remove <name> delete a skill",
3567
+ "",
3568
+ " --global installs into ~/.holt/skills (available in every folder).",
3569
+ " Without it, skills live in this folder at ./.holt/skills.",
3570
+ ""
3571
+ ].join("\n")));
3572
+ }
3573
+ var SKILL_TEMPLATE = (name) => [
3574
+ "---",
3575
+ `name: ${name}`,
3576
+ "description: One sentence on what this skill does and when to use it.",
3577
+ "---",
2151
3578
  "",
2152
3579
  `# ${name}`,
2153
3580
  "",
@@ -2170,12 +3597,12 @@ function isGitUrl(s) {
2170
3597
  return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
2171
3598
  }
2172
3599
  function findSkillDir(root) {
2173
- if (existsSync8(join8(root, "SKILL.md"))) return root;
3600
+ if (existsSync11(join11(root, "SKILL.md"))) return root;
2174
3601
  let subs = [];
2175
3602
  try {
2176
- subs = readdirSync2(root).filter((f) => {
3603
+ subs = readdirSync3(root).filter((f) => {
2177
3604
  try {
2178
- return statSync3(join8(root, f)).isDirectory();
3605
+ return statSync4(join11(root, f)).isDirectory();
2179
3606
  } catch {
2180
3607
  return false;
2181
3608
  }
@@ -2183,8 +3610,8 @@ function findSkillDir(root) {
2183
3610
  } catch {
2184
3611
  return null;
2185
3612
  }
2186
- const withSkill = subs.filter((f) => existsSync8(join8(root, f, "SKILL.md")));
2187
- if (withSkill.length === 1) return join8(root, withSkill[0]);
3613
+ const withSkill = subs.filter((f) => existsSync11(join11(root, f, "SKILL.md")));
3614
+ if (withSkill.length === 1) return join11(root, withSkill[0]);
2188
3615
  return null;
2189
3616
  }
2190
3617
  function cmdList() {
@@ -2224,7 +3651,7 @@ function cmdShow(name) {
2224
3651
  const { skill, body } = loaded;
2225
3652
  console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
2226
3653
  if (skill.description) console.log(" " + skill.description);
2227
- console.log(c.dim(" " + join8(skill.dir, "SKILL.md")));
3654
+ console.log(c.dim(" " + join11(skill.dir, "SKILL.md")));
2228
3655
  console.log("");
2229
3656
  console.log(c.dim(body));
2230
3657
  console.log("");
@@ -2236,19 +3663,19 @@ function cmdCreate(name, global) {
2236
3663
  return;
2237
3664
  }
2238
3665
  const scope = global ? "global" : "workspace";
2239
- const dir = join8(skillsRoot(scope), clean);
2240
- if (existsSync8(dir)) {
3666
+ const dir = join11(skillsRoot(scope), clean);
3667
+ if (existsSync11(dir)) {
2241
3668
  console.error(c.red(`
2242
3669
  A ${scope} skill named "${clean}" already exists.`));
2243
3670
  console.error(c.dim(" " + dir + "\n"));
2244
3671
  process.exitCode = 1;
2245
3672
  return;
2246
3673
  }
2247
- mkdirSync6(dir, { recursive: true });
2248
- writeFileSync7(join8(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
3674
+ mkdirSync8(dir, { recursive: true });
3675
+ writeFileSync9(join11(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
2249
3676
  console.log(c.green(`
2250
3677
  Created ${scope} skill "${clean}".`));
2251
- console.log(c.dim(" " + join8(dir, "SKILL.md")));
3678
+ console.log(c.dim(" " + join11(dir, "SKILL.md")));
2252
3679
  console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
2253
3680
  }
2254
3681
  function cmdAdd(source, global) {
@@ -2261,7 +3688,7 @@ function cmdAdd(source, global) {
2261
3688
  let tempDir = "";
2262
3689
  try {
2263
3690
  if (isGitUrl(source)) {
2264
- tempDir = mkdtempSync(join8(tmpdir(), "holt-skill-"));
3691
+ tempDir = mkdtempSync(join11(tmpdir(), "holt-skill-"));
2265
3692
  console.log(c.dim(`
2266
3693
  Cloning ${source} ...`));
2267
3694
  const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
@@ -2272,8 +3699,8 @@ function cmdAdd(source, global) {
2272
3699
  }
2273
3700
  fetched = tempDir;
2274
3701
  } else {
2275
- fetched = resolve(source);
2276
- if (!existsSync8(fetched)) {
3702
+ fetched = resolve2(source);
3703
+ if (!existsSync11(fetched)) {
2277
3704
  console.error(c.red(`
2278
3705
  No such path: ${fetched}
2279
3706
  `));
@@ -2289,7 +3716,7 @@ function cmdAdd(source, global) {
2289
3716
  }
2290
3717
  let skillName = "";
2291
3718
  try {
2292
- const { data } = parseFrontmatter(readFileSync8(join8(skillDir, "SKILL.md"), "utf8"));
3719
+ const { data } = parseFrontmatter(readFileSync12(join11(skillDir, "SKILL.md"), "utf8"));
2293
3720
  skillName = sanitizeName(data.name || "");
2294
3721
  } catch {
2295
3722
  skillName = "";
@@ -2300,25 +3727,25 @@ function cmdAdd(source, global) {
2300
3727
  process.exitCode = 1;
2301
3728
  return;
2302
3729
  }
2303
- const dest = join8(skillsRoot(scope), skillName);
2304
- if (existsSync8(dest)) {
3730
+ const dest = join11(skillsRoot(scope), skillName);
3731
+ if (existsSync11(dest)) {
2305
3732
  console.error(c.red(`
2306
3733
  A ${scope} skill named "${skillName}" already exists.`));
2307
3734
  console.error(c.dim(" " + dest + "\n"));
2308
3735
  process.exitCode = 1;
2309
3736
  return;
2310
3737
  }
2311
- mkdirSync6(skillsRoot(scope), { recursive: true });
3738
+ mkdirSync8(skillsRoot(scope), { recursive: true });
2312
3739
  cpSync(skillDir, dest, { recursive: true });
2313
- const nestedGit = join8(dest, ".git");
2314
- if (existsSync8(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
3740
+ const nestedGit = join11(dest, ".git");
3741
+ if (existsSync11(nestedGit)) rmSync5(nestedGit, { recursive: true, force: true });
2315
3742
  console.log(c.green(`
2316
3743
  Installed ${scope} skill "${skillName}".`));
2317
- console.log(c.dim(" " + join8(dest, "SKILL.md") + "\n"));
3744
+ console.log(c.dim(" " + join11(dest, "SKILL.md") + "\n"));
2318
3745
  } finally {
2319
- if (tempDir && existsSync8(tempDir)) {
3746
+ if (tempDir && existsSync11(tempDir)) {
2320
3747
  try {
2321
- rmSync3(tempDir, { recursive: true, force: true });
3748
+ rmSync5(tempDir, { recursive: true, force: true });
2322
3749
  } catch {
2323
3750
  }
2324
3751
  }
@@ -2330,9 +3757,9 @@ async function cmdRemove(name, ask) {
2330
3757
  console.log(c.dim("\n Usage: holt skill remove <name>\n"));
2331
3758
  return;
2332
3759
  }
2333
- const wsDir = join8(skillsRoot("workspace"), clean);
2334
- const globalDir = join8(skillsRoot("global"), clean);
2335
- const target = existsSync8(wsDir) ? wsDir : existsSync8(globalDir) ? globalDir : "";
3760
+ const wsDir = join11(skillsRoot("workspace"), clean);
3761
+ const globalDir = join11(skillsRoot("global"), clean);
3762
+ const target = existsSync11(wsDir) ? wsDir : existsSync11(globalDir) ? globalDir : "";
2336
3763
  if (!target) {
2337
3764
  console.error(c.dim(`
2338
3765
  No skill named "${clean}" found.
@@ -2344,7 +3771,7 @@ async function cmdRemove(name, ask) {
2344
3771
  const a = (await ask(`
2345
3772
  Delete ${scope} skill "${clean}"? [y/N] `) ?? "").trim().toLowerCase();
2346
3773
  if (a === "y" || a === "yes") {
2347
- rmSync3(target, { recursive: true, force: true });
3774
+ rmSync5(target, { recursive: true, force: true });
2348
3775
  console.log(c.green(" Removed.\n"));
2349
3776
  } else {
2350
3777
  console.log(c.dim(" Kept.\n"));
@@ -2392,9 +3819,9 @@ async function skillCmd(sub, rest = []) {
2392
3819
  }
2393
3820
 
2394
3821
  // 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";
3822
+ import { writeFileSync as writeFileSync10, mkdirSync as mkdirSync9, existsSync as existsSync12 } from "fs";
3823
+ import { join as join12, dirname as dirname2, resolve as resolve3 } from "path";
3824
+ import { spawn as spawn4 } from "child_process";
2398
3825
 
2399
3826
  // src/graphview.ts
2400
3827
  var STOPWORDS = /* @__PURE__ */ new Set([
@@ -2469,7 +3896,7 @@ var STOPWORDS = /* @__PURE__ */ new Set([
2469
3896
  "thanks",
2470
3897
  "lets"
2471
3898
  ]);
2472
- function cosine2(a, b) {
3899
+ function cosine3(a, b) {
2473
3900
  let dot = 0;
2474
3901
  let na = 0;
2475
3902
  let nb = 0;
@@ -2529,7 +3956,7 @@ function buildGraph(turns) {
2529
3956
  const a = withEmb[i];
2530
3957
  for (let j = i + 1; j < withEmb.length; j++) {
2531
3958
  const b = withEmb[j];
2532
- const sim = cosine2(a.emb, b.emb);
3959
+ const sim = cosine3(a.emb, b.emb);
2533
3960
  if (sim >= SEMANTIC_MIN) pairs.push({ source: a.id, target: b.id, sim });
2534
3961
  }
2535
3962
  }
@@ -2557,6 +3984,39 @@ function buildGraph(turns) {
2557
3984
  }
2558
3985
  return { nodes, edges };
2559
3986
  }
3987
+ function buildWikiGraph(pages) {
3988
+ const nodes = [];
3989
+ const edges = [];
3990
+ const byKey = /* @__PURE__ */ new Map();
3991
+ for (const p of pages) {
3992
+ byKey.set(p.slug.toLowerCase(), p);
3993
+ byKey.set(p.title.toLowerCase(), p);
3994
+ }
3995
+ for (const p of pages) {
3996
+ nodes.push({ id: "wiki:" + p.slug, kind: "wiki", label: p.title });
3997
+ }
3998
+ for (const p of pages) {
3999
+ const seen = /* @__PURE__ */ new Set();
4000
+ for (const raw of p.links) {
4001
+ const target = byKey.get(raw.toLowerCase());
4002
+ if (!target || target.slug === p.slug) continue;
4003
+ const key = target.slug;
4004
+ if (seen.has(key)) continue;
4005
+ seen.add(key);
4006
+ edges.push({ source: "wiki:" + p.slug, target: "wiki:" + target.slug, kind: "wikilink", width: 2 });
4007
+ }
4008
+ }
4009
+ return { nodes, edges };
4010
+ }
4011
+ function mergeGraphs(a, b) {
4012
+ const seen = new Set(a.nodes.map((n) => n.id));
4013
+ const nodes = [...a.nodes];
4014
+ for (const n of b.nodes) if (!seen.has(n.id)) {
4015
+ nodes.push(n);
4016
+ seen.add(n.id);
4017
+ }
4018
+ return { nodes, edges: [...a.edges, ...b.edges] };
4019
+ }
2560
4020
  function escapeForScript(json) {
2561
4021
  return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\u0021--");
2562
4022
  }
@@ -2662,6 +4122,7 @@ function renderGraphHtml(graph2, meta) {
2662
4122
  <div class="row"><span class="swatch" style="background:var(--amber)"></span>you</div>
2663
4123
  <div class="row"><span class="swatch" style="background:var(--cyan)"></span>assistant</div>
2664
4124
  <div class="row"><span class="swatch" style="background:var(--violet)"></span>concept</div>
4125
+ <div class="row"><span class="swatch" style="background:#5bd66f"></span>wiki page</div>
2665
4126
  <div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session</div>
2666
4127
  </div>
2667
4128
  <div id="hint">drag to pan &middot; wheel to zoom &middot; click a node &middot; Esc to clear</div>
@@ -2712,7 +4173,7 @@ function renderGraphHtml(graph2, meta) {
2712
4173
  });
2713
4174
 
2714
4175
  function radiusOf(n) {
2715
- var base = n.kind === "concept" ? 3 : 5;
4176
+ var base = n.kind === "concept" ? 3 : n.kind === "wiki" ? 6 : 5;
2716
4177
  return base + Math.min(9, Math.sqrt(n._deg) * 1.6);
2717
4178
  }
2718
4179
 
@@ -2791,6 +4252,7 @@ function renderGraphHtml(graph2, meta) {
2791
4252
  var query = "";
2792
4253
 
2793
4254
  function roleColor(n) {
4255
+ if (n.kind === "wiki") return "#5bd66f";
2794
4256
  if (n.kind === "concept") return "#9a8cff";
2795
4257
  return n.role === "user" ? "#f0b91e" : "#35d0d6";
2796
4258
  }
@@ -2817,7 +4279,7 @@ function renderGraphHtml(graph2, meta) {
2817
4279
  }
2818
4280
  ctx.globalAlpha = dim;
2819
4281
  ctx.lineWidth = Math.max(0.4, lk.width * view.scale * 0.5);
2820
- ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : "#4a5262";
4282
+ ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : lk.kind === "wikilink" ? "#5bd66f" : "#4a5262";
2821
4283
  ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
2822
4284
  }
2823
4285
  ctx.globalAlpha = 1;
@@ -2848,11 +4310,11 @@ function renderGraphHtml(graph2, meta) {
2848
4310
  ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.stroke();
2849
4311
  }
2850
4312
 
2851
- // concept labels always; turn labels when zoomed in
2852
- if (n.kind === "concept" || view.scale > 1.4) {
4313
+ // concept + wiki labels always; turn labels when zoomed in
4314
+ if (n.kind === "concept" || n.kind === "wiki" || view.scale > 1.4) {
2853
4315
  ctx.globalAlpha = faded ? 0.25 : 0.9;
2854
- ctx.fillStyle = "#e7e9ee";
2855
- ctx.font = (n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
4316
+ ctx.fillStyle = n.kind === "wiki" ? "#5bd66f" : "#e7e9ee";
4317
+ ctx.font = (n.kind === "wiki" ? 12 : n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
2856
4318
  ctx.fillText(n.label, p.x + r + 4, p.y + 3);
2857
4319
  }
2858
4320
  }
@@ -2916,6 +4378,8 @@ function renderGraphHtml(graph2, meta) {
2916
4378
  var when = hov.ts ? new Date(hov.ts).toISOString().slice(0, 10) : "";
2917
4379
  var meta = hov.kind === "concept"
2918
4380
  ? ("concept &middot; in " + (hov.freq || 0) + " turns")
4381
+ : hov.kind === "wiki"
4382
+ ? "wiki page"
2919
4383
  : (esc(hov.session || "") + (when ? " &middot; " + when : ""));
2920
4384
  tooltip.innerHTML = "<div>" + esc(hov.label) + "</div><div class='t-meta'>" + meta + "</div>";
2921
4385
  tooltip.style.display = "block";
@@ -2959,8 +4423,8 @@ function renderGraphHtml(graph2, meta) {
2959
4423
  }
2960
4424
  function onNodeClick(n) {
2961
4425
  selected = n.id;
2962
- if (n.kind === "concept") {
2963
- highlightSet = neighbors(n.id); // light up its connected turns
4426
+ if (n.kind === "concept" || n.kind === "wiki") {
4427
+ highlightSet = neighbors(n.id); // light up its connected nodes
2964
4428
  closePanel();
2965
4429
  } else {
2966
4430
  highlightSet = null;
@@ -3015,11 +4479,16 @@ function parseArgs(args) {
3015
4479
  for (let i = 0; i < args.length; i++) {
3016
4480
  const a = args[i];
3017
4481
  if (a === "--no-open") opts.open = false;
4482
+ else if (a === "--wiki") opts.wiki = true;
4483
+ else if (a === "--no-wiki") opts.wiki = false;
3018
4484
  else if (a === "--out") opts.out = args[++i];
3019
4485
  else if (a && a.startsWith("--out=")) opts.out = a.slice("--out=".length);
3020
4486
  }
3021
4487
  return opts;
3022
4488
  }
4489
+ function wikiGraphPages() {
4490
+ return loadPages().map((p) => ({ slug: p.slug, title: p.title, links: extractLinks(p.body) }));
4491
+ }
3023
4492
  function openInBrowser(path) {
3024
4493
  try {
3025
4494
  let cmd;
@@ -3034,7 +4503,7 @@ function openInBrowser(path) {
3034
4503
  cmd = "xdg-open";
3035
4504
  cmdArgs = [path];
3036
4505
  }
3037
- const child = spawn3(cmd, cmdArgs, { detached: true, stdio: "ignore" });
4506
+ const child = spawn4(cmd, cmdArgs, { detached: true, stdio: "ignore" });
3038
4507
  child.on("error", () => {
3039
4508
  });
3040
4509
  child.unref();
@@ -3050,12 +4519,16 @@ async function graph(args = []) {
3050
4519
  close();
3051
4520
  const opts = parseArgs(args);
3052
4521
  const turns = loadTurns();
3053
- if (turns.length === 0) {
4522
+ const includeWiki = opts.wiki !== void 0 ? opts.wiki : existsSync12(wikiDir());
4523
+ const wikiPages = includeWiki ? wikiGraphPages() : [];
4524
+ if (turns.length === 0 && wikiPages.length === 0) {
3054
4525
  console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
3055
4526
  return;
3056
4527
  }
3057
- const g = buildGraph(turns);
4528
+ let g = buildGraph(turns);
4529
+ if (wikiPages.length) g = mergeGraphs(g, buildWikiGraph(wikiPages));
3058
4530
  const conceptCount = g.nodes.filter((n) => n.kind === "concept").length;
4531
+ const wikiCount = g.nodes.filter((n) => n.kind === "wiki").length;
3059
4532
  const sessions = new Set(turns.map((t) => t.session)).size;
3060
4533
  const html = renderGraphHtml(g, {
3061
4534
  workspace: workspace(),
@@ -3064,11 +4537,13 @@ async function graph(args = []) {
3064
4537
  concepts: conceptCount,
3065
4538
  edges: g.edges.length
3066
4539
  });
3067
- const outPath = opts.out ? resolve2(opts.out) : join9(wsHoltDir(), "graph.html");
3068
- mkdirSync7(dirname2(outPath), { recursive: true });
3069
- writeFileSync8(outPath, html, "utf8");
4540
+ const outPath = opts.out ? resolve3(opts.out) : join12(wsHoltDir(), "graph.html");
4541
+ mkdirSync9(dirname2(outPath), { recursive: true });
4542
+ writeFileSync10(outPath, html, "utf8");
3070
4543
  console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
3071
- console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
4544
+ const parts = [`${turns.length} turns`, `${conceptCount} concepts`];
4545
+ if (wikiCount) parts.push(`${wikiCount} wiki pages`);
4546
+ console.log(` nodes ${g.nodes.length} (${parts.join(", ")})`);
3072
4547
  console.log(` edges ${g.edges.length}`);
3073
4548
  console.log(` file ${outPath}`);
3074
4549
  if (opts.open) {
@@ -3174,7 +4649,7 @@ function recoverId(line) {
3174
4649
  return null;
3175
4650
  }
3176
4651
  function runMcpServer(opts) {
3177
- return new Promise((resolve4) => {
4652
+ return new Promise((resolve7) => {
3178
4653
  const stdin = process.stdin;
3179
4654
  let buffer = "";
3180
4655
  let chain = Promise.resolve();
@@ -3226,11 +4701,11 @@ function runMcpServer(opts) {
3226
4701
  processLine(buffer);
3227
4702
  buffer = "";
3228
4703
  }
3229
- chain.then(() => resolve4()).catch(() => resolve4());
4704
+ chain.then(() => resolve7()).catch(() => resolve7());
3230
4705
  });
3231
4706
  stdin.on("error", (e) => {
3232
4707
  console.error(`[mcp] stdin error: ${e.message}`);
3233
- chain.then(() => resolve4()).catch(() => resolve4());
4708
+ chain.then(() => resolve7()).catch(() => resolve7());
3234
4709
  });
3235
4710
  stdin.resume();
3236
4711
  });
@@ -3350,7 +4825,7 @@ function holtTools() {
3350
4825
  }
3351
4826
 
3352
4827
  // src/version.ts
3353
- var VERSION = "0.8.4";
4828
+ var VERSION = "0.9.0";
3354
4829
 
3355
4830
  // src/commands/mcp.ts
3356
4831
  function printSetup() {
@@ -3395,10 +4870,370 @@ async function mcp(sub, rest = []) {
3395
4870
  }
3396
4871
  }
3397
4872
 
4873
+ // src/commands/hook.ts
4874
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync11, existsSync as existsSync13, mkdirSync as mkdirSync10, copyFileSync, realpathSync as realpathSync2 } from "fs";
4875
+ import { join as join13, dirname as dirname3 } from "path";
4876
+ import { homedir as homedir3 } from "os";
4877
+ var EVENT = {
4878
+ inject: "UserPromptSubmit",
4879
+ capture: "Stop"
4880
+ };
4881
+ var HOOK_CMD = {
4882
+ inject: "holt hook inject",
4883
+ capture: "holt hook capture"
4884
+ };
4885
+ function globalSettingsPath() {
4886
+ return join13(homedir3(), ".claude", "settings.json");
4887
+ }
4888
+ function projectSettingsPath() {
4889
+ return join13(process.cwd(), ".claude", "settings.json");
4890
+ }
4891
+ function readSettings(path) {
4892
+ if (!existsSync13(path)) return {};
4893
+ try {
4894
+ const parsed = JSON.parse(readFileSync13(path, "utf8"));
4895
+ return parsed && typeof parsed === "object" ? parsed : {};
4896
+ } catch {
4897
+ throw new Error(`could not parse ${path} (not valid JSON) - leaving it untouched`);
4898
+ }
4899
+ }
4900
+ function writeSettings(path, settings) {
4901
+ mkdirSync10(dirname3(path), { recursive: true });
4902
+ writeFileSync11(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
4903
+ }
4904
+ function backup(path) {
4905
+ if (!existsSync13(path)) return null;
4906
+ const dest = `${path}.holt-bak`;
4907
+ try {
4908
+ copyFileSync(path, dest);
4909
+ return dest;
4910
+ } catch {
4911
+ return null;
4912
+ }
4913
+ }
4914
+ function isHoltEntry(m, dir) {
4915
+ return Array.isArray(m.hooks) && m.hooks.some((h) => typeof h?.command === "string" && h.command.includes(HOOK_CMD[dir]));
4916
+ }
4917
+ function addHook(settings, dir) {
4918
+ settings.hooks = settings.hooks ?? {};
4919
+ const event = EVENT[dir];
4920
+ const list = settings.hooks[event] = settings.hooks[event] ?? [];
4921
+ if (list.some((m) => isHoltEntry(m, dir))) return false;
4922
+ list.push({ hooks: [{ type: "command", command: HOOK_CMD[dir] }] });
4923
+ return true;
4924
+ }
4925
+ function removeHook(settings, dir) {
4926
+ const event = EVENT[dir];
4927
+ const list = settings.hooks?.[event];
4928
+ if (!Array.isArray(list)) return false;
4929
+ let changed = false;
4930
+ const kept = [];
4931
+ for (const m of list) {
4932
+ if (!isHoltEntry(m, dir)) {
4933
+ kept.push(m);
4934
+ continue;
4935
+ }
4936
+ const others = (m.hooks || []).filter((h) => !(typeof h?.command === "string" && h.command.includes(HOOK_CMD[dir])));
4937
+ if (others.length) kept.push({ ...m, hooks: others });
4938
+ changed = true;
4939
+ }
4940
+ if (!changed) return false;
4941
+ if (kept.length) settings.hooks[event] = kept;
4942
+ else delete settings.hooks[event];
4943
+ return true;
4944
+ }
4945
+ function isHoltInstalled(settings, dir) {
4946
+ const list = settings.hooks?.[EVENT[dir]];
4947
+ return Array.isArray(list) && list.some((m) => isHoltEntry(m, dir));
4948
+ }
4949
+ function parseFlags(rest) {
4950
+ let injectOnly = false;
4951
+ let captureOnly = false;
4952
+ let project = false;
4953
+ for (const a of rest) {
4954
+ if (a === "--inject-only") injectOnly = true;
4955
+ else if (a === "--capture-only") captureOnly = true;
4956
+ else if (a === "--project") project = true;
4957
+ }
4958
+ const inject2 = injectOnly || !captureOnly;
4959
+ const capture2 = captureOnly || !injectOnly;
4960
+ return { inject: inject2, capture: capture2, project };
4961
+ }
4962
+ function install(rest) {
4963
+ const flags = parseFlags(rest);
4964
+ const path = flags.project ? projectSettingsPath() : globalSettingsPath();
4965
+ const target = flags.project ? "project (./.claude/settings.json)" : "global (~/.claude/settings.json)";
4966
+ let settings;
4967
+ try {
4968
+ settings = readSettings(path);
4969
+ } catch (e) {
4970
+ console.error(c.red(" " + e.message));
4971
+ process.exitCode = 1;
4972
+ return;
4973
+ }
4974
+ const bak = backup(path);
4975
+ const changed = [];
4976
+ if (flags.inject && addHook(settings, "inject")) changed.push("inject");
4977
+ if (flags.capture && addHook(settings, "capture")) changed.push("capture");
4978
+ writeSettings(path, settings);
4979
+ const wanted = [flags.inject ? "inject" : "", flags.capture ? "capture" : ""].filter(Boolean).join(" + ");
4980
+ if (changed.length) {
4981
+ console.log(c.green(` installed ${changed.join(" + ")} into ${target}`));
4982
+ } else {
4983
+ console.log(c.dim(` ${wanted} already installed in ${target} (no change)`));
4984
+ }
4985
+ if (bak) console.log(c.dim(` backup: ${bak}`));
4986
+ console.log(c.dim(" Holt now injects folder memory before each prompt and captures facts when a session ends,"));
4987
+ console.log(c.dim(" but only in folders you have trusted with an existing .holt/memory."));
4988
+ console.log(c.dim(" Undo any time with: holt hook remove" + (flags.project ? " --project" : "")));
4989
+ }
4990
+ function remove(rest) {
4991
+ const project = rest.includes("--project");
4992
+ const paths = project ? [projectSettingsPath()] : [globalSettingsPath(), projectSettingsPath()];
4993
+ let touchedAny = false;
4994
+ for (const path of paths) {
4995
+ if (!existsSync13(path)) continue;
4996
+ let settings;
4997
+ try {
4998
+ settings = readSettings(path);
4999
+ } catch (e) {
5000
+ console.error(c.red(" " + e.message));
5001
+ continue;
5002
+ }
5003
+ const bak = backup(path);
5004
+ let changed = false;
5005
+ if (removeHook(settings, "inject")) changed = true;
5006
+ if (removeHook(settings, "capture")) changed = true;
5007
+ if (changed) {
5008
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
5009
+ writeSettings(path, settings);
5010
+ console.log(c.green(` removed Holt hooks from ${path}`));
5011
+ if (bak) console.log(c.dim(` backup: ${bak}`));
5012
+ touchedAny = true;
5013
+ }
5014
+ }
5015
+ if (!touchedAny) console.log(c.dim(" no Holt hooks found to remove."));
5016
+ }
5017
+ function status(rest) {
5018
+ const project = rest.includes("--project");
5019
+ const entries = project ? [{ label: "project", path: projectSettingsPath() }] : [
5020
+ { label: "global", path: globalSettingsPath() },
5021
+ { label: "project", path: projectSettingsPath() }
5022
+ ];
5023
+ console.log(c.accent(" Holt ambient hooks"));
5024
+ for (const e of entries) {
5025
+ if (!existsSync13(e.path)) {
5026
+ console.log(c.dim(` ${e.label.padEnd(8)} ${e.path} (no settings file)`));
5027
+ continue;
5028
+ }
5029
+ let settings;
5030
+ try {
5031
+ settings = readSettings(e.path);
5032
+ } catch {
5033
+ console.log(c.red(` ${e.label.padEnd(8)} ${e.path} (unreadable JSON)`));
5034
+ continue;
5035
+ }
5036
+ const inj = isHoltInstalled(settings, "inject");
5037
+ const cap = isHoltInstalled(settings, "capture");
5038
+ const active = [inj ? "inject" : "", cap ? "capture" : ""].filter(Boolean).join(" + ") || "none";
5039
+ const mark = inj || cap ? c.green("on ") : c.dim("off");
5040
+ console.log(` ${e.label.padEnd(8)} ${mark} ${c.dim(e.path)} ${c.dim("[" + active + "]")}`);
5041
+ }
5042
+ console.log(c.dim(" Runtime hooks only act in trusted Holt folders that already have .holt/memory."));
5043
+ }
5044
+ function readStdin(timeoutMs) {
5045
+ return new Promise((resolve7) => {
5046
+ let input = "";
5047
+ let done = false;
5048
+ const finish = () => {
5049
+ if (done) return;
5050
+ done = true;
5051
+ resolve7(input);
5052
+ };
5053
+ try {
5054
+ process.stdin.setEncoding("utf8");
5055
+ process.stdin.on("data", (chunk2) => {
5056
+ input += chunk2;
5057
+ });
5058
+ process.stdin.on("end", finish);
5059
+ process.stdin.on("error", finish);
5060
+ } catch {
5061
+ finish();
5062
+ }
5063
+ setTimeout(finish, timeoutMs);
5064
+ });
5065
+ }
5066
+ function parseHookInput(raw) {
5067
+ try {
5068
+ const j = JSON.parse(raw || "{}");
5069
+ return j && typeof j === "object" ? j : {};
5070
+ } catch {
5071
+ return {};
5072
+ }
5073
+ }
5074
+ function enterFolder(cwd) {
5075
+ if (!cwd || !existsSync13(cwd)) return null;
5076
+ try {
5077
+ const real = realpathSync2(cwd);
5078
+ process.chdir(real);
5079
+ return real;
5080
+ } catch {
5081
+ return null;
5082
+ }
5083
+ }
5084
+ function guardTrusted(real) {
5085
+ if (!isTrusted(real)) return false;
5086
+ if (!existsSync13(memDir())) return false;
5087
+ return true;
5088
+ }
5089
+ async function inject() {
5090
+ try {
5091
+ const raw = await readStdin(2e3);
5092
+ const data = parseHookInput(raw);
5093
+ const prompt = (data.prompt || "").trim();
5094
+ if (prompt.length < 3) return;
5095
+ const real = enterFolder(data.cwd);
5096
+ if (!real) return;
5097
+ if (!guardTrusted(real)) return;
5098
+ const session = data.session_id || newSessionId();
5099
+ const hits = await recall(prompt, session, 4);
5100
+ if (!hits.length) return;
5101
+ const lines = hits.map((h) => `- ${h.turn.content.replace(/\s+/g, " ").trim().slice(0, 500)}`);
5102
+ process.stdout.write(
5103
+ ["[Holt memory - relevant notes from earlier sessions in this folder]", ...lines, ""].join("\n")
5104
+ );
5105
+ } catch (e) {
5106
+ process.stderr.write("holt hook inject: " + (e instanceof Error ? e.message : String(e)) + "\n");
5107
+ }
5108
+ }
5109
+ function readTranscript(path) {
5110
+ const out = [];
5111
+ let raw;
5112
+ try {
5113
+ raw = readFileSync13(path, "utf8");
5114
+ } catch {
5115
+ return out;
5116
+ }
5117
+ for (const line of raw.split("\n")) {
5118
+ if (!line.trim()) continue;
5119
+ let rec;
5120
+ try {
5121
+ rec = JSON.parse(line);
5122
+ } catch {
5123
+ continue;
5124
+ }
5125
+ const d = rec;
5126
+ if (d.type !== "user" && d.type !== "assistant") continue;
5127
+ const m = d.message;
5128
+ if (!m || m.role !== "user" && m.role !== "assistant") continue;
5129
+ let text = "";
5130
+ if (typeof m.content === "string") {
5131
+ text = m.content;
5132
+ } else if (Array.isArray(m.content)) {
5133
+ text = m.content.map((b) => {
5134
+ if (typeof b === "string") return b;
5135
+ if (b && typeof b.text === "string") return b.text;
5136
+ return "";
5137
+ }).join(" ").trim();
5138
+ }
5139
+ text = text.trim();
5140
+ if (text.length < 3) continue;
5141
+ out.push({ role: m.role, content: text });
5142
+ }
5143
+ return out;
5144
+ }
5145
+ function resolveExtractionBrain(cfg) {
5146
+ const id = cfg.defaultBrain;
5147
+ if (!id) return null;
5148
+ if (BRAIN_IDS.includes(id)) {
5149
+ const bid = id;
5150
+ if (!cfg.brains[bid].enabled) return null;
5151
+ if (!isInstalled(cfg.brains[bid].command)) return null;
5152
+ return { kind: "cli", id: bid };
5153
+ }
5154
+ const api = findApiBrain(cfg, id);
5155
+ if (api && resolveApiKey(api)) return { kind: "api", brain: api };
5156
+ return null;
5157
+ }
5158
+ async function capture() {
5159
+ try {
5160
+ const raw = await readStdin(3e3);
5161
+ const data = parseHookInput(raw);
5162
+ const real = enterFolder(data.cwd);
5163
+ if (!real) return;
5164
+ if (!guardTrusted(real)) return;
5165
+ const cfg = loadConfig();
5166
+ if (!cfg) return;
5167
+ if (cfg.memory?.extractFacts === false) return;
5168
+ const brain = resolveExtractionBrain(cfg);
5169
+ if (!brain) return;
5170
+ if (!data.transcript_path) return;
5171
+ const history = readTranscript(data.transcript_path);
5172
+ if (!history.length) return;
5173
+ const session = "hook-" + (data.session_id || newSessionId());
5174
+ const n = await extractAndSaveFacts(brain, cfg, history, session);
5175
+ if (n > 0) process.stderr.write(`holt hook capture: saved ${n} fact${n === 1 ? "" : "s"} to ${memDir()}
5176
+ `);
5177
+ } catch (e) {
5178
+ process.stderr.write("holt hook capture: " + (e instanceof Error ? e.message : String(e)) + "\n");
5179
+ }
5180
+ }
5181
+ var USAGE2 = [
5182
+ " holt hook <command>",
5183
+ "",
5184
+ " Wire Holt into Claude Code so folder memory works ambiently: it injects",
5185
+ " relevant remembered notes before each prompt and captures durable facts",
5186
+ " when a session ends. Both run only in trusted Holt folders that already",
5187
+ " have .holt/memory.",
5188
+ "",
5189
+ " management:",
5190
+ " install [flags] write the hooks into Claude Code settings",
5191
+ " --inject-only install only the before-prompt recall hook",
5192
+ " --capture-only install only the end-of-session fact hook",
5193
+ " --project write ./.claude/settings.json (default: ~/.claude)",
5194
+ " remove [--project] remove ONLY Holt hooks, leaving other settings intact",
5195
+ " status [--project] show what is installed and which directions are active",
5196
+ "",
5197
+ " runtime (invoked by Claude Code, not you):",
5198
+ " inject UserPromptSubmit body: recall + print a context block",
5199
+ " capture Stop body: distill facts from the transcript and save them",
5200
+ "",
5201
+ " example: holt hook install # both hooks, global settings"
5202
+ ].join("\n");
5203
+ async function hook(sub, rest = []) {
5204
+ switch (sub) {
5205
+ case "install":
5206
+ install(rest);
5207
+ return;
5208
+ case "remove":
5209
+ case "uninstall":
5210
+ remove(rest);
5211
+ return;
5212
+ case "status":
5213
+ status(rest);
5214
+ return;
5215
+ case "inject":
5216
+ await inject();
5217
+ return;
5218
+ case "capture":
5219
+ await capture();
5220
+ return;
5221
+ case void 0:
5222
+ case "help":
5223
+ case "-h":
5224
+ case "--help":
5225
+ console.log(USAGE2);
5226
+ return;
5227
+ default:
5228
+ console.error(` Unknown hook command: "${sub}". Run "holt hook help".`);
5229
+ process.exitCode = 1;
5230
+ }
5231
+ }
5232
+
3398
5233
  // src/commands/run.ts
3399
- import { writeFileSync as writeFileSync9 } from "fs";
3400
- import { resolve as resolve3 } from "path";
3401
- var USAGE = [
5234
+ import { writeFileSync as writeFileSync12 } from "fs";
5235
+ import { resolve as resolve4 } from "path";
5236
+ var USAGE3 = [
3402
5237
  " holt run <task> [options]",
3403
5238
  "",
3404
5239
  " Run a single task against your default brain and stream the reply.",
@@ -3448,7 +5283,7 @@ function parseArgs2(args) {
3448
5283
  async function run(args) {
3449
5284
  const p = parseArgs2(args);
3450
5285
  if (!p.task) {
3451
- console.log(USAGE);
5286
+ console.log(USAGE3);
3452
5287
  return;
3453
5288
  }
3454
5289
  if (!isTrusted()) {
@@ -3486,9 +5321,9 @@ async function run(args) {
3486
5321
  process.stdout.write("\n");
3487
5322
  }
3488
5323
  if (p.out) {
3489
- const target = resolve3(process.cwd(), p.out);
5324
+ const target = resolve4(process.cwd(), p.out);
3490
5325
  try {
3491
- writeFileSync9(target, result.text, "utf8");
5326
+ writeFileSync12(target, result.text, "utf8");
3492
5327
  process.stderr.write(c.dim(`saved to ${target}`) + "\n");
3493
5328
  } catch (e) {
3494
5329
  process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
@@ -3499,38 +5334,38 @@ async function run(args) {
3499
5334
 
3500
5335
  // src/commands/schedule.ts
3501
5336
  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";
5337
+ import { writeFileSync as writeFileSync14, mkdirSync as mkdirSync12, rmSync as rmSync6, existsSync as existsSync14 } from "fs";
5338
+ import { dirname as dirname4 } from "path";
3504
5339
 
3505
5340
  // 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";
5341
+ import { homedir as homedir4 } from "os";
5342
+ import { join as join14 } from "path";
5343
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync13, mkdirSync as mkdirSync11 } from "fs";
3509
5344
  import { randomUUID as randomUUID4 } from "crypto";
3510
5345
  import { spawnSync as spawnSync3 } from "child_process";
3511
5346
  function schedulesPath() {
3512
- return join10(GLOBAL_DIR, "schedules.json");
5347
+ return join14(GLOBAL_DIR, "schedules.json");
3513
5348
  }
3514
5349
  function logsDir() {
3515
- return join10(GLOBAL_DIR, "logs");
5350
+ return join14(GLOBAL_DIR, "logs");
3516
5351
  }
3517
5352
  function logPath(id) {
3518
- return join10(logsDir(), `${id}.log`);
5353
+ return join14(logsDir(), `${id}.log`);
3519
5354
  }
3520
5355
  function plistPath(id) {
3521
- return join10(homedir3(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
5356
+ return join14(homedir4(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
3522
5357
  }
3523
5358
  function loadJobs() {
3524
5359
  try {
3525
- const raw = JSON.parse(readFileSync9(schedulesPath(), "utf8"));
5360
+ const raw = JSON.parse(readFileSync14(schedulesPath(), "utf8"));
3526
5361
  return Array.isArray(raw) ? raw : [];
3527
5362
  } catch {
3528
5363
  return [];
3529
5364
  }
3530
5365
  }
3531
5366
  function saveJobs(jobs) {
3532
- mkdirSync8(GLOBAL_DIR, { recursive: true });
3533
- writeFileSync10(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
5367
+ mkdirSync11(GLOBAL_DIR, { recursive: true });
5368
+ writeFileSync13(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
3534
5369
  }
3535
5370
  function addJob(job) {
3536
5371
  const jobs = loadJobs();
@@ -3575,6 +5410,10 @@ function resolveHoltPath() {
3575
5410
  function buildCommand(job, holtPath) {
3576
5411
  const holt = shQuote(holtPath);
3577
5412
  const ws = shQuote(job.workspace);
5413
+ if (job.runArgs && job.runArgs.length > 0) {
5414
+ const parts = job.runArgs.map(shQuote).join(" ");
5415
+ return `cd ${ws} && ${holt} ${parts} --quiet`;
5416
+ }
3578
5417
  const log = shQuote(logPath(job.id));
3579
5418
  let cmd = `cd ${ws} && ${holt} run ${shQuote(job.task)}`;
3580
5419
  if (job.brain) cmd += ` --brain ${shQuote(job.brain)}`;
@@ -3634,7 +5473,7 @@ function appendCronLine(crontab, line) {
3634
5473
  return (base ? base + "\n" : "") + line + "\n";
3635
5474
  }
3636
5475
  function ensureLogsDir() {
3637
- mkdirSync8(logsDir(), { recursive: true });
5476
+ mkdirSync11(logsDir(), { recursive: true });
3638
5477
  }
3639
5478
 
3640
5479
  // src/commands/schedule.ts
@@ -3660,8 +5499,8 @@ function parseAdd(rest) {
3660
5499
  }
3661
5500
  function installDarwin(job, holtPath) {
3662
5501
  const path = plistPath(job.id);
3663
- mkdirSync9(dirname3(path), { recursive: true });
3664
- writeFileSync11(path, buildLaunchdPlist(job, holtPath), "utf8");
5502
+ mkdirSync12(dirname4(path), { recursive: true });
5503
+ writeFileSync14(path, buildLaunchdPlist(job, holtPath), "utf8");
3665
5504
  const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
3666
5505
  if (res.status === 0) {
3667
5506
  console.log(c.green(" Loaded into launchd."));
@@ -3690,114 +5529,576 @@ function installLinux(job, holtPath) {
3690
5529
  console.log(c.dim(" Could not update the crontab automatically. Add this line yourself:"));
3691
5530
  console.log(" " + line);
3692
5531
  }
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'));
5532
+ }
5533
+ async function addCmd(rest) {
5534
+ const { task, when, opts } = parseAdd(rest);
5535
+ if (!task || !when) {
5536
+ console.log(c.dim('\n Usage: holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]\n'));
5537
+ process.exitCode = 1;
5538
+ return;
5539
+ }
5540
+ try {
5541
+ parseWhen(when);
5542
+ } catch (e) {
5543
+ console.error("\n " + c.red(e.message) + "\n");
5544
+ process.exitCode = 1;
5545
+ return;
5546
+ }
5547
+ const ws = workspace();
5548
+ const holtPath = resolveHoltPath();
5549
+ const job = {
5550
+ id: newJobId(),
5551
+ name: opts.name || task.slice(0, 40),
5552
+ task,
5553
+ when,
5554
+ workspace: ws,
5555
+ notify: opts.notify,
5556
+ ...opts.brain ? { brain: opts.brain } : {}
5557
+ };
5558
+ ensureLogsDir();
5559
+ addJob(job);
5560
+ console.log("\n" + c.accent("Scheduled."));
5561
+ console.log(` id ${job.id}`);
5562
+ console.log(` when daily at ${job.when}`);
5563
+ console.log(` workspace ${job.workspace}`);
5564
+ if (job.notify) console.log(` notify on (pushes output to Telegram)`);
5565
+ console.log(` log ${logPath(job.id)}`);
5566
+ if (process.platform === "darwin") installDarwin(job, holtPath);
5567
+ else if (process.platform === "linux") installLinux(job, holtPath);
5568
+ else {
5569
+ console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
5570
+ console.log(" " + buildCronLine(job, holtPath));
5571
+ }
5572
+ console.log("");
5573
+ }
5574
+ function listCmd2() {
5575
+ const jobs = loadJobs();
5576
+ if (jobs.length === 0) {
5577
+ console.log(c.dim("\n No scheduled tasks yet. Add one with:"));
5578
+ console.log(c.dim(' holt schedule add "<task>" <HH:MM> [--notify]\n'));
5579
+ return;
5580
+ }
5581
+ console.log("\n" + c.accent("Scheduled tasks"));
5582
+ for (const j of jobs) {
5583
+ console.log(` ${c.bold(j.id)} daily ${j.when} ${c.dim(j.notify ? "(notify)" : "")}`);
5584
+ console.log(` ${j.name}`);
5585
+ console.log(c.dim(` ${j.workspace}`));
5586
+ }
5587
+ console.log("");
5588
+ }
5589
+ function removeDarwin(id) {
5590
+ const path = plistPath(id);
5591
+ if (existsSync14(path)) {
5592
+ spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
5593
+ try {
5594
+ rmSync6(path);
5595
+ } catch {
5596
+ }
5597
+ }
5598
+ }
5599
+ function removeLinux(id) {
5600
+ const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
5601
+ const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
5602
+ const next = stripCronLines(body, id);
5603
+ spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
5604
+ }
5605
+ function removeCmd(rest) {
5606
+ const id = rest[0];
5607
+ if (!id) {
5608
+ console.log(c.dim("\n Usage: holt schedule remove <id> (see ids with: holt schedule list)\n"));
5609
+ process.exitCode = 1;
5610
+ return;
5611
+ }
5612
+ const before = loadJobs();
5613
+ const existed = before.some((j) => j.id === id);
5614
+ removeJob(id);
5615
+ if (process.platform === "darwin") removeDarwin(id);
5616
+ else if (process.platform === "linux") removeLinux(id);
5617
+ if (existed) console.log("\n" + c.green(` Removed schedule ${id}.`) + "\n");
5618
+ else {
5619
+ console.error(c.dim(`
5620
+ No schedule with id ${id}. Nothing to remove.
5621
+ `));
5622
+ process.exitCode = 1;
5623
+ }
5624
+ }
5625
+ function usage2() {
5626
+ console.log("\n" + c.accent("holt schedule") + c.dim(" run a task automatically on a timer"));
5627
+ console.log(c.dim('\n holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]'));
5628
+ console.log(c.dim(" holt schedule list"));
5629
+ console.log(c.dim(" holt schedule remove <id>\n"));
5630
+ }
5631
+ async function schedule(sub, rest = []) {
5632
+ const action = (sub || "").toLowerCase();
5633
+ if (action === "list") {
5634
+ listCmd2();
5635
+ return;
5636
+ }
5637
+ if (action !== "add" && action !== "remove" && action !== "rm") {
5638
+ if (action) process.exitCode = 1;
5639
+ usage2();
5640
+ return;
5641
+ }
5642
+ const { ask, close } = createReader();
5643
+ if (!await ensureTrusted(ask)) {
5644
+ close();
5645
+ return;
5646
+ }
5647
+ close();
5648
+ if (action === "add") await addCmd(rest);
5649
+ else removeCmd(rest);
5650
+ }
5651
+
5652
+ // src/commands/routine.ts
5653
+ import { spawnSync as spawnSync5 } from "child_process";
5654
+ import { writeFileSync as writeFileSync16, mkdirSync as mkdirSync14, rmSync as rmSync7, existsSync as existsSync15 } from "fs";
5655
+ import { dirname as dirname5, resolve as resolve5 } from "path";
5656
+
5657
+ // src/routines.ts
5658
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync15, mkdirSync as mkdirSync13 } from "fs";
5659
+ import { join as join15 } from "path";
5660
+ function sanitizeRoutineName(name) {
5661
+ return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
5662
+ }
5663
+ function routinesPath() {
5664
+ return join15(GLOBAL_DIR, "routines.json");
5665
+ }
5666
+ function loadRoutines() {
5667
+ try {
5668
+ const raw = JSON.parse(readFileSync15(routinesPath(), "utf8"));
5669
+ return Array.isArray(raw) ? raw : [];
5670
+ } catch {
5671
+ return [];
5672
+ }
5673
+ }
5674
+ function saveRoutines(routines) {
5675
+ mkdirSync13(GLOBAL_DIR, { recursive: true });
5676
+ writeFileSync15(routinesPath(), JSON.stringify(routines, null, 2) + "\n", "utf8");
5677
+ }
5678
+ function findRoutine(name) {
5679
+ const want = sanitizeRoutineName(name);
5680
+ return loadRoutines().find((r) => r.name === want);
5681
+ }
5682
+ function upsertRoutine(routine2) {
5683
+ const routines = loadRoutines().filter((r) => r.name !== routine2.name);
5684
+ routines.push(routine2);
5685
+ saveRoutines(routines);
5686
+ return routines;
5687
+ }
5688
+ function removeRoutine(name) {
5689
+ const want = sanitizeRoutineName(name);
5690
+ const routines = loadRoutines().filter((r) => r.name !== want);
5691
+ saveRoutines(routines);
5692
+ return routines;
5693
+ }
5694
+ var ROUTINE_TEMPLATES = {
5695
+ "daily-brief": {
5696
+ describe: "A short daily brief of what changed and what is open in this folder, pushed to Telegram.",
5697
+ source: {
5698
+ kind: "task",
5699
+ 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."
5700
+ },
5701
+ when: "08:00",
5702
+ notify: true
5703
+ },
5704
+ standup: {
5705
+ describe: "A daily standup: what got done, what is next, and any blockers in this folder.",
5706
+ source: {
5707
+ kind: "task",
5708
+ value: "Write a short standup for this folder: what got done recently, what is next, and any blockers. Three short sections, plain text."
5709
+ },
5710
+ when: "09:00",
5711
+ notify: true
5712
+ }
5713
+ };
5714
+ function templateNames() {
5715
+ return Object.keys(ROUTINE_TEMPLATES).sort();
5716
+ }
5717
+
5718
+ // src/commands/routine.ts
5719
+ function jobForRoutine(r) {
5720
+ return {
5721
+ id: routineJobId(r.name),
5722
+ name: `routine:${r.name}`,
5723
+ task: r.source.kind === "task" ? r.source.value : `skill:${r.source.value}`,
5724
+ when: r.when,
5725
+ workspace: r.workspace,
5726
+ notify: r.notify,
5727
+ ...r.brain ? { brain: r.brain } : {},
5728
+ runArgs: ["routine", "run", r.name]
5729
+ };
5730
+ }
5731
+ function routineJobId(name) {
5732
+ return `routine-${sanitizeRoutineName(name)}`;
5733
+ }
5734
+ function installDarwin2(job, holtPath) {
5735
+ const path = plistPath(job.id);
5736
+ mkdirSync14(dirname5(path), { recursive: true });
5737
+ writeFileSync16(path, buildLaunchdPlist(job, holtPath), "utf8");
5738
+ const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
5739
+ if (res.status === 0) {
5740
+ console.log(c.green(" Loaded into launchd."));
5741
+ } else {
5742
+ console.log(c.dim(" Wrote the plist, but launchctl load did not confirm."));
5743
+ console.log(c.dim(` Load it yourself with: launchctl load -w ${path}`));
5744
+ if (res.stderr && res.stderr.trim()) console.log(c.dim(` (${res.stderr.trim()})`));
5745
+ }
5746
+ console.log(c.dim(` plist: ${path}`));
5747
+ }
5748
+ function currentCrontab2() {
5749
+ const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
5750
+ return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
5751
+ }
5752
+ function writeCrontab2(body) {
5753
+ const res = spawnSync5("crontab", ["-"], { input: body, encoding: "utf8" });
5754
+ return res.status === 0;
5755
+ }
5756
+ function installLinux2(job, holtPath) {
5757
+ const line = buildCronLine(job, holtPath);
5758
+ const next = appendCronLine(currentCrontab2(), line);
5759
+ if (writeCrontab2(next)) {
5760
+ console.log(c.green(" Added to your crontab."));
5761
+ console.log(c.dim(` line: ${line}`));
5762
+ } else {
5763
+ console.log(c.dim(" Could not update the crontab automatically. Add this line yourself:"));
5764
+ console.log(" " + line);
5765
+ }
5766
+ }
5767
+ function installSchedule(r) {
5768
+ const holtPath = resolveHoltPath();
5769
+ const job = jobForRoutine(r);
5770
+ removeSchedule(r.name);
5771
+ addJob(job);
5772
+ if (process.platform === "darwin") installDarwin2(job, holtPath);
5773
+ else if (process.platform === "linux") installLinux2(job, holtPath);
5774
+ else {
5775
+ console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
5776
+ console.log(" " + buildCronLine(job, holtPath));
5777
+ }
5778
+ }
5779
+ function removeScheduleDarwin(id) {
5780
+ const path = plistPath(id);
5781
+ if (existsSync15(path)) {
5782
+ spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
5783
+ try {
5784
+ rmSync7(path);
5785
+ } catch {
5786
+ }
5787
+ }
5788
+ }
5789
+ function removeScheduleLinux(id) {
5790
+ const body = currentCrontab2();
5791
+ const next = stripCronLines(body, id);
5792
+ writeCrontab2(next);
5793
+ }
5794
+ function removeSchedule(name) {
5795
+ const id = routineJobId(name);
5796
+ const existed = loadJobs().some((j) => j.id === id);
5797
+ removeJob(id);
5798
+ if (!existed) return;
5799
+ if (process.platform === "darwin") removeScheduleDarwin(id);
5800
+ else if (process.platform === "linux") removeScheduleLinux(id);
5801
+ }
5802
+ function parseAdd2(rest) {
5803
+ const opts = { notify: false };
5804
+ let name;
5805
+ for (let i = 0; i < rest.length; i++) {
5806
+ const a = rest[i];
5807
+ if (a === void 0) continue;
5808
+ if (a === "--notify") opts.notify = true;
5809
+ else if (a === "--skill") opts.skill = rest[++i];
5810
+ else if (a === "--task") opts.task = rest[++i];
5811
+ else if (a === "--template") opts.template = rest[++i];
5812
+ else if (a === "--at") opts.when = rest[++i];
5813
+ else if (a === "--out") opts.out = rest[++i];
5814
+ else if (a === "--brain") opts.brain = rest[++i];
5815
+ else if (a.startsWith("--")) {
5816
+ const eq = a.indexOf("=");
5817
+ if (eq > 2) {
5818
+ const k = a.slice(2, eq);
5819
+ const v = a.slice(eq + 1);
5820
+ if (k === "skill") opts.skill = v;
5821
+ else if (k === "task") opts.task = v;
5822
+ else if (k === "template") opts.template = v;
5823
+ else if (k === "at") opts.when = v;
5824
+ else if (k === "out") opts.out = v;
5825
+ else if (k === "brain") opts.brain = v;
5826
+ }
5827
+ } else if (name === void 0) name = a;
5828
+ }
5829
+ return { name, opts };
5830
+ }
5831
+ async function addCmd2(rest) {
5832
+ const { name: rawName, opts } = parseAdd2(rest);
5833
+ const name = sanitizeRoutineName(rawName || "");
5834
+ if (!name) {
5835
+ usage3();
5836
+ process.exitCode = 1;
5837
+ return;
5838
+ }
5839
+ let source;
5840
+ let when = opts.when;
5841
+ let notify2 = opts.notify;
5842
+ if (opts.template) {
5843
+ const tpl = ROUTINE_TEMPLATES[opts.template];
5844
+ if (!tpl) {
5845
+ console.error(
5846
+ "\n " + c.red(`Unknown template "${opts.template}".`) + c.dim(` Available: ${templateNames().join(", ")}`) + "\n"
5847
+ );
5848
+ process.exitCode = 1;
5849
+ return;
5850
+ }
5851
+ source = { ...tpl.source };
5852
+ if (when === void 0) when = tpl.when;
5853
+ if (!opts.notify) notify2 = tpl.notify;
5854
+ } else if (opts.skill !== void 0 && opts.task !== void 0) {
5855
+ console.error("\n " + c.red("Give either --skill or --task, not both.") + "\n");
5856
+ process.exitCode = 1;
5857
+ return;
5858
+ } else if (opts.skill !== void 0) {
5859
+ const loaded = loadSkill(opts.skill);
5860
+ if (!loaded) {
5861
+ console.error("\n " + c.red(`No skill named "${opts.skill}".`) + c.dim(' Try "holt skill list".') + "\n");
5862
+ process.exitCode = 1;
5863
+ return;
5864
+ }
5865
+ source = { kind: "skill", value: loaded.skill.name };
5866
+ } else if (opts.task !== void 0 && opts.task.trim()) {
5867
+ source = { kind: "task", value: opts.task.trim() };
5868
+ } else {
5869
+ console.error("\n " + c.red('A routine needs a source: give --skill <s>, --task "<p>", or --template <t>.') + "\n");
5870
+ process.exitCode = 1;
5871
+ return;
5872
+ }
5873
+ if (when !== void 0) {
5874
+ try {
5875
+ parseWhen(when);
5876
+ } catch (e) {
5877
+ console.error("\n " + c.red(e.message) + "\n");
5878
+ process.exitCode = 1;
5879
+ return;
5880
+ }
5881
+ }
5882
+ const routine2 = {
5883
+ name,
5884
+ source,
5885
+ notify: notify2,
5886
+ workspace: workspace(),
5887
+ ...when !== void 0 ? { when } : {},
5888
+ ...opts.out ? { out: opts.out } : {},
5889
+ ...opts.brain ? { brain: opts.brain } : {}
5890
+ };
5891
+ upsertRoutine(routine2);
5892
+ if (routine2.when) installSchedule(routine2);
5893
+ console.log("\n" + c.accent("Routine saved."));
5894
+ printRoutine(routine2, " ");
5895
+ if (!routine2.when) console.log(c.dim(`
5896
+ Run it with: holt routine run ${routine2.name}`));
5897
+ console.log("");
5898
+ }
5899
+ function buildRoutinePrompt(r) {
5900
+ if (r.source.kind === "task") return { prompt: r.source.value };
5901
+ const loaded = loadSkill(r.source.value);
5902
+ if (!loaded) return { error: `Skill "${r.source.value}" is no longer installed. Try "holt skill list".` };
5903
+ const prompt = [
5904
+ loaded.body,
5905
+ "",
5906
+ "Apply the skill above to this request:",
5907
+ "Run this skill now for the current folder."
5908
+ ].join("\n");
5909
+ return { prompt };
5910
+ }
5911
+ async function runCmd(rest) {
5912
+ let quiet = false;
5913
+ let outOverride;
5914
+ let notifyOverride = false;
5915
+ let brainOverride;
5916
+ let name = "";
5917
+ for (let i = 0; i < rest.length; i++) {
5918
+ const a = rest[i];
5919
+ if (a === void 0) continue;
5920
+ if (a === "--quiet") quiet = true;
5921
+ else if (a === "--notify") notifyOverride = true;
5922
+ else if (a === "--out") outOverride = rest[++i];
5923
+ else if (a === "--brain") brainOverride = rest[++i];
5924
+ else if (!a.startsWith("--") && !name) name = sanitizeRoutineName(a);
5925
+ }
5926
+ if (!name) {
5927
+ console.log(c.dim("\n Usage: holt routine run <name> [--quiet] [--out <file>] [--notify] [--brain <id>]\n"));
5928
+ process.exitCode = 1;
5929
+ return;
5930
+ }
5931
+ const routine2 = findRoutine(name);
5932
+ if (!routine2) {
5933
+ process.stderr.write(c.red(`No routine named "${name}". See "holt routine list".`) + "\n");
5934
+ process.exitCode = 1;
5935
+ return;
5936
+ }
5937
+ if (!isTrusted()) {
5938
+ if (process.stdin.isTTY) {
5939
+ const { ask, close } = createReader();
5940
+ const ok2 = await ensureTrusted(ask);
5941
+ close();
5942
+ if (!ok2) return;
5943
+ } else {
5944
+ process.stderr.write("This folder is not trusted. Run holt init or holt chat here once, then run the routine.\n");
5945
+ process.exitCode = 1;
5946
+ return;
5947
+ }
5948
+ }
5949
+ const built = buildRoutinePrompt(routine2);
5950
+ if ("error" in built) {
5951
+ process.stderr.write(c.red(built.error) + "\n");
3698
5952
  process.exitCode = 1;
3699
5953
  return;
3700
5954
  }
3701
- try {
3702
- parseWhen(when);
3703
- } catch (e) {
3704
- console.error("\n " + c.red(e.message) + "\n");
5955
+ const brainId = brainOverride ?? routine2.brain;
5956
+ const outTarget = outOverride ?? routine2.out;
5957
+ const wantNotify = notifyOverride || routine2.notify;
5958
+ let streamed = false;
5959
+ const result = await runTask(built.prompt, {
5960
+ ...brainId ? { brainId } : {},
5961
+ onChunk: quiet ? void 0 : (ch) => {
5962
+ streamed = true;
5963
+ process.stdout.write(ch);
5964
+ }
5965
+ });
5966
+ if (!result.ok) {
5967
+ if (streamed) process.stdout.write("\n");
5968
+ process.stderr.write(c.red(result.text) + "\n");
3705
5969
  process.exitCode = 1;
3706
5970
  return;
3707
5971
  }
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));
5972
+ if (!quiet && !streamed) process.stdout.write(result.text);
5973
+ if (!quiet && (streamed || result.text) && !result.text.endsWith("\n")) process.stdout.write("\n");
5974
+ if (outTarget) {
5975
+ const target = resolve5(routine2.workspace, outTarget);
5976
+ try {
5977
+ writeFileSync16(target, result.text, "utf8");
5978
+ if (!quiet) process.stderr.write(c.dim(`saved to ${target}`) + "\n");
5979
+ } catch (e) {
5980
+ process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
5981
+ process.exitCode = 1;
5982
+ }
5983
+ }
5984
+ if (wantNotify) {
5985
+ if (!loadTelegramConfig()) {
5986
+ process.stderr.write(c.dim('Telegram is not set up; skipping notify. Run "holt telegram setup".') + "\n");
5987
+ } else {
5988
+ const sent = await sendMessage(result.text);
5989
+ if (!sent) process.stderr.write(c.dim("Could not push the result to Telegram.") + "\n");
5990
+ }
3732
5991
  }
3733
- console.log("");
3734
5992
  }
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'));
5993
+ function sourceLabel(s) {
5994
+ return s.kind === "skill" ? `skill:${s.value}` : `task: ${s.value.length > 46 ? s.value.slice(0, 45) + "\u2026" : s.value}`;
5995
+ }
5996
+ function outputsLabel(r) {
5997
+ const outs = [];
5998
+ if (r.notify) outs.push("notify");
5999
+ if (r.out) outs.push(`out:${r.out}`);
6000
+ if (outs.length === 0) outs.push("stdout");
6001
+ return outs.join(" ");
6002
+ }
6003
+ function listCmd3() {
6004
+ const routines = loadRoutines();
6005
+ if (routines.length === 0) {
6006
+ console.log(c.dim("\n No routines yet. Add one with:"));
6007
+ console.log(c.dim(' holt routine add <name> (--skill <s> | --task "<p>") [--at HH:MM] [--notify] [--out <file>]\n'));
3740
6008
  return;
3741
6009
  }
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}`));
6010
+ console.log("\n" + c.accent("Routines"));
6011
+ for (const r of routines) {
6012
+ const sched = r.when ? `daily ${r.when}` : "manual";
6013
+ console.log(` ${c.bold(r.name)} ${c.dim(sched)} ${c.dim(outputsLabel(r))}`);
6014
+ console.log(c.dim(` ${sourceLabel(r.source)}`));
6015
+ console.log(c.dim(` ${r.workspace}`));
6016
+ }
6017
+ console.log(c.dim("\n holt routine show <name> full detail"));
6018
+ console.log(c.dim(" holt routine run <name> run it now\n"));
6019
+ }
6020
+ function printRoutine(r, pad) {
6021
+ console.log(`${pad}name ${r.name}`);
6022
+ console.log(`${pad}source ${sourceLabel(r.source)}`);
6023
+ console.log(`${pad}schedule ${r.when ? `daily at ${r.when}` : "manual (run on demand)"}`);
6024
+ console.log(`${pad}outputs ${outputsLabel(r)}`);
6025
+ if (r.brain) console.log(`${pad}brain ${r.brain}`);
6026
+ console.log(`${pad}workspace ${r.workspace}`);
6027
+ }
6028
+ function showCmd2(rest) {
6029
+ const name = sanitizeRoutineName(rest.find((a) => !a.startsWith("--")) || "");
6030
+ if (!name) {
6031
+ console.log(c.dim("\n Usage: holt routine show <name>\n"));
6032
+ process.exitCode = 1;
6033
+ return;
3747
6034
  }
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
- }
6035
+ const r = findRoutine(name);
6036
+ if (!r) {
6037
+ console.error(c.dim(`
6038
+ No routine named "${name}". See "holt routine list".
6039
+ `));
6040
+ process.exitCode = 1;
6041
+ return;
3758
6042
  }
6043
+ console.log("\n" + c.accent(r.name));
6044
+ printRoutine(r, " ");
6045
+ if (r.source.kind === "task") {
6046
+ console.log(c.dim("\n task:"));
6047
+ console.log(" " + r.source.value);
6048
+ }
6049
+ console.log("");
3759
6050
  }
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"));
6051
+ function removeCmd2(rest) {
6052
+ const name = sanitizeRoutineName(rest.find((a) => !a.startsWith("--")) || "");
6053
+ if (!name) {
6054
+ console.log(c.dim("\n Usage: holt routine remove <name>\n"));
3770
6055
  process.exitCode = 1;
3771
6056
  return;
3772
6057
  }
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");
6058
+ const existed = loadRoutines().some((r) => r.name === name);
6059
+ removeSchedule(name);
6060
+ removeRoutine(name);
6061
+ if (existed) console.log("\n" + c.green(` Removed routine ${name}.`) + "\n");
3779
6062
  else {
3780
6063
  console.error(c.dim(`
3781
- No schedule with id ${id}. Nothing to remove.
6064
+ No routine named "${name}". Nothing to remove.
3782
6065
  `));
3783
6066
  process.exitCode = 1;
3784
6067
  }
3785
6068
  }
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"));
6069
+ function usage3() {
6070
+ console.log("\n" + c.accent("holt routine") + c.dim(" named, reusable, scheduled jobs"));
6071
+ console.log(c.dim('\n holt routine add <name> (--skill <s> | --task "<p>") [--at HH:MM] [--notify] [--out <file>] [--brain <id>]'));
6072
+ console.log(c.dim(` holt routine add <name> --template <t> (templates: ${templateNames().join(", ")})`));
6073
+ console.log(c.dim(" holt routine run <name> [--quiet]"));
6074
+ console.log(c.dim(" holt routine list"));
6075
+ console.log(c.dim(" holt routine show <name>"));
6076
+ console.log(c.dim(" holt routine remove <name>"));
6077
+ console.log(c.dim("\n A routine = a task source (skill or inline prompt) + an optional daily"));
6078
+ console.log(c.dim(" schedule + output routing (stdout, --out file, or --notify to Telegram).\n"));
3791
6079
  }
3792
- async function schedule(sub, rest = []) {
6080
+ async function routine(sub, rest = []) {
3793
6081
  const action = (sub || "").toLowerCase();
3794
- if (action === "list") {
3795
- listCmd();
6082
+ if (action === "list" || action === "ls") {
6083
+ listCmd3();
6084
+ return;
6085
+ }
6086
+ if (action === "show" || action === "view") {
6087
+ showCmd2(rest);
6088
+ return;
6089
+ }
6090
+ if (!action) {
6091
+ usage3();
6092
+ listCmd3();
6093
+ return;
6094
+ }
6095
+ if (action === "run") {
6096
+ await runCmd(rest);
3796
6097
  return;
3797
6098
  }
3798
6099
  if (action !== "add" && action !== "remove" && action !== "rm") {
3799
- if (action) process.exitCode = 1;
3800
- usage2();
6100
+ process.exitCode = 1;
6101
+ usage3();
3801
6102
  return;
3802
6103
  }
3803
6104
  const { ask, close } = createReader();
@@ -3806,12 +6107,12 @@ async function schedule(sub, rest = []) {
3806
6107
  return;
3807
6108
  }
3808
6109
  close();
3809
- if (action === "add") await addCmd(rest);
3810
- else removeCmd(rest);
6110
+ if (action === "add") await addCmd2(rest);
6111
+ else removeCmd2(rest);
3811
6112
  }
3812
6113
 
3813
6114
  // src/commands/notify.ts
3814
- import { readFileSync as readFileSync10 } from "fs";
6115
+ import { readFileSync as readFileSync16 } from "fs";
3815
6116
  async function notify(args) {
3816
6117
  const nonFlag = args.filter((a) => a !== "-" && !a.startsWith("--"));
3817
6118
  const wantsStdin = args.includes("-") || !process.stdin.isTTY;
@@ -3819,7 +6120,7 @@ async function notify(args) {
3819
6120
  if (nonFlag.length > 0 && !args.includes("-")) {
3820
6121
  message = nonFlag.join(" ").trim();
3821
6122
  } else if (wantsStdin) {
3822
- message = readStdin().trim();
6123
+ message = readStdin2().trim();
3823
6124
  } else if (nonFlag.length > 0) {
3824
6125
  message = nonFlag.join(" ").trim();
3825
6126
  }
@@ -3842,14 +6143,409 @@ async function notify(args) {
3842
6143
  process.exitCode = 1;
3843
6144
  }
3844
6145
  }
3845
- function readStdin() {
6146
+ function readStdin2() {
3846
6147
  try {
3847
- return readFileSync10(0, "utf8");
6148
+ return readFileSync16(0, "utf8");
3848
6149
  } catch {
3849
6150
  return "";
3850
6151
  }
3851
6152
  }
3852
6153
 
6154
+ // src/specs.ts
6155
+ import os from "os";
6156
+ var BYTES_PER_GB = 1024 ** 3;
6157
+ function toGB(bytes) {
6158
+ if (!Number.isFinite(bytes) || bytes <= 0) return 0;
6159
+ return Math.round(bytes / BYTES_PER_GB);
6160
+ }
6161
+ function detect() {
6162
+ let platform = "unknown";
6163
+ let arch = "unknown";
6164
+ let cpuModel = "unknown";
6165
+ let cpuCount = 0;
6166
+ let totalRamGB = 0;
6167
+ let freeRamGB = 0;
6168
+ let nodeVersion = "unknown";
6169
+ try {
6170
+ platform = os.platform();
6171
+ } catch {
6172
+ }
6173
+ try {
6174
+ arch = os.arch();
6175
+ } catch {
6176
+ }
6177
+ try {
6178
+ const cpus = os.cpus();
6179
+ if (Array.isArray(cpus) && cpus.length) {
6180
+ cpuCount = cpus.length;
6181
+ const first = cpus[0];
6182
+ if (first && typeof first.model === "string" && first.model.trim()) {
6183
+ cpuModel = first.model.trim();
6184
+ }
6185
+ }
6186
+ } catch {
6187
+ }
6188
+ try {
6189
+ totalRamGB = toGB(os.totalmem());
6190
+ } catch {
6191
+ }
6192
+ try {
6193
+ freeRamGB = toGB(os.freemem());
6194
+ } catch {
6195
+ }
6196
+ try {
6197
+ if (typeof process.version === "string" && process.version) nodeVersion = process.version;
6198
+ } catch {
6199
+ }
6200
+ return { platform, arch, cpuModel, cpuCount, totalRamGB, freeRamGB, nodeVersion };
6201
+ }
6202
+ var LOCAL_MODEL_RECS = [
6203
+ {
6204
+ minRamGB: 48,
6205
+ rec: {
6206
+ local: true,
6207
+ model: "qwen2.5:32b",
6208
+ size: "~20GB",
6209
+ note: "Plenty of headroom. A 32B model runs comfortably and gives the best local quality."
6210
+ }
6211
+ },
6212
+ {
6213
+ minRamGB: 24,
6214
+ rec: {
6215
+ local: true,
6216
+ model: "qwen2.5:14b",
6217
+ size: "~9GB",
6218
+ note: "A 14B model fits well here and is a solid local maintainer."
6219
+ }
6220
+ },
6221
+ {
6222
+ minRamGB: 16,
6223
+ rec: {
6224
+ local: true,
6225
+ model: "qwen2.5:7b",
6226
+ alt: "llama3.1:8b",
6227
+ size: "~4.7GB",
6228
+ note: "A 7B model works but is tight alongside the embed model and your editor. Prefer running it on your always-on machine."
6229
+ }
6230
+ },
6231
+ {
6232
+ minRamGB: 0,
6233
+ rec: {
6234
+ // Discouraged below 16GB: local generation is too modest to be worth it
6235
+ // for the wiki maintainer. The small model stays named as the only viable
6236
+ // option if you insist, but local is off by default here.
6237
+ local: false,
6238
+ model: "llama3.2:3b",
6239
+ size: "~2GB",
6240
+ 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.'
6241
+ }
6242
+ }
6243
+ ];
6244
+ function recommendLocalModel2(totalRamGB) {
6245
+ const ram = Number.isFinite(totalRamGB) && totalRamGB > 0 ? totalRamGB : 0;
6246
+ for (const tier of LOCAL_MODEL_RECS) {
6247
+ if (ram >= tier.minRamGB) return tier.rec;
6248
+ }
6249
+ return {
6250
+ local: false,
6251
+ model: null,
6252
+ note: 'Could not read RAM. Use the hosted "brain" maintainer to be safe.'
6253
+ };
6254
+ }
6255
+
6256
+ // src/commands/doctor.ts
6257
+ var OLLAMA_URL3 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
6258
+ function header(title) {
6259
+ console.log("\n" + c.accent(title));
6260
+ }
6261
+ function row(label, value) {
6262
+ console.log(` ${label.padEnd(12)}${value}`);
6263
+ }
6264
+ function note(text) {
6265
+ console.log(" " + c.dim(text));
6266
+ }
6267
+ function good(text) {
6268
+ console.log(" " + c.green(text));
6269
+ }
6270
+ function warn(text) {
6271
+ console.log(" " + c.red(text));
6272
+ }
6273
+ async function doctor() {
6274
+ const specs = detect();
6275
+ const steps = [];
6276
+ console.log("\n" + c.bold("Holt doctor") + c.dim(" a look at this machine and how best to run Holt on it"));
6277
+ header("Machine");
6278
+ row("platform", `${specs.platform} / ${specs.arch}`);
6279
+ row("cpu", `${specs.cpuModel}${specs.cpuCount ? ` (${specs.cpuCount} cores)` : ""}`);
6280
+ row(
6281
+ "ram",
6282
+ specs.totalRamGB ? `${specs.totalRamGB} GB total, ${specs.freeRamGB} GB free` : "unknown"
6283
+ );
6284
+ row("node", specs.nodeVersion);
6285
+ header("Brains");
6286
+ const installed = [];
6287
+ for (const id of BRAIN_IDS) {
6288
+ const def = BRAIN_DEFS[id];
6289
+ const here = isInstalled(def.command);
6290
+ if (here) installed.push(def.label);
6291
+ console.log(` ${here ? c.green("installed") : c.dim("missing ")} ${def.label} ${c.dim(`(${def.command})`)}`);
6292
+ }
6293
+ const cfg = loadConfig();
6294
+ const apiBrains = cfg?.apiBrains ?? [];
6295
+ if (apiBrains.length) {
6296
+ note(`API brains configured here: ${apiBrains.map((b) => b.id).join(", ")}`);
6297
+ }
6298
+ if (installed.length) {
6299
+ good(`Recommendation: use an installed CLI brain (${installed[0]}). No API keys needed.`);
6300
+ } else if (apiBrains.length) {
6301
+ good("Recommendation: no CLI brain installed, but you have an API brain configured. That works.");
6302
+ } else {
6303
+ warn("Recommendation: no brain available. Install one CLI brain (claude, codex, or gemini) or add an API brain.");
6304
+ steps.push('Install a brain: run "holt init" (or add an API brain in "holt setting").');
6305
+ }
6306
+ header("Semantic memory");
6307
+ let embedOk = false;
6308
+ try {
6309
+ embedOk = await embeddingsAvailable();
6310
+ } catch {
6311
+ embedOk = false;
6312
+ }
6313
+ if (embedOk) {
6314
+ good(`Ollama reachable and "${EMBED_MODEL}" is present. Memory recall is semantic, private, and local.`);
6315
+ } else {
6316
+ note(`Ollama with "${EMBED_MODEL}" not reachable at ${OLLAMA_URL3}.`);
6317
+ note("Without it, Holt still remembers, but recall falls back to keyword match.");
6318
+ warn(`Recommendation: enable local embeddings. Install Ollama, then: ollama pull ${EMBED_MODEL}`);
6319
+ steps.push(`Enable semantic memory: ollama pull ${EMBED_MODEL}`);
6320
+ }
6321
+ header("Knowledge wiki maintainer");
6322
+ const rec = recommendLocalModel2(specs.totalRamGB);
6323
+ note('Holt can keep your knowledge wiki with a hosted "brain" (rides your Claude plan) or a local model.');
6324
+ good('Default recommendation: "brain" (best quality, no extra RAM, uses the brain you already have).');
6325
+ if (rec.local && rec.model) {
6326
+ const alt = rec.alt ? ` (or ${rec.alt})` : "";
6327
+ console.log(
6328
+ " " + c.cyan("local option: ") + `${rec.model}${alt}${rec.size ? c.dim(` ${rec.size}`) : ""} for this ${specs.totalRamGB || "?"} GB machine.`
6329
+ );
6330
+ note(rec.note);
6331
+ note(`To use it locally: ollama pull ${rec.model}`);
6332
+ } else {
6333
+ note(rec.note);
6334
+ if (rec.model) note(`If you still want local: ollama pull ${rec.model} (${rec.size ?? "small"}).`);
6335
+ }
6336
+ header("Always-on / Telegram");
6337
+ const tg = loadTelegramConfig();
6338
+ if (tg) {
6339
+ good("Telegram bot is configured. You can chat with Holt from your phone.");
6340
+ } else {
6341
+ note("No Telegram bot configured. Set one up to reach Holt from your phone: holt telegram setup");
6342
+ }
6343
+ note("Hosting tip: a low-power always-on machine (an old laptop, a Pi) is ideal for the bot and scheduled runs.");
6344
+ note("Heavy local models want more RAM, so keep those on your bigger machine and let the small box relay.");
6345
+ header("Recommended next steps");
6346
+ if (!cfg) {
6347
+ steps.unshift('Set up this folder: run "holt init".');
6348
+ }
6349
+ if (steps.length === 0) {
6350
+ good('You are in good shape. Run "holt chat" to start.');
6351
+ } else {
6352
+ let n = 1;
6353
+ for (const s of steps) console.log(` ${c.accent(String(n++) + ".")} ${s}`);
6354
+ }
6355
+ console.log("");
6356
+ }
6357
+
6358
+ // src/commands/write.ts
6359
+ import { writeFileSync as writeFileSync17 } from "fs";
6360
+ import { resolve as resolve6 } from "path";
6361
+
6362
+ // src/antiai.ts
6363
+ var EM_DASH = String.fromCharCode(8212);
6364
+ var ANTI_AI_RULES = [
6365
+ "No em-dashes anywhere. Use a comma, colon, period, or parentheses instead.",
6366
+ 'No "rule of three" tricolons or parallel three-beat lists for drama.',
6367
+ `No "in today's fast-paced world" or "in an era of" style openers.`,
6368
+ 'No "it is not X, it is Y" or "not just X but Y" reframes.',
6369
+ "No tidy bow-tie conclusion that restates the thesis in one neat takeaway.",
6370
+ "No callback closer where the last line echoes or inverts the opening.",
6371
+ "Vary sentence length. Mix short and long. Avoid uniform rhythm.",
6372
+ "Prefer concrete specifics, names, and numbers over abstractions.",
6373
+ 'Cut hedging filler ("it is worth noting", "needless to say", "the truth is").',
6374
+ "Avoid corporate buzzwords (leverage, unlock, seamless, robust, holistic, synergy, elevate, streamline, empower, game-changer).",
6375
+ "Avoid performed emotion (thrilled, humbled, honored, excited to share).",
6376
+ "No emoji unless the voice profile explicitly allows it.",
6377
+ "No ALL CAPS for emphasis and no bold on adjectives or adverbs.",
6378
+ "Leave at least one thought slightly open. Do not resolve everything neatly.",
6379
+ "Write like a person talking to one smart reader, not a press release."
6380
+ ];
6381
+ function antiAiPromptBlock() {
6382
+ return [
6383
+ "ANTI-AI WRITING RULES (follow all, they matter):",
6384
+ ...ANTI_AI_RULES.map((r) => `- ${r}`),
6385
+ "",
6386
+ "The single hardest rule: the output must contain zero em-dash characters.",
6387
+ "Write plainly. If a sentence sounds like a LinkedIn influencer or a brochure, rewrite it."
6388
+ ].join("\n");
6389
+ }
6390
+ function antiAiCheckInstruction() {
6391
+ return [
6392
+ "Review the draft below against these anti-AI rules and the voice profile.",
6393
+ "Rewrite any sentence that violates them. Keep the meaning and length similar.",
6394
+ "Remove every em-dash. Output ONLY the corrected draft, no commentary.",
6395
+ "",
6396
+ antiAiPromptBlock()
6397
+ ].join("\n");
6398
+ }
6399
+ function scanAntiAi(text) {
6400
+ const flags = [];
6401
+ const t = text || "";
6402
+ if (t.includes(EM_DASH)) {
6403
+ flags.push({ rule: "no-em-dash", detail: "contains an em-dash character" });
6404
+ }
6405
+ const openers = [
6406
+ /in today'?s (fast[- ]paced|ever[- ]changing) world/i,
6407
+ /in an era of/i,
6408
+ /without further ado/i,
6409
+ /let'?s (dive|get started)/i
6410
+ ];
6411
+ for (const re of openers) {
6412
+ if (re.test(t)) flags.push({ rule: "stock-opener", detail: `matches ${re.source}` });
6413
+ }
6414
+ const buzz = ["leverage", "seamless", "seamlessly", "unlock", "game-changer", "synergy", "holistic", "elevate", "empower", "streamline"];
6415
+ for (const w of buzz) {
6416
+ const re = new RegExp(`\\b${w.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\b`, "i");
6417
+ if (re.test(t)) flags.push({ rule: "buzzword", detail: `uses "${w}"` });
6418
+ }
6419
+ return flags;
6420
+ }
6421
+ function stripEmDash(text) {
6422
+ return (text || "").split(EM_DASH).join(", ");
6423
+ }
6424
+
6425
+ // src/commands/write.ts
6426
+ var TYPES = ["linkedin", "email", "tweet", "blog", "generic"];
6427
+ var TYPE_SHAPE = {
6428
+ linkedin: "Format: a LinkedIn post. Keep it tight, a few short paragraphs. No hashtag wall.",
6429
+ email: "Format: an email. Include a subject line, then a short body. Plain and direct.",
6430
+ tweet: "Format: a single short post under 280 characters. One clear idea.",
6431
+ blog: 'Format: a short blog post with a title and a few paragraphs. No "conclusion" header.',
6432
+ generic: "Format: whatever best fits the request. Keep it concise unless asked otherwise."
6433
+ };
6434
+ var USAGE4 = [
6435
+ ' holt write "<what to write>" [options]',
6436
+ "",
6437
+ " Draft content in your saved writing voice, with anti-AI checks.",
6438
+ "",
6439
+ " options:",
6440
+ " --type <t> linkedin | email | tweet | blog | generic (default generic)",
6441
+ " --out <file> also write the draft to a file (relative to this folder)",
6442
+ " --brain <id> use a specific brain (CLI or API brain id)",
6443
+ " --fast skip the anti-AI self-check second pass",
6444
+ "",
6445
+ ' example: holt write "a linkedin post about shipping Holt" --type linkedin',
6446
+ "",
6447
+ ' Tip: build your voice first with "holt voice". Without it, a plain voice is used.'
6448
+ ].join("\n");
6449
+ function parseArgs3(args) {
6450
+ const words2 = [];
6451
+ let type = "generic";
6452
+ let out;
6453
+ let brainId;
6454
+ let fast = false;
6455
+ for (let i = 0; i < args.length; i++) {
6456
+ const a = args[i];
6457
+ if (a === "--type") {
6458
+ const t = (args[++i] ?? "").toLowerCase();
6459
+ if (TYPES.includes(t)) type = t;
6460
+ continue;
6461
+ }
6462
+ if (a === "--out") {
6463
+ out = args[++i];
6464
+ continue;
6465
+ }
6466
+ if (a === "--brain") {
6467
+ brainId = args[++i];
6468
+ continue;
6469
+ }
6470
+ if (a === "--fast") {
6471
+ fast = true;
6472
+ continue;
6473
+ }
6474
+ words2.push(a);
6475
+ }
6476
+ return { task: words2.join(" ").trim(), type, out, brainId, fast };
6477
+ }
6478
+ function buildWritePrompt(p, voiceBlock) {
6479
+ const parts = [antiAiPromptBlock(), ""];
6480
+ if (voiceBlock) {
6481
+ parts.push(voiceBlock, "");
6482
+ } else {
6483
+ parts.push("No saved voice profile. Write in a plain, natural, first-person voice.", "");
6484
+ }
6485
+ parts.push(TYPE_SHAPE[p.type], "");
6486
+ parts.push("Write the following. Output ONLY the finished piece, no preamble and no notes:");
6487
+ parts.push(p.task);
6488
+ return parts.join("\n");
6489
+ }
6490
+ async function write(args) {
6491
+ const p = parseArgs3(args);
6492
+ if (!p.task) {
6493
+ console.log(USAGE4);
6494
+ return;
6495
+ }
6496
+ if (!isTrusted()) {
6497
+ if (process.stdin.isTTY) {
6498
+ const { ask, close } = createReader();
6499
+ const ok2 = await ensureTrusted(ask);
6500
+ close();
6501
+ if (!ok2) return;
6502
+ } else {
6503
+ process.stderr.write("This folder is not trusted. Run holt init or holt chat here once first.\n");
6504
+ process.exitCode = 1;
6505
+ return;
6506
+ }
6507
+ }
6508
+ const v = loadVoice();
6509
+ const voiceBlock = voicePromptBlock(v);
6510
+ if (!v || !v.style && v.samples.length === 0 && v.answers.every((a) => !a.answer.trim())) {
6511
+ process.stderr.write(c.dim('No voice profile found, using a plain voice. Build one with "holt voice".') + "\n");
6512
+ }
6513
+ const prompt = buildWritePrompt(p, voiceBlock);
6514
+ const gen = await runTask(prompt, { brainId: p.brainId, recall: false, store: false });
6515
+ if (!gen.ok) {
6516
+ process.stderr.write(c.red(gen.text) + "\n");
6517
+ process.exitCode = 1;
6518
+ return;
6519
+ }
6520
+ let draft = gen.text;
6521
+ if (!p.fast) {
6522
+ const flagsBefore = scanAntiAi(draft);
6523
+ const checkPrompt = [
6524
+ antiAiCheckInstruction(),
6525
+ voiceBlock ? "\n" + voiceBlock : "",
6526
+ "\nDRAFT:",
6527
+ draft
6528
+ ].join("\n");
6529
+ const checked = await runTask(checkPrompt, { brainId: p.brainId, recall: false, store: false });
6530
+ if (checked.ok && checked.text.trim()) {
6531
+ const flagsAfter = scanAntiAi(checked.text);
6532
+ if (flagsAfter.length <= flagsBefore.length) draft = checked.text;
6533
+ }
6534
+ }
6535
+ draft = stripEmDash(draft).trimEnd();
6536
+ process.stdout.write(draft + "\n");
6537
+ if (p.out) {
6538
+ const target = resolve6(process.cwd(), p.out);
6539
+ try {
6540
+ writeFileSync17(target, draft + "\n", "utf8");
6541
+ process.stderr.write(c.dim(`saved to ${target}`) + "\n");
6542
+ } catch (e) {
6543
+ process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
6544
+ process.exitCode = 1;
6545
+ }
6546
+ }
6547
+ }
6548
+
3853
6549
  // src/cli.ts
3854
6550
  var BANNER = `
3855
6551
  \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 +6564,18 @@ Commands:
3868
6564
  chat Start a session. It remembers past sessions in this folder
3869
6565
  run <task> Run one task non-interactively: recall, brain executes, remember
3870
6566
  schedule Fire "holt run" on a timer: holt schedule [add | list | remove]
6567
+ routine Named, reusable, scheduled jobs: holt routine [add | run | list | remove]
3871
6568
  telegram Chat with Holt from your phone: holt telegram [setup]
3872
6569
  notify [msg] Push a message to your phone over Telegram (stdin-friendly)
6570
+ voice Teach Holt how you write: holt voice [add <file> | show | edit | clear]
6571
+ write <what> Draft content in your voice, with anti-AI checks: holt write "..." [--type]
3873
6572
  memory Inspect memory: holt memory [search <query> | embed | clear]
6573
+ wiki Your derived knowledge wiki: holt wiki [sync | rebuild | lint | list | show]
3874
6574
  graph See your memory as an interactive knowledge graph in the browser
3875
6575
  skill Manage skills: holt skill [list | show | create | add | remove]
6576
+ doctor Check this machine and recommend how best to run Holt here
3876
6577
  mcp Serve this folder's memory to Claude Code, Cursor, Codex (holt mcp setup)
6578
+ hook Ambient memory for Claude Code: holt hook [install | remove | status]
3877
6579
  setting Configure brains, API brains, and your launch command (per folder)
3878
6580
  login <brain> Sign in to a brain: claude, codex, or gemini
3879
6581
  version Print the Holt version
@@ -3916,6 +6618,9 @@ async function main() {
3916
6618
  case "memory":
3917
6619
  await memoryCmd(process.argv[3], process.argv.slice(4));
3918
6620
  break;
6621
+ case "wiki":
6622
+ await wikiCmd(process.argv[3], process.argv.slice(4));
6623
+ break;
3919
6624
  case "skill":
3920
6625
  case "skills":
3921
6626
  await skillCmd(process.argv[3], process.argv.slice(4));
@@ -3926,18 +6631,33 @@ async function main() {
3926
6631
  case "mcp":
3927
6632
  await mcp(process.argv[3], process.argv.slice(4));
3928
6633
  break;
6634
+ case "hook":
6635
+ await hook(process.argv[3], process.argv.slice(4));
6636
+ break;
3929
6637
  case "run":
3930
6638
  await run(process.argv.slice(3));
3931
6639
  break;
3932
6640
  case "schedule":
3933
6641
  await schedule(process.argv[3], process.argv.slice(4));
3934
6642
  break;
6643
+ case "routine":
6644
+ await routine(process.argv[3], process.argv.slice(4));
6645
+ break;
3935
6646
  case "telegram":
3936
6647
  await telegram(process.argv[3], process.argv.slice(4));
3937
6648
  break;
3938
6649
  case "notify":
3939
6650
  await notify(process.argv.slice(3));
3940
6651
  break;
6652
+ case "doctor":
6653
+ await doctor();
6654
+ break;
6655
+ case "voice":
6656
+ await voice(process.argv[3], process.argv.slice(4));
6657
+ break;
6658
+ case "write":
6659
+ await write(process.argv.slice(3));
6660
+ break;
3941
6661
  default:
3942
6662
  console.error(`
3943
6663
  Unknown command: "${cmd}"`);