@holt-os/holt 0.8.0 → 0.8.3

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 +14 -1
  2. package/dist/cli.js +947 -837
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -22,6 +22,11 @@ var c = {
22
22
  red: wrap("31"),
23
23
  cyan: wrap("36")
24
24
  };
25
+ function bar(fraction, width = 12) {
26
+ const f = Math.max(0, Math.min(1, fraction));
27
+ const filled = Math.round(f * width);
28
+ return c.green("\u2588".repeat(filled)) + c.dim("\u2591".repeat(width - filled));
29
+ }
25
30
  function createReader() {
26
31
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27
32
  const buffer = [];
@@ -319,8 +324,29 @@ ${END}`;
319
324
  return { ok: false, kind: "none", file, immediate: false, message: `Could not write ${file}: ${e.message}` };
320
325
  }
321
326
  }
327
+ function isValidAliasName(name) {
328
+ return /^[A-Za-z_][A-Za-z0-9_-]{0,31}$/.test(name);
329
+ }
322
330
  function installAlias(name) {
331
+ if (!isValidAliasName(name)) {
332
+ return {
333
+ ok: false,
334
+ kind: "none",
335
+ file: "",
336
+ immediate: false,
337
+ message: `"${name}" is not a valid launch command. Use a single word: a letter or _ then letters, digits, _ or - (max 32).`
338
+ };
339
+ }
323
340
  removeAlias();
341
+ if (process.platform !== "win32" && isInstalled(name)) {
342
+ return {
343
+ ok: false,
344
+ kind: "none",
345
+ file: "",
346
+ immediate: false,
347
+ message: `"${name}" is already a command on your PATH. Pick another word.`
348
+ };
349
+ }
324
350
  if (process.platform !== "win32") {
325
351
  const binDir = launcherBinDir();
326
352
  if (binDir) {
@@ -475,9 +501,10 @@ function appendTurn(t) {
475
501
  }
476
502
  function clearMemory() {
477
503
  if (existsSync4(memPath())) rmSync2(memPath());
504
+ if (existsSync4(factsMdPath())) rmSync2(factsMdPath());
478
505
  }
479
506
  function normalizeFact(s) {
480
- return s.toLowerCase().replace(/\s+/g, " ").trim().replace(/[.,;:!?]+$/, "");
507
+ return (s || "").toLowerCase().replace(/\s+/g, " ").trim().replace(/[.,;:!?]+$/, "");
481
508
  }
482
509
  var FACTS_HEADER = "# Holt facts\n\nDistilled memories for this folder. Safe to edit by hand.\n";
483
510
  async function saveFact(content, session) {
@@ -545,7 +572,7 @@ function cosine(a, b) {
545
572
  }
546
573
  function tokens(s) {
547
574
  return new Set(
548
- s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2)
575
+ (s || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2)
549
576
  );
550
577
  }
551
578
  function keywordScore(q, text) {
@@ -594,303 +621,123 @@ async function backfillEmbeddings(onProgress) {
594
621
  return { embedded, total: missing.length };
595
622
  }
596
623
 
597
- // src/commands/setting.ts
598
- function printStatus(cfg) {
599
- console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
600
- for (const id of BRAIN_IDS) {
601
- const b = cfg.brains[id];
602
- const tags = [
603
- b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
604
- cfg.defaultBrain === id ? c.accent("default") : ""
605
- ].filter(Boolean).join(" ");
606
- console.log(` ${id.padEnd(10)} ${b.label.padEnd(16)} ${tags}`);
607
- }
608
- if (cfg.apiBrains.length) {
609
- console.log(c.dim(" api brains:"));
610
- for (const a of cfg.apiBrains) {
611
- const key = resolveApiKey(a) ? c.green("key ok") : c.red("no key");
612
- const def = cfg.defaultBrain === a.id ? c.accent("default") : "";
613
- console.log(` ${a.id.padEnd(10)} ${`${a.provider}/${a.model}`.padEnd(30)} ${key} ${def}`);
614
- }
615
- }
616
- console.log(c.dim(` output format: ${cfg.outputFormat}`));
617
- console.log(c.dim(` launch command: ${currentAlias() || "holt (default)"}`));
618
- console.log("\n " + c.dim("[d] default brain [t] toggle brain [c] connect API brain [x] remove API brain [a] launch command [enter] done"));
624
+ // src/telegram.ts
625
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5, mkdirSync as mkdirSync5 } from "fs";
626
+ import { join as join5 } from "path";
627
+ var API_BASE = "https://api.telegram.org";
628
+ var CHUNK_SIZE = 4e3;
629
+ function telegramConfigPath() {
630
+ return join5(GLOBAL_DIR, "telegram.json");
619
631
  }
620
- async function connectApiBrain(ask, cfg) {
621
- let provider = null;
622
- for (let tries = 0; tries < 3 && !provider; tries++) {
623
- const provRaw = (await ask(` provider [${PROVIDERS.join("/")}] (enter for ${PROVIDERS[0]}, or "skip"): `) ?? "skip").trim().toLowerCase();
624
- if (provRaw === "skip" || provRaw === "q" || provRaw === "n" || provRaw === "no") {
625
- console.log(c.dim(' skipped. Add one later with "holt setting" then "c".'));
626
- return null;
627
- }
628
- if (provRaw === "") provider = PROVIDERS[0];
629
- else if (PROVIDERS.includes(provRaw)) provider = provRaw;
630
- else console.log(c.dim(` "${provRaw}" is not a provider. Type one of: ${PROVIDERS.join(", ")}`));
631
- }
632
- if (!provider) {
633
- console.log(c.dim(' skipped after three tries. Add one later with "holt setting" then "c".'));
634
- return null;
635
- }
636
- const suggestion = PROVIDER_MODEL_SUGGESTION[provider];
637
- const modelRaw = (await ask(` model (enter for ${suggestion}): `) ?? "").trim();
638
- const model = modelRaw || suggestion;
639
- const idRaw = (await ask(" short name for this brain (e.g. sonnet): ") ?? "").trim();
640
- if (!idRaw) {
641
- console.log(c.dim(" cancelled (no name)."));
642
- return null;
643
- }
644
- if (isReservedBrainId(idRaw)) {
645
- console.log(c.red(` "${idRaw}" is reserved for a CLI brain. Pick another name.`));
646
- return null;
647
- }
648
- if (findApiBrain(cfg, idRaw)) {
649
- console.log(c.red(` an API brain named "${idRaw}" already exists.`));
632
+ function loadTelegramConfig() {
633
+ const path = telegramConfigPath();
634
+ if (!existsSync5(path)) return null;
635
+ try {
636
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
637
+ if (typeof raw.token !== "string" || typeof raw.allowedChatId !== "number" || raw.allowedChatId === 0) return null;
638
+ return { token: raw.token, allowedChatId: raw.allowedChatId };
639
+ } catch {
650
640
  return null;
651
641
  }
652
- console.log(c.dim(" key: paste a raw key (stored locally, mode 600), or type the name of an env var that holds it."));
653
- const keyRaw = (await ask(" key or env var name: ") ?? "").trim();
654
- let keyEnv;
655
- if (!keyRaw) {
656
- console.log(c.dim(" no key given now. You can set the standard env var later."));
657
- } else if (/^[A-Z][A-Z0-9_]*$/.test(keyRaw)) {
658
- keyEnv = keyRaw;
659
- console.log(c.dim(` will read the key from env var ${keyEnv}.`));
660
- } else {
661
- saveCredential(provider, keyRaw);
662
- console.log(c.green(` key stored in ~/.holt/credentials.json (mode 600).`));
663
- }
664
- const brain = { id: idRaw, provider, model, ...keyEnv ? { keyEnv } : {} };
665
- cfg.apiBrains.push(brain);
666
- const resolvable = resolveApiKey(brain);
667
- console.log(c.green(` connected "${idRaw}" -> ${provider}/${model}.`) + (resolvable ? "" : c.dim(" (no key resolves yet)")));
668
- return brain;
669
642
  }
670
- function selectableIds(cfg) {
671
- return [...BRAIN_IDS.filter((id) => cfg.brains[id].enabled), ...cfg.apiBrains.map((a) => a.id)];
643
+ function saveTelegramConfig(cfg) {
644
+ mkdirSync5(GLOBAL_DIR, { recursive: true });
645
+ writeFileSync5(telegramConfigPath(), JSON.stringify(cfg, null, 2) + "\n", {
646
+ encoding: "utf8",
647
+ mode: 384
648
+ });
672
649
  }
673
- async function runSettings(ask) {
674
- let cfg = loadConfig() ?? defaultConfig();
675
- while (true) {
676
- printStatus(cfg);
677
- const raw = await ask(" > ");
678
- const choice = (raw ?? "").trim().toLowerCase();
679
- if (raw === null || choice === "" || choice === "q") break;
680
- if (choice === "d") {
681
- const options = selectableIds(cfg);
682
- if (options.length === 0) {
683
- console.log(c.dim(' No brains ready. Toggle a CLI brain on ("t") or connect an API brain ("c").'));
684
- continue;
685
- }
686
- const pick = (await ask(` default brain [${options.join("/")}]: `) ?? "").trim();
687
- if (options.includes(pick)) {
688
- cfg.defaultBrain = pick;
689
- const label = BRAIN_IDS.includes(pick) ? cfg.brains[pick].label : pick;
690
- console.log(c.green(` default set to ${label}`));
691
- } else console.log(c.dim(" unchanged."));
692
- } else if (choice === "t") {
693
- const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
694
- if (BRAIN_IDS.includes(pick)) {
695
- if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
696
- console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
697
- } else {
698
- cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
699
- if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
700
- if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
701
- console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
702
- }
703
- } else console.log(c.dim(" unchanged."));
704
- } else if (choice === "c") {
705
- await connectApiBrain(ask, cfg);
706
- if (!cfg.defaultBrain && cfg.apiBrains.length) cfg.defaultBrain = cfg.apiBrains[cfg.apiBrains.length - 1]?.id ?? null;
707
- } else if (choice === "x") {
708
- if (cfg.apiBrains.length === 0) {
709
- console.log(c.dim(" no API brains to remove."));
710
- continue;
711
- }
712
- const pick = (await ask(` remove which API brain [${cfg.apiBrains.map((a) => a.id).join("/")}]: `) ?? "").trim();
713
- const idx = cfg.apiBrains.findIndex((a) => a.id === pick);
714
- if (idx >= 0) {
715
- cfg.apiBrains.splice(idx, 1);
716
- if (cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
717
- console.log(c.dim(` removed "${pick}". (its stored key, if any, stays in ~/.holt/credentials.json)`));
718
- } else console.log(c.dim(" unchanged."));
719
- } else if (choice === "a") {
720
- const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
721
- if (name && name !== "holt") {
722
- const r = installAlias(name);
723
- if (r.ok && r.immediate) console.log(c.green(` "${name}" is ready to use right now.`));
724
- else if (r.ok) console.log(c.green(` alias "${name}" added to ${r.file}. Run: source ${r.file} (new terminals will not need it).`));
725
- else console.log(c.red(" " + r.message));
726
- } else {
727
- removeAlias();
728
- console.log(c.dim(" reset to holt."));
650
+ function buildSendRequest(token, chatId, text) {
651
+ return {
652
+ url: `${API_BASE}/bot${token}/sendMessage`,
653
+ body: JSON.stringify({ chat_id: chatId, text })
654
+ };
655
+ }
656
+ function buildGetUpdatesUrl(token, offset, timeoutSec) {
657
+ const params = new URLSearchParams({
658
+ offset: String(offset),
659
+ timeout: String(timeoutSec)
660
+ });
661
+ return `${API_BASE}/bot${token}/getUpdates?${params.toString()}`;
662
+ }
663
+ function chunk(text, size = CHUNK_SIZE) {
664
+ if (text.length === 0) return [""];
665
+ const out = [];
666
+ for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
667
+ return out;
668
+ }
669
+ async function sendMessage(text, chatId) {
670
+ const cfg = loadTelegramConfig();
671
+ if (!cfg) {
672
+ process.stderr.write('[telegram] not configured; run "holt telegram setup"\n');
673
+ return false;
674
+ }
675
+ const target = chatId ?? cfg.allowedChatId;
676
+ const pieces = chunk(text || "No output.");
677
+ let allOk = true;
678
+ for (const piece of pieces) {
679
+ const { url, body } = buildSendRequest(cfg.token, target, piece);
680
+ try {
681
+ const res = await fetch(url, {
682
+ method: "POST",
683
+ headers: { "content-type": "application/json" },
684
+ body
685
+ });
686
+ if (!res.ok) {
687
+ allOk = false;
688
+ process.stderr.write(`[telegram] sendMessage HTTP ${res.status}
689
+ `);
729
690
  }
730
- } else {
731
- console.log(c.dim(" pick d, t, c, x, a, or press enter to finish."));
691
+ } catch (err2) {
692
+ allOk = false;
693
+ process.stderr.write(`[telegram] sendMessage failed: ${err2.message}
694
+ `);
732
695
  }
733
- saveConfig(cfg);
734
696
  }
735
- saveConfig(cfg);
736
- return cfg;
697
+ return allOk;
737
698
  }
738
- async function setting() {
739
- const { ask, close } = createReader();
740
- if (!await ensureTrusted(ask)) {
741
- close();
742
- return;
699
+ async function getUpdates(offset, tokenOverride) {
700
+ const token = tokenOverride ?? loadTelegramConfig()?.token;
701
+ if (!token) return [];
702
+ const timeoutSec = 30;
703
+ const url = buildGetUpdatesUrl(token, offset, timeoutSec);
704
+ const ac = new AbortController();
705
+ const timer = setTimeout(() => ac.abort(), (timeoutSec + 10) * 1e3);
706
+ try {
707
+ const res = await fetch(url, { signal: ac.signal });
708
+ if (!res.ok) {
709
+ process.stderr.write(`[telegram] getUpdates HTTP ${res.status}
710
+ `);
711
+ return [];
712
+ }
713
+ const data = await res.json();
714
+ if (!data || data.ok !== true || !Array.isArray(data.result)) return [];
715
+ return data.result;
716
+ } catch (err2) {
717
+ process.stderr.write(`[telegram] getUpdates failed: ${err2.message}
718
+ `);
719
+ return [];
720
+ } finally {
721
+ clearTimeout(timer);
743
722
  }
744
- await runSettings(ask);
745
- close();
746
- console.log("");
747
723
  }
748
724
 
749
- // src/commands/init.ts
750
- function parseBrains(raw, found) {
751
- const s = raw.trim().toLowerCase();
752
- if (s === "") return found.length ? found : [...BRAIN_IDS];
753
- if (s === "all") return [...BRAIN_IDS];
754
- const picked = s.split(/[\s,]+/).filter((x) => BRAIN_IDS.includes(x));
755
- if (picked.length) return [...new Set(picked)];
756
- return found.length ? found : ["claude"];
725
+ // src/runner.ts
726
+ import { randomUUID as randomUUID2 } from "crypto";
727
+
728
+ // src/apibrain.ts
729
+ function parseSSELines(buffer) {
730
+ const normalized = buffer.replace(/\r\n/g, "\n");
731
+ const parts = normalized.split("\n\n");
732
+ const rest = parts.pop() ?? "";
733
+ const events = parts.filter((p) => p.trim() !== "");
734
+ return { events, rest };
757
735
  }
758
- async function init() {
759
- const { ask, close } = createReader();
760
- if (!await ensureTrusted(ask)) {
761
- close();
762
- return;
763
- }
764
- console.log(c.accent("Holt setup") + c.dim(` (${workspace()})`) + "\n");
765
- console.log("Looking for agent CLIs on your machine...\n");
766
- const found = [];
767
- for (const id of BRAIN_IDS) {
768
- const ok2 = isInstalled(BRAIN_DEFS[id].command);
769
- console.log(` ${ok2 ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
770
- if (ok2) found.push(id);
771
- }
772
- console.log("");
773
- const chosen = parseBrains(
774
- await ask('Which brains do you want? claude, codex, gemini (comma-separated, or "all"): ') ?? "",
775
- found
776
- );
777
- console.log(c.dim(` using: ${chosen.join(", ")}`));
778
- const toInstall = chosen.filter((id) => !isInstalled(BRAIN_DEFS[id].command));
779
- const loginWanted = /* @__PURE__ */ new Set();
780
- for (const id of toInstall) {
781
- const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
782
- if (a !== "n" && a !== "no") loginWanted.add(id);
783
- }
784
- const connectedApiBrains = [];
785
- const apiAns = (await ask("\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ") ?? "").trim().toLowerCase();
786
- if (apiAns === "y" || apiAns === "yes") {
787
- const holder = defaultConfig();
788
- const brain = await connectApiBrain(ask, holder);
789
- if (brain) connectedApiBrains.push(brain);
790
- }
791
- const defPick = chosen.includes("claude") ? "claude" : chosen[0];
792
- const dans = (await ask(`
793
- Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
794
- const defaultBrain = chosen.includes(dans) ? dans : defPick;
795
- const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
796
- let aliasNote = "";
797
- let aliasNeedsSource = "";
798
- let aliasWorked = false;
799
- if (aliasAns && aliasAns !== "holt") {
800
- const r = installAlias(aliasAns);
801
- aliasWorked = r.ok;
802
- if (r.ok && r.immediate) aliasNote = c.green(` "${aliasAns}" is ready to use right now (launcher at ${r.file}).`);
803
- else if (r.ok) {
804
- aliasNote = c.green(` alias "${aliasAns}" -> holt chat added to ${r.file}`);
805
- aliasNeedsSource = r.file;
806
- } else aliasNote = c.red(" " + r.message);
807
- }
808
- let wantMemorySetup = false;
809
- const embedReady = await embeddingsAvailable();
810
- if (embedReady) {
811
- console.log(c.dim("\nSemantic memory: ready (local Ollama with " + EMBED_MODEL + " detected)."));
812
- } else {
813
- const ollamaHere = isInstalled("ollama");
814
- const q = ollamaHere ? `Semantic memory needs a local embed model. Pull ${EMBED_MODEL} with Ollama now? [Y/n] ` : "Enable private semantic memory? Installs Ollama plus a small local embed model. Everything stays on your machine. [Y/n] ";
815
- const a = (await ask("\n" + q) ?? "").trim().toLowerCase();
816
- wantMemorySetup = a !== "n" && a !== "no";
817
- if (!wantMemorySetup) console.log(c.dim(' Okay. Memory still works with keyword recall; run "holt init" again anytime.'));
818
- }
819
- close();
820
- for (const id of toInstall) {
821
- const s = BRAIN_SETUP[id];
822
- console.log("\n" + c.accent(`Installing ${BRAIN_DEFS[id].label}`) + c.dim(` (${s.install.join(" ")})`));
823
- const code = await runInteractive(s.install[0], s.install.slice(1));
824
- console.log(code === 0 ? c.green(` ${BRAIN_DEFS[id].label} installed.`) : c.red(` Install failed (exit ${code}). Run manually: ${s.install.join(" ")}`));
825
- }
826
- for (const id of toInstall) {
827
- if (!loginWanted.has(id) || !isInstalled(BRAIN_DEFS[id].command)) continue;
828
- const s = BRAIN_SETUP[id];
829
- console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
830
- console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool to return here.`));
831
- await runInteractive(s.login[0], s.login.slice(1));
832
- }
833
- if (wantMemorySetup) {
834
- if (!isInstalled("ollama")) {
835
- if (process.platform === "darwin" && isInstalled("brew")) {
836
- console.log("\n" + c.accent("Installing Ollama") + c.dim(" (brew install ollama)"));
837
- const code = await runInteractive("brew", ["install", "ollama"]);
838
- if (code === 0) await runInteractive("brew", ["services", "start", "ollama"]);
839
- else console.log(c.red(' Install failed. Get Ollama from https://ollama.com/download and run "holt init" again.'));
840
- } else {
841
- console.log(c.dim('\n Get Ollama from https://ollama.com/download, then run "holt init" again to finish memory setup.'));
842
- }
843
- }
844
- if (isInstalled("ollama")) {
845
- console.log("\n" + c.accent("Pulling embed model") + c.dim(` (ollama pull ${EMBED_MODEL})`));
846
- const code = await runInteractive("ollama", ["pull", EMBED_MODEL]);
847
- if (code !== 0) {
848
- console.log(c.dim(' Could not pull. Start Ollama (open the app or run "ollama serve"), then run:'));
849
- console.log(c.dim(` ollama pull ${EMBED_MODEL}`));
850
- }
851
- resetEmbedProbe();
852
- if (await embeddingsAvailable()) console.log(c.green(" Semantic memory is ready. Chats in trusted folders are stored and recalled locally."));
853
- }
854
- }
855
- const cfg = loadConfig() ?? defaultConfig();
856
- for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
857
- for (const b of connectedApiBrains) if (!cfg.apiBrains.some((a) => a.id === b.id)) cfg.apiBrains.push(b);
858
- cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
859
- saveConfig(cfg);
860
- console.log("\n" + c.green("Saved to ./.holt/config.json"));
861
- if (aliasNote) console.log(aliasNote);
862
- if (cfg.defaultBrain) {
863
- if (aliasWorked && !aliasNeedsSource) {
864
- console.log("Start chatting: " + c.accent(aliasAns) + "\n");
865
- } else if (aliasNeedsSource) {
866
- console.log("\nStart chatting:");
867
- console.log(" " + c.accent(`source ${aliasNeedsSource}`) + c.dim(" (once; new terminals will not need it)"));
868
- console.log(" " + c.accent(aliasAns) + "\n");
869
- console.log(c.dim(" Or right now, without sourcing: holt chat\n"));
870
- } else {
871
- console.log("Start chatting: " + c.accent("holt chat") + "\n");
872
- }
873
- } else {
874
- console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
875
- }
876
- }
877
-
878
- // src/commands/chat.ts
879
- import { randomUUID as randomUUID2 } from "crypto";
880
-
881
- // src/apibrain.ts
882
- function parseSSELines(buffer) {
883
- const normalized = buffer.replace(/\r\n/g, "\n");
884
- const parts = normalized.split("\n\n");
885
- const rest = parts.pop() ?? "";
886
- const events = parts.filter((p) => p.trim() !== "");
887
- return { events, rest };
888
- }
889
- function dataLines(event) {
890
- const out = [];
891
- for (const line of event.split("\n")) {
892
- const t = line.replace(/\r$/, "");
893
- if (t.startsWith("data:")) out.push(t.slice(5).replace(/^ /, ""));
736
+ function dataLines(event) {
737
+ const out = [];
738
+ for (const line of event.split("\n")) {
739
+ const t = line.replace(/\r$/, "");
740
+ if (t.startsWith("data:")) out.push(t.slice(5).replace(/^ /, ""));
894
741
  }
895
742
  return out;
896
743
  }
@@ -1026,11 +873,629 @@ async function runApiBrain(brain, prompt, onChunk) {
1026
873
  }
1027
874
  }
1028
875
  }
1029
- const trimmed = text.trim();
1030
- if (trimmed) return { ok: true, text: trimmed };
1031
- return { ok: false, text: `${brain.provider} returned an empty reply.` };
876
+ const trimmed = text.trim();
877
+ if (trimmed) return { ok: true, text: trimmed };
878
+ return { ok: false, text: `${brain.provider} returned an empty reply.` };
879
+ }
880
+
881
+ // src/skills.ts
882
+ import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync6, statSync as statSync2 } from "fs";
883
+ import { join as join6 } from "path";
884
+ function skillsRoot(scope) {
885
+ return scope === "workspace" ? join6(wsHoltDir(), "skills") : join6(GLOBAL_DIR, "skills");
886
+ }
887
+ function sanitizeName(name) {
888
+ return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
889
+ }
890
+ function parseFrontmatter(md) {
891
+ const text = md.replace(/^/, "");
892
+ const lines = text.split("\n");
893
+ let start = 0;
894
+ while (start < lines.length && lines[start].trim() === "") start++;
895
+ if (start >= lines.length || lines[start].trim() !== "---") {
896
+ return { data: {}, body: text.trim() };
897
+ }
898
+ const data = {};
899
+ let i = start + 1;
900
+ let closed = false;
901
+ for (; i < lines.length; i++) {
902
+ const line = lines[i];
903
+ if (line.trim() === "---") {
904
+ closed = true;
905
+ i++;
906
+ break;
907
+ }
908
+ const idx = line.indexOf(":");
909
+ if (idx === -1) continue;
910
+ const key = line.slice(0, idx).trim();
911
+ if (!key) continue;
912
+ let value = line.slice(idx + 1).trim();
913
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
914
+ value = value.slice(1, -1);
915
+ }
916
+ data[key.toLowerCase()] = value;
917
+ }
918
+ if (!closed) {
919
+ return { data: {}, body: text.trim() };
920
+ }
921
+ return { data, body: lines.slice(i).join("\n").trim() };
922
+ }
923
+ function readSkill(dir, folder, scope) {
924
+ const file = join6(dir, "SKILL.md");
925
+ if (!existsSync6(file)) return null;
926
+ let raw = "";
927
+ try {
928
+ raw = readFileSync6(file, "utf8");
929
+ } catch {
930
+ return null;
931
+ }
932
+ const { data, body } = parseFrontmatter(raw);
933
+ const name = sanitizeName(data.name || folder) || folder;
934
+ let description = data.description || "";
935
+ if (!description) {
936
+ const firstLine2 = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
937
+ description = firstLine2.replace(/^#+\s*/, "");
938
+ }
939
+ return { name, description, dir, scope };
940
+ }
941
+ function listScope(scope) {
942
+ const root = skillsRoot(scope);
943
+ if (!existsSync6(root)) return [];
944
+ let entries;
945
+ try {
946
+ entries = readdirSync(root);
947
+ } catch {
948
+ return [];
949
+ }
950
+ const out = [];
951
+ for (const folder of entries) {
952
+ const dir = join6(root, folder);
953
+ try {
954
+ if (!statSync2(dir).isDirectory()) continue;
955
+ } catch {
956
+ continue;
957
+ }
958
+ const skill = readSkill(dir, folder, scope);
959
+ if (skill) out.push(skill);
960
+ }
961
+ return out;
962
+ }
963
+ function listSkills() {
964
+ const byName = /* @__PURE__ */ new Map();
965
+ for (const s of listScope("global")) byName.set(s.name, s);
966
+ for (const s of listScope("workspace")) byName.set(s.name, s);
967
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
968
+ }
969
+ function loadSkill(name) {
970
+ const want = sanitizeName(name);
971
+ const skill = listSkills().find((s) => s.name === want);
972
+ if (!skill) return null;
973
+ let raw = "";
974
+ try {
975
+ raw = readFileSync6(join6(skill.dir, "SKILL.md"), "utf8");
976
+ } catch {
977
+ return null;
978
+ }
979
+ const { body } = parseFrontmatter(raw);
980
+ return { skill, body };
981
+ }
982
+ function skillsPromptBlock() {
983
+ const skills = listSkills();
984
+ if (skills.length === 0) return "";
985
+ const lines = [
986
+ `Skills available to this user (invoked with "/skill <name>"). When the user invokes a skill, follow that skill's instructions:`,
987
+ ...skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`)
988
+ ];
989
+ return lines.join("\n");
990
+ }
991
+ function resolveSkillInvocation(line) {
992
+ const trimmed = line.trim();
993
+ const m = trimmed.match(/^\/skill\s+(\S+)\s*([\s\S]*)$/i);
994
+ if (!m) return null;
995
+ const name = m[1];
996
+ const input = (m[2] || "").trim();
997
+ const loaded = loadSkill(name);
998
+ if (!loaded) return null;
999
+ const prompt = [
1000
+ loaded.body,
1001
+ "",
1002
+ "Apply the skill above to this request:",
1003
+ input || "Introduce what you can do with this skill."
1004
+ ].join("\n");
1005
+ return { prompt, skillName: loaded.skill.name };
1006
+ }
1007
+
1008
+ // src/runner.ts
1009
+ function resolveActive(cfg, id) {
1010
+ if (BRAIN_IDS.includes(id)) {
1011
+ const b = cfg.brains[id];
1012
+ return { kind: "cli", id, label: b.label };
1013
+ }
1014
+ const api = findApiBrain(cfg, id);
1015
+ if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
1016
+ return null;
1017
+ }
1018
+ async function runTask(task, opts = {}) {
1019
+ try {
1020
+ const cfg = loadConfig();
1021
+ if (!cfg || !cfg.defaultBrain) {
1022
+ return { ok: false, text: "No Holt setup in this folder. Run holt init.", brainLabel: "" };
1023
+ }
1024
+ const wantId = opts.brainId ?? cfg.defaultBrain;
1025
+ const active = resolveActive(cfg, wantId);
1026
+ if (!active) {
1027
+ return { ok: false, text: `Brain "${wantId}" is not configured. Run holt setting.`, brainLabel: "" };
1028
+ }
1029
+ if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
1030
+ return {
1031
+ ok: false,
1032
+ text: `${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Install it or run holt setting.`,
1033
+ brainLabel: active.label
1034
+ };
1035
+ }
1036
+ if (active.kind === "api" && !resolveApiKey(active.brain)) {
1037
+ return {
1038
+ ok: false,
1039
+ text: `${active.label} has no API key. Run holt setting to add one.`,
1040
+ brainLabel: active.label
1041
+ };
1042
+ }
1043
+ const runSession = "run-" + newSessionId();
1044
+ const recalled = opts.recall !== false ? await recall(task, runSession, 4) : [];
1045
+ const block = skillsPromptBlock();
1046
+ const base = renderPrompt([], task, recalled);
1047
+ const prompt = block ? block + "\n\n" + base : base;
1048
+ const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, opts.onChunk) : await runApiBrain(active.brain, prompt, opts.onChunk);
1049
+ if (res.ok && opts.store !== false) {
1050
+ const now = Date.now();
1051
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session: runSession, role: "user", content: task, emb: await embed(task) ?? void 0 });
1052
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session: runSession, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
1053
+ }
1054
+ return { ok: res.ok, text: res.text, brainLabel: active.label };
1055
+ } catch (e) {
1056
+ return { ok: false, text: e.message, brainLabel: "" };
1057
+ }
1058
+ }
1059
+
1060
+ // src/commands/telegram.ts
1061
+ var HELP_TEXT = "Holt bot. Send me anything and I will run it through your selected brain and reply.\nCommands: /help this message.";
1062
+ async function telegram(sub, _rest = []) {
1063
+ const action = (sub || "").toLowerCase();
1064
+ if (action === "setup") {
1065
+ await setup();
1066
+ return;
1067
+ }
1068
+ await runBot();
1069
+ }
1070
+ async function setupTelegram(ask) {
1071
+ console.log("\n" + c.accent("Connect Holt to Telegram") + c.dim(" (chat with Holt from your phone)"));
1072
+ console.log(c.dim(" 1. Open Telegram and message @BotFather."));
1073
+ console.log(c.dim(" 2. Send /newbot, follow the prompts, and copy the bot token it gives you."));
1074
+ console.log(c.dim(" A token looks like 123456789:AAF... (keep it secret)."));
1075
+ const token = (await ask("\n Paste your bot token (enter to skip): ") ?? "").trim();
1076
+ if (!token) {
1077
+ console.log(c.dim(' Skipped. Set it up later with "holt telegram setup" or in "holt setting".'));
1078
+ return false;
1079
+ }
1080
+ console.log("\n" + c.dim(" Now I need your chat id (so the bot only answers you)."));
1081
+ console.log(c.dim(" Option A: open your new bot in Telegram and send it any message,"));
1082
+ console.log(c.dim(" then I will auto-detect the chat id."));
1083
+ console.log(c.dim(" Option B: paste a numeric chat id yourself."));
1084
+ const mode = (await ask("\n Auto-detect from a message you just sent? [Y/n] ") ?? "").trim().toLowerCase();
1085
+ let allowedChatId = null;
1086
+ if (mode === "" || mode === "y" || mode === "yes") {
1087
+ console.log(c.dim("\n Checking for a recent message..."));
1088
+ const updates = await getUpdates(0, token);
1089
+ allowedChatId = latestChatId(updates);
1090
+ if (allowedChatId === null) {
1091
+ console.log(c.red(" Did not find a recent message."));
1092
+ const manual = (await ask(" Paste your numeric chat id instead: ") ?? "").trim();
1093
+ allowedChatId = parseChatId(manual);
1094
+ }
1095
+ } else {
1096
+ const manual = (await ask(" Paste your numeric chat id: ") ?? "").trim();
1097
+ allowedChatId = parseChatId(manual);
1098
+ }
1099
+ if (allowedChatId === null) {
1100
+ console.log(c.red("\n No valid chat id. Nothing saved. Try again anytime."));
1101
+ return false;
1102
+ }
1103
+ const cfg = { token, allowedChatId };
1104
+ saveTelegramConfig(cfg);
1105
+ console.log(
1106
+ "\n" + c.green(" Telegram connected.") + c.dim(` token ...${token.slice(-4)} chat id ${allowedChatId} (~/.holt/telegram.json, mode 600)`)
1107
+ );
1108
+ console.log(c.dim(" Start the bot anytime with: holt telegram"));
1109
+ return true;
1110
+ }
1111
+ async function setup() {
1112
+ const { ask, close } = createReader();
1113
+ try {
1114
+ await setupTelegram(ask);
1115
+ console.log("");
1116
+ } finally {
1117
+ close();
1118
+ }
1119
+ }
1120
+ function parseChatId(s) {
1121
+ if (!/^-?\d+$/.test(s)) return null;
1122
+ const n = Number(s);
1123
+ return Number.isFinite(n) ? n : null;
1124
+ }
1125
+ function latestChatId(updates) {
1126
+ for (let i = updates.length - 1; i >= 0; i--) {
1127
+ const u = updates[i];
1128
+ if (u && u.message) return u.message.chat.id;
1129
+ }
1130
+ return null;
1131
+ }
1132
+ async function runBot() {
1133
+ const cfg = loadTelegramConfig();
1134
+ if (!cfg) {
1135
+ console.log(c.dim('\n Telegram is not set up. Run "holt telegram setup" first.\n'));
1136
+ return;
1137
+ }
1138
+ if (!isTrusted()) {
1139
+ trustDir();
1140
+ process.stderr.write(`[telegram] auto-trusted ${workspace()}
1141
+ `);
1142
+ }
1143
+ process.stderr.write("[telegram] bot started; long-polling for messages. Ctrl-C to stop.\n");
1144
+ let offset = 0;
1145
+ let busy = false;
1146
+ while (true) {
1147
+ let updates = [];
1148
+ try {
1149
+ updates = await getUpdates(offset);
1150
+ } catch (err2) {
1151
+ process.stderr.write(`[telegram] poll error: ${err2.message}
1152
+ `);
1153
+ await sleep(1e3);
1154
+ continue;
1155
+ }
1156
+ for (const u of updates) {
1157
+ if (u && typeof u.update_id === "number") offset = Math.max(offset, u.update_id + 1);
1158
+ try {
1159
+ await handleUpdate(u, cfg, () => busy, (v) => {
1160
+ busy = v;
1161
+ });
1162
+ } catch (err2) {
1163
+ process.stderr.write(`[telegram] update ${u?.update_id} failed: ${err2.message}
1164
+ `);
1165
+ }
1166
+ }
1167
+ }
1168
+ }
1169
+ async function handleUpdate(u, cfg, getBusy, setBusy) {
1170
+ const msg = u?.message;
1171
+ if (!msg || typeof msg.text !== "string") return;
1172
+ if (msg.chat.id !== cfg.allowedChatId) {
1173
+ process.stderr.write(`[telegram] ignoring message from chat ${msg.chat.id}
1174
+ `);
1175
+ return;
1176
+ }
1177
+ const text = msg.text.trim();
1178
+ if (text === "/start" || text === "/help") {
1179
+ await sendMessage(HELP_TEXT, cfg.allowedChatId);
1180
+ return;
1181
+ }
1182
+ if (getBusy()) {
1183
+ await sendMessage("Still working on the previous message. Try again in a moment.", cfg.allowedChatId);
1184
+ return;
1185
+ }
1186
+ setBusy(true);
1187
+ try {
1188
+ process.stderr.write(`[telegram] running task (${text.length} chars)
1189
+ `);
1190
+ const result = await runTask(text);
1191
+ await sendMessage(result.text || "No output.", cfg.allowedChatId);
1192
+ process.stderr.write(`[telegram] replied (brain: ${result.brainLabel})
1193
+ `);
1194
+ } catch (err2) {
1195
+ process.stderr.write(`[telegram] runTask failed: ${err2.message}
1196
+ `);
1197
+ await sendMessage("Sorry, that run failed. Check the Holt logs.", cfg.allowedChatId);
1198
+ } finally {
1199
+ setBusy(false);
1200
+ }
1201
+ }
1202
+ function sleep(ms) {
1203
+ return new Promise((r) => setTimeout(r, ms));
1204
+ }
1205
+
1206
+ // src/commands/setting.ts
1207
+ function printStatus(cfg) {
1208
+ console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
1209
+ for (const id of BRAIN_IDS) {
1210
+ const b = cfg.brains[id];
1211
+ const tags = [
1212
+ b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
1213
+ cfg.defaultBrain === id ? c.accent("default") : ""
1214
+ ].filter(Boolean).join(" ");
1215
+ console.log(` ${id.padEnd(10)} ${b.label.padEnd(16)} ${tags}`);
1216
+ }
1217
+ if (cfg.apiBrains.length) {
1218
+ console.log(c.dim(" api brains:"));
1219
+ for (const a of cfg.apiBrains) {
1220
+ const key = resolveApiKey(a) ? c.green("key ok") : c.red("no key");
1221
+ const def = cfg.defaultBrain === a.id ? c.accent("default") : "";
1222
+ console.log(` ${a.id.padEnd(10)} ${`${a.provider}/${a.model}`.padEnd(30)} ${key} ${def}`);
1223
+ }
1224
+ }
1225
+ console.log(c.dim(` output format: ${cfg.outputFormat}`));
1226
+ console.log(c.dim(` launch command: ${currentAlias() || "holt (default)"}`));
1227
+ const tg = loadTelegramConfig();
1228
+ console.log(c.dim(` telegram: ${tg ? `connected (chat ${tg.allowedChatId})` : "not set up"}`));
1229
+ console.log("\n " + c.dim("[d] default brain [t] toggle brain [c] connect API brain [x] remove API brain"));
1230
+ console.log(" " + c.dim("[a] launch command [m] connect Telegram [enter] done"));
1231
+ }
1232
+ async function connectApiBrain(ask, cfg) {
1233
+ let provider = null;
1234
+ for (let tries = 0; tries < 3 && !provider; tries++) {
1235
+ const provRaw = (await ask(` provider [${PROVIDERS.join("/")}] (enter for ${PROVIDERS[0]}, or "skip"): `) ?? "skip").trim().toLowerCase();
1236
+ if (provRaw === "skip" || provRaw === "q" || provRaw === "n" || provRaw === "no") {
1237
+ console.log(c.dim(' skipped. Add one later with "holt setting" then "c".'));
1238
+ return null;
1239
+ }
1240
+ if (provRaw === "") provider = PROVIDERS[0];
1241
+ else if (PROVIDERS.includes(provRaw)) provider = provRaw;
1242
+ else console.log(c.dim(` "${provRaw}" is not a provider. Type one of: ${PROVIDERS.join(", ")}`));
1243
+ }
1244
+ if (!provider) {
1245
+ console.log(c.dim(' skipped after three tries. Add one later with "holt setting" then "c".'));
1246
+ return null;
1247
+ }
1248
+ const suggestion = PROVIDER_MODEL_SUGGESTION[provider];
1249
+ const modelRaw = (await ask(` model (enter for ${suggestion}): `) ?? "").trim();
1250
+ const model = modelRaw || suggestion;
1251
+ const idRaw = (await ask(" short name for this brain (e.g. sonnet): ") ?? "").trim();
1252
+ if (!idRaw) {
1253
+ console.log(c.dim(" cancelled (no name)."));
1254
+ return null;
1255
+ }
1256
+ if (isReservedBrainId(idRaw)) {
1257
+ console.log(c.red(` "${idRaw}" is reserved for a CLI brain. Pick another name.`));
1258
+ return null;
1259
+ }
1260
+ if (findApiBrain(cfg, idRaw)) {
1261
+ console.log(c.red(` an API brain named "${idRaw}" already exists.`));
1262
+ return null;
1263
+ }
1264
+ console.log(c.dim(" key: paste a raw key (stored locally, mode 600), or type the name of an env var that holds it."));
1265
+ const keyRaw = (await ask(" key or env var name: ") ?? "").trim();
1266
+ let keyEnv;
1267
+ if (!keyRaw) {
1268
+ console.log(c.dim(" no key given now. You can set the standard env var later."));
1269
+ } else if (/^[A-Z][A-Z0-9_]*$/.test(keyRaw)) {
1270
+ keyEnv = keyRaw;
1271
+ console.log(c.dim(` will read the key from env var ${keyEnv}.`));
1272
+ } else {
1273
+ saveCredential(provider, keyRaw);
1274
+ console.log(c.green(` key stored in ~/.holt/credentials.json (mode 600).`));
1275
+ }
1276
+ const brain = { id: idRaw, provider, model, ...keyEnv ? { keyEnv } : {} };
1277
+ cfg.apiBrains.push(brain);
1278
+ const resolvable = resolveApiKey(brain);
1279
+ console.log(c.green(` connected "${idRaw}" -> ${provider}/${model}.`) + (resolvable ? "" : c.dim(" (no key resolves yet)")));
1280
+ return brain;
1281
+ }
1282
+ function selectableIds(cfg) {
1283
+ return [...BRAIN_IDS.filter((id) => cfg.brains[id].enabled), ...cfg.apiBrains.map((a) => a.id)];
1284
+ }
1285
+ async function runSettings(ask) {
1286
+ let cfg = loadConfig() ?? defaultConfig();
1287
+ while (true) {
1288
+ printStatus(cfg);
1289
+ const raw = await ask(" > ");
1290
+ const choice = (raw ?? "").trim().toLowerCase();
1291
+ if (raw === null || choice === "" || choice === "q") break;
1292
+ if (choice === "d") {
1293
+ const options = selectableIds(cfg);
1294
+ if (options.length === 0) {
1295
+ console.log(c.dim(' No brains ready. Toggle a CLI brain on ("t") or connect an API brain ("c").'));
1296
+ continue;
1297
+ }
1298
+ const pick = (await ask(` default brain [${options.join("/")}]: `) ?? "").trim();
1299
+ if (options.includes(pick)) {
1300
+ cfg.defaultBrain = pick;
1301
+ const label = BRAIN_IDS.includes(pick) ? cfg.brains[pick].label : pick;
1302
+ console.log(c.green(` default set to ${label}`));
1303
+ } else console.log(c.dim(" unchanged."));
1304
+ } else if (choice === "t") {
1305
+ const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
1306
+ if (BRAIN_IDS.includes(pick)) {
1307
+ if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
1308
+ console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
1309
+ } else {
1310
+ cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
1311
+ if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
1312
+ if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
1313
+ console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
1314
+ }
1315
+ } else console.log(c.dim(" unchanged."));
1316
+ } else if (choice === "c") {
1317
+ await connectApiBrain(ask, cfg);
1318
+ if (!cfg.defaultBrain && cfg.apiBrains.length) cfg.defaultBrain = cfg.apiBrains[cfg.apiBrains.length - 1]?.id ?? null;
1319
+ } else if (choice === "x") {
1320
+ if (cfg.apiBrains.length === 0) {
1321
+ console.log(c.dim(" no API brains to remove."));
1322
+ continue;
1323
+ }
1324
+ const pick = (await ask(` remove which API brain [${cfg.apiBrains.map((a) => a.id).join("/")}]: `) ?? "").trim();
1325
+ const idx = cfg.apiBrains.findIndex((a) => a.id === pick);
1326
+ if (idx >= 0) {
1327
+ cfg.apiBrains.splice(idx, 1);
1328
+ if (cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
1329
+ console.log(c.dim(` removed "${pick}". (its stored key, if any, stays in ~/.holt/credentials.json)`));
1330
+ } else console.log(c.dim(" unchanged."));
1331
+ } else if (choice === "a") {
1332
+ const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
1333
+ if (name && name !== "holt") {
1334
+ const r = installAlias(name);
1335
+ if (r.ok && r.immediate) console.log(c.green(` "${name}" is ready to use right now.`));
1336
+ else if (r.ok) console.log(c.green(` alias "${name}" added to ${r.file}. Run: source ${r.file} (new terminals will not need it).`));
1337
+ else console.log(c.red(" " + r.message));
1338
+ } else {
1339
+ removeAlias();
1340
+ console.log(c.dim(" reset to holt."));
1341
+ }
1342
+ } else if (choice === "m") {
1343
+ await setupTelegram(ask);
1344
+ } else {
1345
+ console.log(c.dim(" pick d, t, c, x, a, m, or press enter to finish."));
1346
+ }
1347
+ saveConfig(cfg);
1348
+ }
1349
+ saveConfig(cfg);
1350
+ return cfg;
1351
+ }
1352
+ async function setting() {
1353
+ const { ask, close } = createReader();
1354
+ if (!await ensureTrusted(ask)) {
1355
+ close();
1356
+ return;
1357
+ }
1358
+ await runSettings(ask);
1359
+ close();
1360
+ console.log("");
1361
+ }
1362
+
1363
+ // src/commands/init.ts
1364
+ function parseBrains(raw, found) {
1365
+ const s = raw.trim().toLowerCase();
1366
+ if (s === "") return found.length ? found : [...BRAIN_IDS];
1367
+ if (s === "all") return [...BRAIN_IDS];
1368
+ const picked = s.split(/[\s,]+/).filter((x) => BRAIN_IDS.includes(x));
1369
+ if (picked.length) return [...new Set(picked)];
1370
+ return found.length ? found : ["claude"];
1371
+ }
1372
+ async function init() {
1373
+ const { ask, close } = createReader();
1374
+ if (!await ensureTrusted(ask)) {
1375
+ close();
1376
+ return;
1377
+ }
1378
+ console.log(c.accent("Holt setup") + c.dim(` (${workspace()})`) + "\n");
1379
+ console.log("Looking for agent CLIs on your machine...\n");
1380
+ const found = [];
1381
+ for (const id of BRAIN_IDS) {
1382
+ const ok2 = isInstalled(BRAIN_DEFS[id].command);
1383
+ console.log(` ${ok2 ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
1384
+ if (ok2) found.push(id);
1385
+ }
1386
+ console.log("");
1387
+ const chosen = parseBrains(
1388
+ await ask('Which brains do you want? claude, codex, gemini (comma-separated, or "all"): ') ?? "",
1389
+ found
1390
+ );
1391
+ console.log(c.dim(` using: ${chosen.join(", ")}`));
1392
+ const toInstall = chosen.filter((id) => !isInstalled(BRAIN_DEFS[id].command));
1393
+ const loginWanted = /* @__PURE__ */ new Set();
1394
+ for (const id of toInstall) {
1395
+ const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
1396
+ if (a !== "n" && a !== "no") loginWanted.add(id);
1397
+ }
1398
+ const connectedApiBrains = [];
1399
+ const apiAns = (await ask("\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ") ?? "").trim().toLowerCase();
1400
+ if (apiAns === "y" || apiAns === "yes") {
1401
+ const holder = defaultConfig();
1402
+ const brain = await connectApiBrain(ask, holder);
1403
+ if (brain) connectedApiBrains.push(brain);
1404
+ }
1405
+ const defPick = chosen.includes("claude") ? "claude" : chosen[0];
1406
+ const dans = (await ask(`
1407
+ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
1408
+ const defaultBrain = chosen.includes(dans) ? dans : defPick;
1409
+ const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
1410
+ let aliasNote = "";
1411
+ let aliasNeedsSource = "";
1412
+ let aliasWorked = false;
1413
+ if (aliasAns && aliasAns !== "holt") {
1414
+ const r = installAlias(aliasAns);
1415
+ aliasWorked = r.ok;
1416
+ if (r.ok && r.immediate) aliasNote = c.green(` "${aliasAns}" is ready to use right now (launcher at ${r.file}).`);
1417
+ else if (r.ok) {
1418
+ aliasNote = c.green(` alias "${aliasAns}" -> holt chat added to ${r.file}`);
1419
+ aliasNeedsSource = r.file;
1420
+ } else aliasNote = c.red(" " + r.message);
1421
+ }
1422
+ let wantMemorySetup = false;
1423
+ const embedReady = await embeddingsAvailable();
1424
+ if (embedReady) {
1425
+ console.log(c.dim("\nSemantic memory: ready (local Ollama with " + EMBED_MODEL + " detected)."));
1426
+ } else {
1427
+ const ollamaHere = isInstalled("ollama");
1428
+ const q = ollamaHere ? `Semantic memory needs a local embed model. Pull ${EMBED_MODEL} with Ollama now? [Y/n] ` : "Enable private semantic memory? Installs Ollama plus a small local embed model. Everything stays on your machine. [Y/n] ";
1429
+ const a = (await ask("\n" + q) ?? "").trim().toLowerCase();
1430
+ wantMemorySetup = a !== "n" && a !== "no";
1431
+ if (!wantMemorySetup) console.log(c.dim(' Okay. Memory still works with keyword recall; run "holt init" again anytime.'));
1432
+ }
1433
+ if (!loadTelegramConfig()) {
1434
+ const tgAns = (await ask("\nChat with Holt from your phone over Telegram? [y/N] ") ?? "").trim().toLowerCase();
1435
+ if (tgAns === "y" || tgAns === "yes") await setupTelegram(ask);
1436
+ }
1437
+ close();
1438
+ for (const id of toInstall) {
1439
+ const s = BRAIN_SETUP[id];
1440
+ console.log("\n" + c.accent(`Installing ${BRAIN_DEFS[id].label}`) + c.dim(` (${s.install.join(" ")})`));
1441
+ const code = await runInteractive(s.install[0], s.install.slice(1));
1442
+ console.log(code === 0 ? c.green(` ${BRAIN_DEFS[id].label} installed.`) : c.red(` Install failed (exit ${code}). Run manually: ${s.install.join(" ")}`));
1443
+ }
1444
+ for (const id of toInstall) {
1445
+ if (!loginWanted.has(id) || !isInstalled(BRAIN_DEFS[id].command)) continue;
1446
+ const s = BRAIN_SETUP[id];
1447
+ console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
1448
+ console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool to return here.`));
1449
+ await runInteractive(s.login[0], s.login.slice(1));
1450
+ }
1451
+ if (wantMemorySetup) {
1452
+ if (!isInstalled("ollama")) {
1453
+ if (process.platform === "darwin" && isInstalled("brew")) {
1454
+ console.log("\n" + c.accent("Installing Ollama") + c.dim(" (brew install ollama)"));
1455
+ const code = await runInteractive("brew", ["install", "ollama"]);
1456
+ if (code === 0) await runInteractive("brew", ["services", "start", "ollama"]);
1457
+ else console.log(c.red(' Install failed. Get Ollama from https://ollama.com/download and run "holt init" again.'));
1458
+ } else {
1459
+ console.log(c.dim('\n Get Ollama from https://ollama.com/download, then run "holt init" again to finish memory setup.'));
1460
+ }
1461
+ }
1462
+ if (isInstalled("ollama")) {
1463
+ console.log("\n" + c.accent("Pulling embed model") + c.dim(` (ollama pull ${EMBED_MODEL})`));
1464
+ const code = await runInteractive("ollama", ["pull", EMBED_MODEL]);
1465
+ if (code !== 0) {
1466
+ console.log(c.dim(' Could not pull. Start Ollama (open the app or run "ollama serve"), then run:'));
1467
+ console.log(c.dim(` ollama pull ${EMBED_MODEL}`));
1468
+ }
1469
+ resetEmbedProbe();
1470
+ if (await embeddingsAvailable()) console.log(c.green(" Semantic memory is ready. Chats in trusted folders are stored and recalled locally."));
1471
+ }
1472
+ }
1473
+ const cfg = loadConfig() ?? defaultConfig();
1474
+ for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
1475
+ for (const b of connectedApiBrains) if (!cfg.apiBrains.some((a) => a.id === b.id)) cfg.apiBrains.push(b);
1476
+ cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
1477
+ saveConfig(cfg);
1478
+ console.log("\n" + c.green("Saved to ./.holt/config.json"));
1479
+ if (aliasNote) console.log(aliasNote);
1480
+ if (cfg.defaultBrain) {
1481
+ if (aliasWorked && !aliasNeedsSource) {
1482
+ console.log("Start chatting: " + c.accent(aliasAns) + "\n");
1483
+ } else if (aliasNeedsSource) {
1484
+ console.log("\nStart chatting:");
1485
+ console.log(" " + c.accent(`source ${aliasNeedsSource}`) + c.dim(" (once; new terminals will not need it)"));
1486
+ console.log(" " + c.accent(aliasAns) + "\n");
1487
+ console.log(c.dim(" Or right now, without sourcing: holt chat\n"));
1488
+ } else {
1489
+ console.log("Start chatting: " + c.accent("holt chat") + "\n");
1490
+ }
1491
+ } else {
1492
+ console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
1493
+ }
1032
1494
  }
1033
1495
 
1496
+ // src/commands/chat.ts
1497
+ import { randomUUID as randomUUID3 } from "crypto";
1498
+
1034
1499
  // src/facts.ts
1035
1500
  var MIN_EXCHANGES_FOR_EXTRACTION = 3;
1036
1501
  var MAX_TRANSCRIPT_TURNS = 20;
@@ -1141,8 +1606,8 @@ async function extractAndSaveFacts(active, cfg, history, session) {
1141
1606
  }
1142
1607
 
1143
1608
  // src/output.ts
1144
- import { writeFileSync as writeFileSync5 } from "fs";
1145
- import { join as join5 } from "path";
1609
+ import { writeFileSync as writeFileSync6 } from "fs";
1610
+ import { join as join7, isAbsolute } from "path";
1146
1611
  function escapeHtml(s) {
1147
1612
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1148
1613
  }
@@ -1153,8 +1618,12 @@ function renderInline(text) {
1153
1618
  return `\0${codes.length - 1}\0`;
1154
1619
  });
1155
1620
  s = escapeHtml(s);
1156
- s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, url) => {
1157
- const safe = url.replace(/"/g, "%22");
1621
+ s = s.replace(/\[([^\]]+)\]\((\S*?(?:\([^)\s]*\)[^)\s]*)*)\)/g, (_m, label, url) => {
1622
+ const trimmed = url.trim();
1623
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
1624
+ const safeScheme = !scheme || /^(https?|mailto)$/i.test(scheme[1]);
1625
+ if (!safeScheme) return label;
1626
+ const safe = trimmed.replace(/"/g, "%22");
1158
1627
  return `<a href="${safe}">${label}</a>`;
1159
1628
  });
1160
1629
  s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
@@ -1274,140 +1743,23 @@ function saveReply(reply, format, name) {
1274
1743
  const ext = format === "html" ? ".html" : ".md";
1275
1744
  let base = (name ?? "").trim();
1276
1745
  if (!base) base = `holt-reply-${timestamp()}`;
1746
+ if (isAbsolute(base) || /[\\/]/.test(base) || base === "." || base === "..") {
1747
+ throw new Error('name must be a plain file name in this folder (no "/", "\\", or "..").');
1748
+ }
1277
1749
  if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
1278
- const path = join5(process.cwd(), base);
1279
- writeFileSync5(path, renderReply(reply, format), "utf8");
1750
+ const path = join7(process.cwd(), base);
1751
+ writeFileSync6(path, renderReply(reply, format), "utf8");
1280
1752
  return path;
1281
1753
  }
1282
1754
 
1283
- // src/skills.ts
1284
- import { readFileSync as readFileSync5, readdirSync, existsSync as existsSync5, statSync as statSync2 } from "fs";
1285
- import { join as join6 } from "path";
1286
- function skillsRoot(scope) {
1287
- return scope === "workspace" ? join6(wsHoltDir(), "skills") : join6(GLOBAL_DIR, "skills");
1288
- }
1289
- function sanitizeName(name) {
1290
- return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
1291
- }
1292
- function parseFrontmatter(md) {
1293
- const text = md.replace(/^/, "");
1294
- const lines = text.split("\n");
1295
- let start = 0;
1296
- while (start < lines.length && lines[start].trim() === "") start++;
1297
- if (start >= lines.length || lines[start].trim() !== "---") {
1298
- return { data: {}, body: text.trim() };
1299
- }
1300
- const data = {};
1301
- let i = start + 1;
1302
- let closed = false;
1303
- for (; i < lines.length; i++) {
1304
- const line = lines[i];
1305
- if (line.trim() === "---") {
1306
- closed = true;
1307
- i++;
1308
- break;
1309
- }
1310
- const idx = line.indexOf(":");
1311
- if (idx === -1) continue;
1312
- const key = line.slice(0, idx).trim();
1313
- if (!key) continue;
1314
- let value = line.slice(idx + 1).trim();
1315
- if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
1316
- value = value.slice(1, -1);
1317
- }
1318
- data[key.toLowerCase()] = value;
1319
- }
1320
- if (!closed) {
1321
- return { data: {}, body: text.trim() };
1322
- }
1323
- return { data, body: lines.slice(i).join("\n").trim() };
1324
- }
1325
- function readSkill(dir, folder, scope) {
1326
- const file = join6(dir, "SKILL.md");
1327
- if (!existsSync5(file)) return null;
1328
- let raw = "";
1329
- try {
1330
- raw = readFileSync5(file, "utf8");
1331
- } catch {
1332
- return null;
1333
- }
1334
- const { data, body } = parseFrontmatter(raw);
1335
- const name = sanitizeName(data.name || folder) || folder;
1336
- let description = data.description || "";
1337
- if (!description) {
1338
- const firstLine2 = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
1339
- description = firstLine2.replace(/^#+\s*/, "");
1340
- }
1341
- return { name, description, dir, scope };
1342
- }
1343
- function listScope(scope) {
1344
- const root = skillsRoot(scope);
1345
- if (!existsSync5(root)) return [];
1346
- let entries;
1347
- try {
1348
- entries = readdirSync(root);
1349
- } catch {
1350
- return [];
1351
- }
1352
- const out = [];
1353
- for (const folder of entries) {
1354
- const dir = join6(root, folder);
1355
- try {
1356
- if (!statSync2(dir).isDirectory()) continue;
1357
- } catch {
1358
- continue;
1359
- }
1360
- const skill = readSkill(dir, folder, scope);
1361
- if (skill) out.push(skill);
1362
- }
1363
- return out;
1364
- }
1365
- function listSkills() {
1366
- const byName = /* @__PURE__ */ new Map();
1367
- for (const s of listScope("global")) byName.set(s.name, s);
1368
- for (const s of listScope("workspace")) byName.set(s.name, s);
1369
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
1370
- }
1371
- function loadSkill(name) {
1372
- const want = sanitizeName(name);
1373
- const skill = listSkills().find((s) => s.name === want);
1374
- if (!skill) return null;
1375
- let raw = "";
1376
- try {
1377
- raw = readFileSync5(join6(skill.dir, "SKILL.md"), "utf8");
1378
- } catch {
1379
- return null;
1380
- }
1381
- const { body } = parseFrontmatter(raw);
1382
- return { skill, body };
1383
- }
1384
- function skillsPromptBlock() {
1385
- const skills = listSkills();
1386
- if (skills.length === 0) return "";
1387
- const lines = [
1388
- `Skills available to this user (invoked with "/skill <name>"). When the user invokes a skill, follow that skill's instructions:`,
1389
- ...skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`)
1390
- ];
1391
- return lines.join("\n");
1392
- }
1393
- function resolveSkillInvocation(line) {
1394
- const trimmed = line.trim();
1395
- const m = trimmed.match(/^\/skill\s+(\S+)\s*([\s\S]*)$/i);
1396
- if (!m) return null;
1397
- const name = m[1];
1398
- const input = (m[2] || "").trim();
1399
- const loaded = loadSkill(name);
1400
- if (!loaded) return null;
1401
- const prompt = [
1402
- loaded.body,
1403
- "",
1404
- "Apply the skill above to this request:",
1405
- input || "Introduce what you can do with this skill."
1406
- ].join("\n");
1407
- return { prompt, skillName: loaded.skill.name };
1408
- }
1409
-
1410
1755
  // src/commands/chat.ts
1756
+ function statusLine(brainLabel, history) {
1757
+ const live = Math.min(history.length, MAX_REPLAY_TURNS);
1758
+ const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
1759
+ const pct = Math.round(frac * 100);
1760
+ const mem = memStats().turns;
1761
+ return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
1762
+ }
1411
1763
  function help() {
1412
1764
  console.log(c.dim([
1413
1765
  " commands:",
@@ -1422,7 +1774,7 @@ function help() {
1422
1774
  " /exit leave"
1423
1775
  ].join("\n")));
1424
1776
  }
1425
- function resolveActive(cfg, id) {
1777
+ function resolveActive2(cfg, id) {
1426
1778
  if (BRAIN_IDS.includes(id)) {
1427
1779
  const b = cfg.brains[id];
1428
1780
  return { kind: "cli", id, label: b.label };
@@ -1449,10 +1801,10 @@ async function chat() {
1449
1801
  console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
1450
1802
  return;
1451
1803
  }
1452
- let active = resolveActive(cfg, cfg.defaultBrain);
1804
+ let active = resolveActive2(cfg, cfg.defaultBrain);
1453
1805
  if (!active) {
1454
1806
  const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
1455
- active = fallback ? resolveActive(cfg, fallback) : null;
1807
+ active = fallback ? resolveActive2(cfg, fallback) : null;
1456
1808
  if (!active) {
1457
1809
  close();
1458
1810
  console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
@@ -1471,7 +1823,8 @@ async function chat() {
1471
1823
  if (embedOk && stats.withEmbeddings < stats.turns) {
1472
1824
  console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
1473
1825
  }
1474
- console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit\n"));
1826
+ console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit"));
1827
+ console.log(statusLine(active.label, history) + "\n");
1475
1828
  while (true) {
1476
1829
  const raw = await ask(c.accent("\u203A "));
1477
1830
  if (raw === null) break;
@@ -1542,8 +1895,8 @@ async function chat() {
1542
1895
  }
1543
1896
  if (cmd === "setting" || cmd === "settings") {
1544
1897
  cfg = await runSettings(ask);
1545
- const next = cfg.defaultBrain ? resolveActive(cfg, cfg.defaultBrain) : null;
1546
- const stillValid = resolveActive(cfg, active.id);
1898
+ const next = cfg.defaultBrain ? resolveActive2(cfg, cfg.defaultBrain) : null;
1899
+ const stillValid = resolveActive2(cfg, active.id);
1547
1900
  active = stillValid ?? next ?? active;
1548
1901
  console.log(c.dim(` brain: ${active.label}`));
1549
1902
  continue;
@@ -1552,15 +1905,17 @@ async function chat() {
1552
1905
  const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
1553
1906
  const apiIds = cfg.apiBrains.map((a) => a.id);
1554
1907
  const all = [...cliEnabled, ...apiIds];
1555
- if (arg && all.includes(arg)) {
1556
- const next = resolveActive(cfg, arg);
1908
+ const rawArg = parts[1] || "";
1909
+ const match = all.find((id) => id.toLowerCase() === rawArg.toLowerCase());
1910
+ if (rawArg && match) {
1911
+ const next = resolveActive2(cfg, match);
1557
1912
  if (next) {
1558
1913
  active = next;
1559
1914
  const turns = Math.floor(history.length / 2);
1560
1915
  console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
1561
1916
  }
1562
- } else if (arg) {
1563
- console.log(c.dim(` "${arg}" is not available. Available: ${all.join(", ") || "none"}`));
1917
+ } else if (rawArg) {
1918
+ console.log(c.dim(` "${rawArg}" is not available. Available: ${all.join(", ") || "none"}`));
1564
1919
  } else {
1565
1920
  const cfgRef = cfg;
1566
1921
  const activeId = active.id;
@@ -1610,8 +1965,9 @@ async function chat() {
1610
1965
  history.push({ role: "user", content: line });
1611
1966
  history.push({ role: "assistant", content: res.text });
1612
1967
  const now = Date.now();
1613
- appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
1614
- appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
1968
+ appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
1969
+ appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
1970
+ console.log(statusLine(active.label, history) + "\n");
1615
1971
  } else {
1616
1972
  console.log(c.red("\n " + res.text + "\n"));
1617
1973
  }
@@ -1632,9 +1988,16 @@ async function chat() {
1632
1988
  async function login(which) {
1633
1989
  const id = (which || "").toLowerCase();
1634
1990
  if (!BRAIN_IDS.includes(id)) {
1635
- console.log(c.dim(`
1991
+ if (which) {
1992
+ console.error(`
1993
+ Unknown brain "${which}". Use one of: ${BRAIN_IDS.join(", ")}
1994
+ `);
1995
+ process.exitCode = 1;
1996
+ } else {
1997
+ console.log(c.dim(`
1636
1998
  Usage: holt login <${BRAIN_IDS.join("|")}>
1637
1999
  `));
2000
+ }
1638
2001
  return;
1639
2002
  }
1640
2003
  const s = BRAIN_SETUP[id];
@@ -1645,7 +2008,7 @@ async function login(which) {
1645
2008
  }
1646
2009
 
1647
2010
  // src/commands/memory.ts
1648
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
2011
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
1649
2012
  async function memoryCmd(sub, rest = []) {
1650
2013
  const { ask, close } = createReader();
1651
2014
  if (!await ensureTrusted(ask)) {
@@ -1692,12 +2055,12 @@ async function memoryCmd(sub, rest = []) {
1692
2055
  return;
1693
2056
  }
1694
2057
  if (action === "facts") {
1695
- if (!existsSync6(factsMdPath())) {
2058
+ if (!existsSync7(factsMdPath())) {
1696
2059
  console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
1697
2060
  close();
1698
2061
  return;
1699
2062
  }
1700
- const body = readFileSync6(factsMdPath(), "utf8").trim();
2063
+ const body = readFileSync7(factsMdPath(), "utf8").trim();
1701
2064
  console.log("\n" + body + "\n");
1702
2065
  close();
1703
2066
  return;
@@ -1720,6 +2083,14 @@ async function memoryCmd(sub, rest = []) {
1720
2083
  close();
1721
2084
  return;
1722
2085
  }
2086
+ if (action && !["clear", "embed", "facts", "search"].includes(action)) {
2087
+ console.error(`
2088
+ Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | clear
2089
+ `);
2090
+ process.exitCode = 1;
2091
+ close();
2092
+ return;
2093
+ }
1723
2094
  const s = memStats();
1724
2095
  const embedOk = await embeddingsAvailable();
1725
2096
  const sessions = new Set(loadTurns().map((t) => t.session)).size;
@@ -1743,17 +2114,17 @@ async function memoryCmd(sub, rest = []) {
1743
2114
 
1744
2115
  // src/commands/skill.ts
1745
2116
  import {
1746
- mkdirSync as mkdirSync5,
1747
- writeFileSync as writeFileSync6,
1748
- readFileSync as readFileSync7,
1749
- existsSync as existsSync7,
2117
+ mkdirSync as mkdirSync6,
2118
+ writeFileSync as writeFileSync7,
2119
+ readFileSync as readFileSync8,
2120
+ existsSync as existsSync8,
1750
2121
  readdirSync as readdirSync2,
1751
2122
  statSync as statSync3,
1752
2123
  rmSync as rmSync3,
1753
2124
  cpSync,
1754
2125
  mkdtempSync
1755
2126
  } from "fs";
1756
- import { join as join7, resolve } from "path";
2127
+ import { join as join8, resolve } from "path";
1757
2128
  import { tmpdir } from "os";
1758
2129
  import { spawnSync as spawnSync2 } from "child_process";
1759
2130
  function usage() {
@@ -1799,12 +2170,12 @@ function isGitUrl(s) {
1799
2170
  return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
1800
2171
  }
1801
2172
  function findSkillDir(root) {
1802
- if (existsSync7(join7(root, "SKILL.md"))) return root;
2173
+ if (existsSync8(join8(root, "SKILL.md"))) return root;
1803
2174
  let subs = [];
1804
2175
  try {
1805
2176
  subs = readdirSync2(root).filter((f) => {
1806
2177
  try {
1807
- return statSync3(join7(root, f)).isDirectory();
2178
+ return statSync3(join8(root, f)).isDirectory();
1808
2179
  } catch {
1809
2180
  return false;
1810
2181
  }
@@ -1812,8 +2183,8 @@ function findSkillDir(root) {
1812
2183
  } catch {
1813
2184
  return null;
1814
2185
  }
1815
- const withSkill = subs.filter((f) => existsSync7(join7(root, f, "SKILL.md")));
1816
- if (withSkill.length === 1) return join7(root, withSkill[0]);
2186
+ const withSkill = subs.filter((f) => existsSync8(join8(root, f, "SKILL.md")));
2187
+ if (withSkill.length === 1) return join8(root, withSkill[0]);
1817
2188
  return null;
1818
2189
  }
1819
2190
  function cmdList() {
@@ -1844,15 +2215,16 @@ function cmdShow(name) {
1844
2215
  }
1845
2216
  const loaded = loadSkill(name);
1846
2217
  if (!loaded) {
1847
- console.log(c.dim(`
2218
+ console.error(c.dim(`
1848
2219
  No skill named "${sanitizeName(name)}". Try "holt skill list".
1849
2220
  `));
2221
+ process.exitCode = 1;
1850
2222
  return;
1851
2223
  }
1852
2224
  const { skill, body } = loaded;
1853
2225
  console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
1854
2226
  if (skill.description) console.log(" " + skill.description);
1855
- console.log(c.dim(" " + join7(skill.dir, "SKILL.md")));
2227
+ console.log(c.dim(" " + join8(skill.dir, "SKILL.md")));
1856
2228
  console.log("");
1857
2229
  console.log(c.dim(body));
1858
2230
  console.log("");
@@ -1864,18 +2236,19 @@ function cmdCreate(name, global) {
1864
2236
  return;
1865
2237
  }
1866
2238
  const scope = global ? "global" : "workspace";
1867
- const dir = join7(skillsRoot(scope), clean);
1868
- if (existsSync7(dir)) {
1869
- console.log(c.red(`
2239
+ const dir = join8(skillsRoot(scope), clean);
2240
+ if (existsSync8(dir)) {
2241
+ console.error(c.red(`
1870
2242
  A ${scope} skill named "${clean}" already exists.`));
1871
- console.log(c.dim(" " + dir + "\n"));
2243
+ console.error(c.dim(" " + dir + "\n"));
2244
+ process.exitCode = 1;
1872
2245
  return;
1873
2246
  }
1874
- mkdirSync5(dir, { recursive: true });
1875
- writeFileSync6(join7(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
2247
+ mkdirSync6(dir, { recursive: true });
2248
+ writeFileSync7(join8(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
1876
2249
  console.log(c.green(`
1877
2250
  Created ${scope} skill "${clean}".`));
1878
- console.log(c.dim(" " + join7(dir, "SKILL.md")));
2251
+ console.log(c.dim(" " + join8(dir, "SKILL.md")));
1879
2252
  console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
1880
2253
  }
1881
2254
  function cmdAdd(source, global) {
@@ -1888,57 +2261,62 @@ function cmdAdd(source, global) {
1888
2261
  let tempDir = "";
1889
2262
  try {
1890
2263
  if (isGitUrl(source)) {
1891
- tempDir = mkdtempSync(join7(tmpdir(), "holt-skill-"));
2264
+ tempDir = mkdtempSync(join8(tmpdir(), "holt-skill-"));
1892
2265
  console.log(c.dim(`
1893
2266
  Cloning ${source} ...`));
1894
2267
  const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
1895
2268
  if (res.status !== 0) {
1896
- console.log(c.red(" Clone failed. Check the URL and that git is installed.\n"));
2269
+ console.error(c.red(" Clone failed. Check the URL and that git is installed.\n"));
2270
+ process.exitCode = 1;
1897
2271
  return;
1898
2272
  }
1899
2273
  fetched = tempDir;
1900
2274
  } else {
1901
2275
  fetched = resolve(source);
1902
- if (!existsSync7(fetched)) {
1903
- console.log(c.red(`
2276
+ if (!existsSync8(fetched)) {
2277
+ console.error(c.red(`
1904
2278
  No such path: ${fetched}
1905
2279
  `));
2280
+ process.exitCode = 1;
1906
2281
  return;
1907
2282
  }
1908
2283
  }
1909
2284
  const skillDir = findSkillDir(fetched);
1910
2285
  if (!skillDir) {
1911
- console.log(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
2286
+ console.error(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
2287
+ process.exitCode = 1;
1912
2288
  return;
1913
2289
  }
1914
2290
  let skillName = "";
1915
2291
  try {
1916
- const { data } = parseFrontmatter(readFileSync7(join7(skillDir, "SKILL.md"), "utf8"));
2292
+ const { data } = parseFrontmatter(readFileSync8(join8(skillDir, "SKILL.md"), "utf8"));
1917
2293
  skillName = sanitizeName(data.name || "");
1918
2294
  } catch {
1919
2295
  skillName = "";
1920
2296
  }
1921
2297
  if (!skillName) skillName = sanitizeName(skillDir.split(/[\\/]/).pop() || "");
1922
2298
  if (!skillName) {
1923
- console.log(c.red("\n Could not determine a valid skill name.\n"));
2299
+ console.error(c.red("\n Could not determine a valid skill name.\n"));
2300
+ process.exitCode = 1;
1924
2301
  return;
1925
2302
  }
1926
- const dest = join7(skillsRoot(scope), skillName);
1927
- if (existsSync7(dest)) {
1928
- console.log(c.red(`
2303
+ const dest = join8(skillsRoot(scope), skillName);
2304
+ if (existsSync8(dest)) {
2305
+ console.error(c.red(`
1929
2306
  A ${scope} skill named "${skillName}" already exists.`));
1930
- console.log(c.dim(" " + dest + "\n"));
2307
+ console.error(c.dim(" " + dest + "\n"));
2308
+ process.exitCode = 1;
1931
2309
  return;
1932
2310
  }
1933
- mkdirSync5(skillsRoot(scope), { recursive: true });
2311
+ mkdirSync6(skillsRoot(scope), { recursive: true });
1934
2312
  cpSync(skillDir, dest, { recursive: true });
1935
- const nestedGit = join7(dest, ".git");
1936
- if (existsSync7(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
2313
+ const nestedGit = join8(dest, ".git");
2314
+ if (existsSync8(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
1937
2315
  console.log(c.green(`
1938
2316
  Installed ${scope} skill "${skillName}".`));
1939
- console.log(c.dim(" " + join7(dest, "SKILL.md") + "\n"));
2317
+ console.log(c.dim(" " + join8(dest, "SKILL.md") + "\n"));
1940
2318
  } finally {
1941
- if (tempDir && existsSync7(tempDir)) {
2319
+ if (tempDir && existsSync8(tempDir)) {
1942
2320
  try {
1943
2321
  rmSync3(tempDir, { recursive: true, force: true });
1944
2322
  } catch {
@@ -1952,13 +2330,14 @@ async function cmdRemove(name, ask) {
1952
2330
  console.log(c.dim("\n Usage: holt skill remove <name>\n"));
1953
2331
  return;
1954
2332
  }
1955
- const wsDir = join7(skillsRoot("workspace"), clean);
1956
- const globalDir = join7(skillsRoot("global"), clean);
1957
- const target = existsSync7(wsDir) ? wsDir : existsSync7(globalDir) ? globalDir : "";
2333
+ const wsDir = join8(skillsRoot("workspace"), clean);
2334
+ const globalDir = join8(skillsRoot("global"), clean);
2335
+ const target = existsSync8(wsDir) ? wsDir : existsSync8(globalDir) ? globalDir : "";
1958
2336
  if (!target) {
1959
- console.log(c.dim(`
2337
+ console.error(c.dim(`
1960
2338
  No skill named "${clean}" found.
1961
2339
  `));
2340
+ process.exitCode = 1;
1962
2341
  return;
1963
2342
  }
1964
2343
  const scope = target === wsDir ? "workspace" : "global";
@@ -2004,6 +2383,7 @@ async function skillCmd(sub, rest = []) {
2004
2383
  await cmdRemove(args[0], ask);
2005
2384
  break;
2006
2385
  default:
2386
+ if (action) process.exitCode = 1;
2007
2387
  usage();
2008
2388
  }
2009
2389
  } finally {
@@ -2012,8 +2392,8 @@ async function skillCmd(sub, rest = []) {
2012
2392
  }
2013
2393
 
2014
2394
  // src/commands/graph.ts
2015
- import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync6 } from "fs";
2016
- import { join as join8, dirname as dirname2, resolve as resolve2 } from "path";
2395
+ import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
2396
+ import { join as join9, dirname as dirname2, resolve as resolve2 } from "path";
2017
2397
  import { spawn as spawn3 } from "child_process";
2018
2398
 
2019
2399
  // src/graphview.ts
@@ -2178,7 +2558,7 @@ function buildGraph(turns) {
2178
2558
  return { nodes, edges };
2179
2559
  }
2180
2560
  function escapeForScript(json) {
2181
- return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\!--");
2561
+ return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\u0021--");
2182
2562
  }
2183
2563
  function escapeHtml2(s) {
2184
2564
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -2291,7 +2671,14 @@ function renderGraphHtml(graph2, meta) {
2291
2671
  <script>
2292
2672
  (function () {
2293
2673
  "use strict";
2294
- var DATA = JSON.parse(document.getElementById("graph-data").textContent);
2674
+ var DATA;
2675
+ try {
2676
+ DATA = JSON.parse(document.getElementById("graph-data").textContent);
2677
+ } catch (e) {
2678
+ document.getElementById("empty").textContent = "Could not read the graph data.";
2679
+ document.getElementById("empty").style.display = "flex";
2680
+ return;
2681
+ }
2295
2682
  var nodes = DATA.nodes || [];
2296
2683
  var edges = DATA.edges || [];
2297
2684
 
@@ -2677,9 +3064,9 @@ async function graph(args = []) {
2677
3064
  concepts: conceptCount,
2678
3065
  edges: g.edges.length
2679
3066
  });
2680
- const outPath = opts.out ? resolve2(opts.out) : join8(wsHoltDir(), "graph.html");
2681
- mkdirSync6(dirname2(outPath), { recursive: true });
2682
- writeFileSync7(outPath, html, "utf8");
3067
+ const outPath = opts.out ? resolve2(opts.out) : join9(wsHoltDir(), "graph.html");
3068
+ mkdirSync7(dirname2(outPath), { recursive: true });
3069
+ writeFileSync8(outPath, html, "utf8");
2683
3070
  console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
2684
3071
  console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
2685
3072
  console.log(` edges ${g.edges.length}`);
@@ -2851,6 +3238,7 @@ function runMcpServer(opts) {
2851
3238
 
2852
3239
  // src/mcp/tools.ts
2853
3240
  var MCP_SESSION = "__mcp__";
3241
+ var MCP_RECALL_SESSION = "__mcp_recall__";
2854
3242
  function holtTools() {
2855
3243
  return [
2856
3244
  {
@@ -2869,7 +3257,7 @@ function holtTools() {
2869
3257
  const query = String(args.query || "").trim();
2870
3258
  if (!query) return "Provide a query to recall.";
2871
3259
  const k = Number(args.k) || 5;
2872
- const hits = await recall(query, MCP_SESSION, k);
3260
+ const hits = await recall(query, MCP_RECALL_SESSION, k);
2873
3261
  if (hits.length === 0) return "No relevant memory found.";
2874
3262
  const lines = hits.map(
2875
3263
  (h) => `${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.replace(/\s+/g, " ").trim()}`
@@ -2961,6 +3349,9 @@ function holtTools() {
2961
3349
  ];
2962
3350
  }
2963
3351
 
3352
+ // src/version.ts
3353
+ var VERSION = "0.8.3";
3354
+
2964
3355
  // src/commands/mcp.ts
2965
3356
  function printSetup() {
2966
3357
  const config = JSON.stringify(
@@ -2969,94 +3360,44 @@ function printSetup() {
2969
3360
  2
2970
3361
  );
2971
3362
  const out = [
2972
- "Add Holt as an MCP server to your other tools.",
2973
- "",
2974
- "The server serves the memory of whatever folder it is launched in, and",
2975
- "auto-trusts that folder on first run.",
2976
- "",
2977
- "Claude Code:",
2978
- " claude mcp add holt -- holt mcp",
2979
- "",
2980
- "Cursor / Codex (JSON config):",
2981
- config,
2982
- ""
2983
- ].join("\n");
2984
- console.log(out);
2985
- }
2986
- async function mcp(sub, rest = []) {
2987
- void rest;
2988
- if (sub === "setup") {
2989
- printSetup();
2990
- return;
2991
- }
2992
- try {
2993
- if (!isTrusted()) trustDir();
2994
- console.error("Holt MCP: serving memory for " + workspace());
2995
- await runMcpServer({ name: "holt", version: "0.7.0", tools: holtTools() });
2996
- } catch (err2) {
2997
- console.error("Holt MCP failed: " + (err2 instanceof Error ? err2.message : String(err2)));
2998
- process.exitCode = 1;
2999
- }
3000
- }
3001
-
3002
- // src/commands/run.ts
3003
- import { writeFileSync as writeFileSync8 } from "fs";
3004
- import { resolve as resolve3 } from "path";
3005
-
3006
- // src/runner.ts
3007
- import { randomUUID as randomUUID3 } from "crypto";
3008
- function resolveActive2(cfg, id) {
3009
- if (BRAIN_IDS.includes(id)) {
3010
- const b = cfg.brains[id];
3011
- return { kind: "cli", id, label: b.label };
3012
- }
3013
- const api = findApiBrain(cfg, id);
3014
- if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
3015
- return null;
3016
- }
3017
- async function runTask(task, opts = {}) {
3018
- try {
3019
- const cfg = loadConfig();
3020
- if (!cfg || !cfg.defaultBrain) {
3021
- return { ok: false, text: "No Holt setup in this folder. Run holt init.", brainLabel: "" };
3022
- }
3023
- const wantId = opts.brainId ?? cfg.defaultBrain;
3024
- const active = resolveActive2(cfg, wantId);
3025
- if (!active) {
3026
- return { ok: false, text: `Brain "${wantId}" is not configured. Run holt setting.`, brainLabel: "" };
3027
- }
3028
- if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
3029
- return {
3030
- ok: false,
3031
- text: `${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Install it or run holt setting.`,
3032
- brainLabel: active.label
3033
- };
3034
- }
3035
- if (active.kind === "api" && !resolveApiKey(active.brain)) {
3036
- return {
3037
- ok: false,
3038
- text: `${active.label} has no API key. Run holt setting to add one.`,
3039
- brainLabel: active.label
3040
- };
3041
- }
3042
- const runSession = "run-" + newSessionId();
3043
- const recalled = opts.recall !== false ? await recall(task, runSession, 4) : [];
3044
- const block = skillsPromptBlock();
3045
- const base = renderPrompt([], task, recalled);
3046
- const prompt = block ? block + "\n\n" + base : base;
3047
- const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, opts.onChunk) : await runApiBrain(active.brain, prompt, opts.onChunk);
3048
- if (res.ok && opts.store !== false) {
3049
- const now = Date.now();
3050
- appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session: runSession, role: "user", content: task, emb: await embed(task) ?? void 0 });
3051
- appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session: runSession, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
3052
- }
3053
- return { ok: res.ok, text: res.text, brainLabel: active.label };
3054
- } catch (e) {
3055
- return { ok: false, text: e.message, brainLabel: "" };
3363
+ "Add Holt as an MCP server to your other tools.",
3364
+ "",
3365
+ "The server serves the memory of whatever folder it is launched in, and",
3366
+ "auto-trusts that folder on first run.",
3367
+ "",
3368
+ "Claude Code:",
3369
+ " claude mcp add holt -- holt mcp",
3370
+ "",
3371
+ "Cursor / Codex (JSON config):",
3372
+ config,
3373
+ ""
3374
+ ].join("\n");
3375
+ console.log(out);
3376
+ }
3377
+ async function mcp(sub, rest = []) {
3378
+ void rest;
3379
+ if (sub === "setup") {
3380
+ printSetup();
3381
+ return;
3382
+ }
3383
+ if (sub !== void 0) {
3384
+ console.error(`Unknown mcp subcommand: "${sub}". Use "holt mcp" to serve, or "holt mcp setup".`);
3385
+ process.exitCode = 1;
3386
+ return;
3387
+ }
3388
+ try {
3389
+ if (!isTrusted()) trustDir();
3390
+ console.error("Holt MCP: serving memory for " + workspace());
3391
+ await runMcpServer({ name: "holt", version: VERSION, tools: holtTools() });
3392
+ } catch (err2) {
3393
+ console.error("Holt MCP failed: " + (err2 instanceof Error ? err2.message : String(err2)));
3394
+ process.exitCode = 1;
3056
3395
  }
3057
3396
  }
3058
3397
 
3059
3398
  // src/commands/run.ts
3399
+ import { writeFileSync as writeFileSync9 } from "fs";
3400
+ import { resolve as resolve3 } from "path";
3060
3401
  var USAGE = [
3061
3402
  " holt run <task> [options]",
3062
3403
  "",
@@ -3132,58 +3473,64 @@ async function run(args) {
3132
3473
  process.stdout.write(ch);
3133
3474
  }
3134
3475
  });
3476
+ if (!result.ok) {
3477
+ if (streamed) process.stdout.write("\n");
3478
+ process.stderr.write(c.red(result.text) + "\n");
3479
+ process.exitCode = 1;
3480
+ return;
3481
+ }
3135
3482
  if (!p.quiet && !streamed) {
3136
3483
  process.stdout.write(result.text);
3137
3484
  }
3138
3485
  if (!p.quiet && (streamed || result.text) && !result.text.endsWith("\n")) {
3139
3486
  process.stdout.write("\n");
3140
3487
  }
3141
- if (!result.ok) {
3142
- process.stderr.write(c.red(result.text) + "\n");
3143
- process.exitCode = 1;
3144
- return;
3145
- }
3146
3488
  if (p.out) {
3147
3489
  const target = resolve3(process.cwd(), p.out);
3148
- writeFileSync8(target, result.text, "utf8");
3149
- process.stderr.write(c.dim(`saved to ${target}`) + "\n");
3490
+ try {
3491
+ writeFileSync9(target, result.text, "utf8");
3492
+ process.stderr.write(c.dim(`saved to ${target}`) + "\n");
3493
+ } catch (e) {
3494
+ process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
3495
+ process.exitCode = 1;
3496
+ }
3150
3497
  }
3151
3498
  }
3152
3499
 
3153
3500
  // src/commands/schedule.ts
3154
3501
  import { spawnSync as spawnSync4 } from "child_process";
3155
- import { writeFileSync as writeFileSync10, mkdirSync as mkdirSync8, rmSync as rmSync4, existsSync as existsSync8 } from "fs";
3502
+ import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync9, rmSync as rmSync4, existsSync as existsSync9 } from "fs";
3156
3503
  import { dirname as dirname3 } from "path";
3157
3504
 
3158
3505
  // src/scheduler.ts
3159
3506
  import { homedir as homedir3 } from "os";
3160
- import { join as join9 } from "path";
3161
- import { readFileSync as readFileSync8, writeFileSync as writeFileSync9, mkdirSync as mkdirSync7 } from "fs";
3507
+ import { join as join10 } from "path";
3508
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync10, mkdirSync as mkdirSync8 } from "fs";
3162
3509
  import { randomUUID as randomUUID4 } from "crypto";
3163
3510
  import { spawnSync as spawnSync3 } from "child_process";
3164
3511
  function schedulesPath() {
3165
- return join9(GLOBAL_DIR, "schedules.json");
3512
+ return join10(GLOBAL_DIR, "schedules.json");
3166
3513
  }
3167
3514
  function logsDir() {
3168
- return join9(GLOBAL_DIR, "logs");
3515
+ return join10(GLOBAL_DIR, "logs");
3169
3516
  }
3170
3517
  function logPath(id) {
3171
- return join9(logsDir(), `${id}.log`);
3518
+ return join10(logsDir(), `${id}.log`);
3172
3519
  }
3173
3520
  function plistPath(id) {
3174
- return join9(homedir3(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
3521
+ return join10(homedir3(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
3175
3522
  }
3176
3523
  function loadJobs() {
3177
3524
  try {
3178
- const raw = JSON.parse(readFileSync8(schedulesPath(), "utf8"));
3525
+ const raw = JSON.parse(readFileSync9(schedulesPath(), "utf8"));
3179
3526
  return Array.isArray(raw) ? raw : [];
3180
3527
  } catch {
3181
3528
  return [];
3182
3529
  }
3183
3530
  }
3184
3531
  function saveJobs(jobs) {
3185
- mkdirSync7(GLOBAL_DIR, { recursive: true });
3186
- writeFileSync9(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
3532
+ mkdirSync8(GLOBAL_DIR, { recursive: true });
3533
+ writeFileSync10(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
3187
3534
  }
3188
3535
  function addJob(job) {
3189
3536
  const jobs = loadJobs();
@@ -3287,7 +3634,7 @@ function appendCronLine(crontab, line) {
3287
3634
  return (base ? base + "\n" : "") + line + "\n";
3288
3635
  }
3289
3636
  function ensureLogsDir() {
3290
- mkdirSync7(logsDir(), { recursive: true });
3637
+ mkdirSync8(logsDir(), { recursive: true });
3291
3638
  }
3292
3639
 
3293
3640
  // src/commands/schedule.ts
@@ -3313,8 +3660,8 @@ function parseAdd(rest) {
3313
3660
  }
3314
3661
  function installDarwin(job, holtPath) {
3315
3662
  const path = plistPath(job.id);
3316
- mkdirSync8(dirname3(path), { recursive: true });
3317
- writeFileSync10(path, buildLaunchdPlist(job, holtPath), "utf8");
3663
+ mkdirSync9(dirname3(path), { recursive: true });
3664
+ writeFileSync11(path, buildLaunchdPlist(job, holtPath), "utf8");
3318
3665
  const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
3319
3666
  if (res.status === 0) {
3320
3667
  console.log(c.green(" Loaded into launchd."));
@@ -3348,12 +3695,14 @@ async function addCmd(rest) {
3348
3695
  const { task, when, opts } = parseAdd(rest);
3349
3696
  if (!task || !when) {
3350
3697
  console.log(c.dim('\n Usage: holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]\n'));
3698
+ process.exitCode = 1;
3351
3699
  return;
3352
3700
  }
3353
3701
  try {
3354
3702
  parseWhen(when);
3355
3703
  } catch (e) {
3356
- console.log("\n " + c.red(e.message) + "\n");
3704
+ console.error("\n " + c.red(e.message) + "\n");
3705
+ process.exitCode = 1;
3357
3706
  return;
3358
3707
  }
3359
3708
  const ws = workspace();
@@ -3400,7 +3749,7 @@ function listCmd() {
3400
3749
  }
3401
3750
  function removeDarwin(id) {
3402
3751
  const path = plistPath(id);
3403
- if (existsSync8(path)) {
3752
+ if (existsSync9(path)) {
3404
3753
  spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
3405
3754
  try {
3406
3755
  rmSync4(path);
@@ -3418,6 +3767,7 @@ function removeCmd(rest) {
3418
3767
  const id = rest[0];
3419
3768
  if (!id) {
3420
3769
  console.log(c.dim("\n Usage: holt schedule remove <id> (see ids with: holt schedule list)\n"));
3770
+ process.exitCode = 1;
3421
3771
  return;
3422
3772
  }
3423
3773
  const before = loadJobs();
@@ -3426,9 +3776,12 @@ function removeCmd(rest) {
3426
3776
  if (process.platform === "darwin") removeDarwin(id);
3427
3777
  else if (process.platform === "linux") removeLinux(id);
3428
3778
  if (existed) console.log("\n" + c.green(` Removed schedule ${id}.`) + "\n");
3429
- else console.log(c.dim(`
3779
+ else {
3780
+ console.error(c.dim(`
3430
3781
  No schedule with id ${id}. Nothing to remove.
3431
3782
  `));
3783
+ process.exitCode = 1;
3784
+ }
3432
3785
  }
3433
3786
  function usage2() {
3434
3787
  console.log("\n" + c.accent("holt schedule") + c.dim(" run a task automatically on a timer"));
@@ -3443,6 +3796,7 @@ async function schedule(sub, rest = []) {
3443
3796
  return;
3444
3797
  }
3445
3798
  if (action !== "add" && action !== "remove" && action !== "rm") {
3799
+ if (action) process.exitCode = 1;
3446
3800
  usage2();
3447
3801
  return;
3448
3802
  }
@@ -3456,249 +3810,6 @@ async function schedule(sub, rest = []) {
3456
3810
  else removeCmd(rest);
3457
3811
  }
3458
3812
 
3459
- // src/telegram.ts
3460
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync11, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
3461
- import { join as join10 } from "path";
3462
- var API_BASE = "https://api.telegram.org";
3463
- var CHUNK_SIZE = 4e3;
3464
- function telegramConfigPath() {
3465
- return join10(GLOBAL_DIR, "telegram.json");
3466
- }
3467
- function loadTelegramConfig() {
3468
- const path = telegramConfigPath();
3469
- if (!existsSync9(path)) return null;
3470
- try {
3471
- const raw = JSON.parse(readFileSync9(path, "utf8"));
3472
- if (typeof raw.token !== "string" || typeof raw.allowedChatId !== "number") return null;
3473
- return { token: raw.token, allowedChatId: raw.allowedChatId };
3474
- } catch {
3475
- return null;
3476
- }
3477
- }
3478
- function saveTelegramConfig(cfg) {
3479
- mkdirSync9(GLOBAL_DIR, { recursive: true });
3480
- writeFileSync11(telegramConfigPath(), JSON.stringify(cfg, null, 2) + "\n", {
3481
- encoding: "utf8",
3482
- mode: 384
3483
- });
3484
- }
3485
- function buildSendRequest(token, chatId, text) {
3486
- return {
3487
- url: `${API_BASE}/bot${token}/sendMessage`,
3488
- body: JSON.stringify({ chat_id: chatId, text })
3489
- };
3490
- }
3491
- function buildGetUpdatesUrl(token, offset, timeoutSec) {
3492
- const params = new URLSearchParams({
3493
- offset: String(offset),
3494
- timeout: String(timeoutSec)
3495
- });
3496
- return `${API_BASE}/bot${token}/getUpdates?${params.toString()}`;
3497
- }
3498
- function chunk(text, size = CHUNK_SIZE) {
3499
- if (text.length === 0) return [""];
3500
- const out = [];
3501
- for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
3502
- return out;
3503
- }
3504
- async function sendMessage(text, chatId) {
3505
- const cfg = loadTelegramConfig();
3506
- if (!cfg) {
3507
- process.stderr.write('[telegram] not configured; run "holt telegram setup"\n');
3508
- return false;
3509
- }
3510
- const target = chatId ?? cfg.allowedChatId;
3511
- const pieces = chunk(text || "No output.");
3512
- let allOk = true;
3513
- for (const piece of pieces) {
3514
- const { url, body } = buildSendRequest(cfg.token, target, piece);
3515
- try {
3516
- const res = await fetch(url, {
3517
- method: "POST",
3518
- headers: { "content-type": "application/json" },
3519
- body
3520
- });
3521
- if (!res.ok) {
3522
- allOk = false;
3523
- process.stderr.write(`[telegram] sendMessage HTTP ${res.status}
3524
- `);
3525
- }
3526
- } catch (err2) {
3527
- allOk = false;
3528
- process.stderr.write(`[telegram] sendMessage failed: ${err2.message}
3529
- `);
3530
- }
3531
- }
3532
- return allOk;
3533
- }
3534
- async function getUpdates(offset) {
3535
- const cfg = loadTelegramConfig();
3536
- if (!cfg) return [];
3537
- const timeoutSec = 30;
3538
- const url = buildGetUpdatesUrl(cfg.token, offset, timeoutSec);
3539
- const ac = new AbortController();
3540
- const timer = setTimeout(() => ac.abort(), (timeoutSec + 10) * 1e3);
3541
- try {
3542
- const res = await fetch(url, { signal: ac.signal });
3543
- if (!res.ok) {
3544
- process.stderr.write(`[telegram] getUpdates HTTP ${res.status}
3545
- `);
3546
- return [];
3547
- }
3548
- const data = await res.json();
3549
- if (!data || data.ok !== true || !Array.isArray(data.result)) return [];
3550
- return data.result;
3551
- } catch (err2) {
3552
- process.stderr.write(`[telegram] getUpdates failed: ${err2.message}
3553
- `);
3554
- return [];
3555
- } finally {
3556
- clearTimeout(timer);
3557
- }
3558
- }
3559
-
3560
- // src/commands/telegram.ts
3561
- var HELP_TEXT = "Holt bot. Send me anything and I will run it through your selected brain and reply.\nCommands: /help this message.";
3562
- async function telegram(sub, _rest = []) {
3563
- const action = (sub || "").toLowerCase();
3564
- if (action === "setup") {
3565
- await setup();
3566
- return;
3567
- }
3568
- await runBot();
3569
- }
3570
- async function setup() {
3571
- const { ask, close } = createReader();
3572
- try {
3573
- console.log("\n" + c.accent("Connect Holt to Telegram"));
3574
- console.log(c.dim(" 1. Open Telegram and message @BotFather."));
3575
- console.log(c.dim(" 2. Send /newbot, follow the prompts, and copy the bot token it gives you."));
3576
- console.log(c.dim(" A token looks like 123456789:AAF... (keep it secret)."));
3577
- const token = (await ask("\n Paste your bot token: ") ?? "").trim();
3578
- if (!token) {
3579
- console.log(c.red(" No token entered. Cancelled.\n"));
3580
- return;
3581
- }
3582
- console.log("\n" + c.dim(" Now I need your chat id (so I only answer you)."));
3583
- console.log(c.dim(" Option A: open your new bot in Telegram and send it any message,"));
3584
- console.log(c.dim(" then I will auto-detect the chat id."));
3585
- console.log(c.dim(" Option B: paste a numeric chat id yourself."));
3586
- const mode = (await ask("\n Auto-detect from a message you just sent? [Y/n] ") ?? "").trim().toLowerCase();
3587
- let allowedChatId = null;
3588
- if (mode === "" || mode === "y" || mode === "yes") {
3589
- saveTelegramConfig({ token, allowedChatId: 0 });
3590
- console.log(c.dim("\n Checking for a recent message..."));
3591
- const updates = await getUpdates(0);
3592
- allowedChatId = latestChatId(updates);
3593
- if (allowedChatId === null) {
3594
- console.log(c.red(" Did not find a recent message."));
3595
- const manual = (await ask(" Paste your numeric chat id instead: ") ?? "").trim();
3596
- allowedChatId = parseChatId(manual);
3597
- }
3598
- } else {
3599
- const manual = (await ask(" Paste your numeric chat id: ") ?? "").trim();
3600
- allowedChatId = parseChatId(manual);
3601
- }
3602
- if (allowedChatId === null) {
3603
- console.log(c.red('\n No valid chat id. Nothing saved. Run "holt telegram setup" again.\n'));
3604
- return;
3605
- }
3606
- const cfg = { token, allowedChatId };
3607
- saveTelegramConfig(cfg);
3608
- console.log(
3609
- "\n" + c.green(" Saved.") + c.dim(` token ...${token.slice(-4)} chat id ${allowedChatId} (~/.holt/telegram.json, mode 600)`)
3610
- );
3611
- console.log(c.dim(" Start the bot with: holt telegram\n"));
3612
- } finally {
3613
- close();
3614
- }
3615
- }
3616
- function parseChatId(s) {
3617
- if (!/^-?\d+$/.test(s)) return null;
3618
- const n = Number(s);
3619
- return Number.isFinite(n) ? n : null;
3620
- }
3621
- function latestChatId(updates) {
3622
- for (let i = updates.length - 1; i >= 0; i--) {
3623
- const u = updates[i];
3624
- if (u && u.message) return u.message.chat.id;
3625
- }
3626
- return null;
3627
- }
3628
- async function runBot() {
3629
- const cfg = loadTelegramConfig();
3630
- if (!cfg) {
3631
- console.log(c.dim('\n Telegram is not set up. Run "holt telegram setup" first.\n'));
3632
- return;
3633
- }
3634
- if (!isTrusted()) {
3635
- trustDir();
3636
- process.stderr.write(`[telegram] auto-trusted ${workspace()}
3637
- `);
3638
- }
3639
- process.stderr.write("[telegram] bot started; long-polling for messages. Ctrl-C to stop.\n");
3640
- let offset = 0;
3641
- let busy = false;
3642
- while (true) {
3643
- let updates = [];
3644
- try {
3645
- updates = await getUpdates(offset);
3646
- } catch (err2) {
3647
- process.stderr.write(`[telegram] poll error: ${err2.message}
3648
- `);
3649
- await sleep(1e3);
3650
- continue;
3651
- }
3652
- for (const u of updates) {
3653
- if (u && typeof u.update_id === "number") offset = Math.max(offset, u.update_id + 1);
3654
- try {
3655
- await handleUpdate(u, cfg, () => busy, (v) => {
3656
- busy = v;
3657
- });
3658
- } catch (err2) {
3659
- process.stderr.write(`[telegram] update ${u?.update_id} failed: ${err2.message}
3660
- `);
3661
- }
3662
- }
3663
- }
3664
- }
3665
- async function handleUpdate(u, cfg, getBusy, setBusy) {
3666
- const msg = u?.message;
3667
- if (!msg || typeof msg.text !== "string") return;
3668
- if (msg.chat.id !== cfg.allowedChatId) {
3669
- process.stderr.write(`[telegram] ignoring message from chat ${msg.chat.id}
3670
- `);
3671
- return;
3672
- }
3673
- const text = msg.text.trim();
3674
- if (text === "/start" || text === "/help") {
3675
- await sendMessage(HELP_TEXT, cfg.allowedChatId);
3676
- return;
3677
- }
3678
- if (getBusy()) {
3679
- await sendMessage("Still working on the previous message. Try again in a moment.", cfg.allowedChatId);
3680
- return;
3681
- }
3682
- setBusy(true);
3683
- try {
3684
- process.stderr.write(`[telegram] running task (${text.length} chars)
3685
- `);
3686
- const result = await runTask(text);
3687
- await sendMessage(result.text || "No output.", cfg.allowedChatId);
3688
- process.stderr.write(`[telegram] replied (brain: ${result.brainLabel})
3689
- `);
3690
- } catch (err2) {
3691
- process.stderr.write(`[telegram] runTask failed: ${err2.message}
3692
- `);
3693
- await sendMessage("Sorry, that run failed. Check the Holt logs.", cfg.allowedChatId);
3694
- } finally {
3695
- setBusy(false);
3696
- }
3697
- }
3698
- function sleep(ms) {
3699
- return new Promise((r) => setTimeout(r, ms));
3700
- }
3701
-
3702
3813
  // src/commands/notify.ts
3703
3814
  import { readFileSync as readFileSync10 } from "fs";
3704
3815
  async function notify(args) {
@@ -3740,7 +3851,6 @@ function readStdin() {
3740
3851
  }
3741
3852
 
3742
3853
  // src/cli.ts
3743
- var VERSION = "0.8.0";
3744
3854
  var BANNER = `
3745
3855
  \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
3746
3856
  \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D
@@ -3829,9 +3939,9 @@ async function main() {
3829
3939
  await notify(process.argv.slice(3));
3830
3940
  break;
3831
3941
  default:
3832
- console.log(`
3942
+ console.error(`
3833
3943
  Unknown command: "${cmd}"`);
3834
- console.log(` Run "holt help" for usage.
3944
+ console.error(` Run "holt help" for usage.
3835
3945
  `);
3836
3946
  process.exitCode = 1;
3837
3947
  }