@holt-os/holt 0.7.0 → 0.8.2

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 +48 -5
  2. package/dist/cli.js +1303 -519
  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 = [];
@@ -36,11 +41,11 @@ function createReader() {
36
41
  closed = true;
37
42
  while (waiters.length) waiters.shift()(null);
38
43
  });
39
- const ask = (q) => new Promise((resolve3) => {
44
+ const ask = (q) => new Promise((resolve4) => {
40
45
  if (q) process.stdout.write(q);
41
- if (buffer.length) resolve3(buffer.shift());
42
- else if (closed) resolve3(null);
43
- else waiters.push(resolve3);
46
+ if (buffer.length) resolve4(buffer.shift());
47
+ else if (closed) resolve4(null);
48
+ else waiters.push(resolve4);
44
49
  });
45
50
  return { ask, close: () => rl.close() };
46
51
  }
@@ -222,12 +227,12 @@ function renderPrompt(history, message, memory = []) {
222
227
  return parts.join("\n");
223
228
  }
224
229
  function runBrain(brain, prompt, onChunk) {
225
- return new Promise((resolve3) => {
230
+ return new Promise((resolve4) => {
226
231
  let child;
227
232
  try {
228
233
  child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
229
234
  } catch (e) {
230
- resolve3({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
235
+ resolve4({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
231
236
  return;
232
237
  }
233
238
  let out = "";
@@ -240,11 +245,11 @@ function runBrain(brain, prompt, onChunk) {
240
245
  child.stderr.on("data", (d) => {
241
246
  err2 += d.toString();
242
247
  });
243
- child.on("error", (e) => resolve3({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
248
+ child.on("error", (e) => resolve4({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
244
249
  child.on("close", (code) => {
245
250
  const text = out.trim();
246
- if (code === 0 && text) resolve3({ ok: true, text });
247
- else resolve3({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
251
+ if (code === 0 && text) resolve4({ ok: true, text });
252
+ else resolve4({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
248
253
  });
249
254
  });
250
255
  }
@@ -393,16 +398,16 @@ function currentAlias() {
393
398
  // src/install.ts
394
399
  import { spawn as spawn2 } from "child_process";
395
400
  function runInteractive(cmd, args) {
396
- return new Promise((resolve3) => {
401
+ return new Promise((resolve4) => {
397
402
  let child;
398
403
  try {
399
404
  child = spawn2(cmd, args, { stdio: "inherit" });
400
405
  } catch {
401
- resolve3(-1);
406
+ resolve4(-1);
402
407
  return;
403
408
  }
404
- child.on("error", () => resolve3(-1));
405
- child.on("close", (code) => resolve3(code ?? -1));
409
+ child.on("error", () => resolve4(-1));
410
+ child.on("close", (code) => resolve4(code ?? -1));
406
411
  });
407
412
  }
408
413
 
@@ -594,341 +599,161 @@ async function backfillEmbeddings(onProgress) {
594
599
  return { embedded, total: missing.length };
595
600
  }
596
601
 
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"));
619
- }
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.`));
602
+ // src/telegram.ts
603
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5, mkdirSync as mkdirSync5 } from "fs";
604
+ import { join as join5 } from "path";
605
+ var API_BASE = "https://api.telegram.org";
606
+ var CHUNK_SIZE = 4e3;
607
+ function telegramConfigPath() {
608
+ return join5(GLOBAL_DIR, "telegram.json");
609
+ }
610
+ function loadTelegramConfig() {
611
+ const path = telegramConfigPath();
612
+ if (!existsSync5(path)) return null;
613
+ try {
614
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
615
+ if (typeof raw.token !== "string" || typeof raw.allowedChatId !== "number") return null;
616
+ return { token: raw.token, allowedChatId: raw.allowedChatId };
617
+ } catch {
650
618
  return null;
651
619
  }
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
620
  }
670
- function selectableIds(cfg) {
671
- return [...BRAIN_IDS.filter((id) => cfg.brains[id].enabled), ...cfg.apiBrains.map((a) => a.id)];
621
+ function saveTelegramConfig(cfg) {
622
+ mkdirSync5(GLOBAL_DIR, { recursive: true });
623
+ writeFileSync5(telegramConfigPath(), JSON.stringify(cfg, null, 2) + "\n", {
624
+ encoding: "utf8",
625
+ mode: 384
626
+ });
672
627
  }
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."));
628
+ function buildSendRequest(token, chatId, text) {
629
+ return {
630
+ url: `${API_BASE}/bot${token}/sendMessage`,
631
+ body: JSON.stringify({ chat_id: chatId, text })
632
+ };
633
+ }
634
+ function buildGetUpdatesUrl(token, offset, timeoutSec) {
635
+ const params = new URLSearchParams({
636
+ offset: String(offset),
637
+ timeout: String(timeoutSec)
638
+ });
639
+ return `${API_BASE}/bot${token}/getUpdates?${params.toString()}`;
640
+ }
641
+ function chunk(text, size = CHUNK_SIZE) {
642
+ if (text.length === 0) return [""];
643
+ const out = [];
644
+ for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
645
+ return out;
646
+ }
647
+ async function sendMessage(text, chatId) {
648
+ const cfg = loadTelegramConfig();
649
+ if (!cfg) {
650
+ process.stderr.write('[telegram] not configured; run "holt telegram setup"\n');
651
+ return false;
652
+ }
653
+ const target = chatId ?? cfg.allowedChatId;
654
+ const pieces = chunk(text || "No output.");
655
+ let allOk = true;
656
+ for (const piece of pieces) {
657
+ const { url, body } = buildSendRequest(cfg.token, target, piece);
658
+ try {
659
+ const res = await fetch(url, {
660
+ method: "POST",
661
+ headers: { "content-type": "application/json" },
662
+ body
663
+ });
664
+ if (!res.ok) {
665
+ allOk = false;
666
+ process.stderr.write(`[telegram] sendMessage HTTP ${res.status}
667
+ `);
729
668
  }
730
- } else {
731
- console.log(c.dim(" pick d, t, c, x, a, or press enter to finish."));
669
+ } catch (err2) {
670
+ allOk = false;
671
+ process.stderr.write(`[telegram] sendMessage failed: ${err2.message}
672
+ `);
732
673
  }
733
- saveConfig(cfg);
734
674
  }
735
- saveConfig(cfg);
736
- return cfg;
675
+ return allOk;
737
676
  }
738
- async function setting() {
739
- const { ask, close } = createReader();
740
- if (!await ensureTrusted(ask)) {
741
- close();
742
- return;
677
+ async function getUpdates(offset) {
678
+ const cfg = loadTelegramConfig();
679
+ if (!cfg) return [];
680
+ const timeoutSec = 30;
681
+ const url = buildGetUpdatesUrl(cfg.token, offset, timeoutSec);
682
+ const ac = new AbortController();
683
+ const timer = setTimeout(() => ac.abort(), (timeoutSec + 10) * 1e3);
684
+ try {
685
+ const res = await fetch(url, { signal: ac.signal });
686
+ if (!res.ok) {
687
+ process.stderr.write(`[telegram] getUpdates HTTP ${res.status}
688
+ `);
689
+ return [];
690
+ }
691
+ const data = await res.json();
692
+ if (!data || data.ok !== true || !Array.isArray(data.result)) return [];
693
+ return data.result;
694
+ } catch (err2) {
695
+ process.stderr.write(`[telegram] getUpdates failed: ${err2.message}
696
+ `);
697
+ return [];
698
+ } finally {
699
+ clearTimeout(timer);
743
700
  }
744
- await runSettings(ask);
745
- close();
746
- console.log("");
747
701
  }
748
702
 
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"];
703
+ // src/runner.ts
704
+ import { randomUUID as randomUUID2 } from "crypto";
705
+
706
+ // src/apibrain.ts
707
+ function parseSSELines(buffer) {
708
+ const normalized = buffer.replace(/\r\n/g, "\n");
709
+ const parts = normalized.split("\n\n");
710
+ const rest = parts.pop() ?? "";
711
+ const events = parts.filter((p) => p.trim() !== "");
712
+ return { events, rest };
757
713
  }
758
- async function init() {
759
- const { ask, close } = createReader();
760
- if (!await ensureTrusted(ask)) {
761
- close();
762
- return;
714
+ function dataLines(event) {
715
+ const out = [];
716
+ for (const line of event.split("\n")) {
717
+ const t = line.replace(/\r$/, "");
718
+ if (t.startsWith("data:")) out.push(t.slice(5).replace(/^ /, ""));
763
719
  }
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);
720
+ return out;
721
+ }
722
+ function extractDelta(provider, json) {
723
+ const j = json;
724
+ if (provider === "anthropic") {
725
+ if (j["type"] === "content_block_delta") {
726
+ const delta = j["delta"];
727
+ if (delta && delta.type === "text_delta" && typeof delta.text === "string") return delta.text;
728
+ }
729
+ return "";
771
730
  }
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);
731
+ if (provider === "openai") {
732
+ const choices = j["choices"];
733
+ const content = choices?.[0]?.delta?.content;
734
+ return typeof content === "string" ? content : "";
783
735
  }
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(/^ /, ""));
894
- }
895
- return out;
896
- }
897
- function extractDelta(provider, json) {
898
- const j = json;
899
- if (provider === "anthropic") {
900
- if (j["type"] === "content_block_delta") {
901
- const delta = j["delta"];
902
- if (delta && delta.type === "text_delta" && typeof delta.text === "string") return delta.text;
903
- }
904
- return "";
905
- }
906
- if (provider === "openai") {
907
- const choices = j["choices"];
908
- const content = choices?.[0]?.delta?.content;
909
- return typeof content === "string" ? content : "";
910
- }
911
- const candidates = j["candidates"];
912
- const parts = candidates?.[0]?.content?.parts;
913
- if (!Array.isArray(parts)) return "";
914
- return parts.map((p) => typeof p.text === "string" ? p.text : "").join("");
915
- }
916
- function buildRequest(brain, key, prompt) {
917
- if (brain.provider === "anthropic") {
918
- return {
919
- url: "https://api.anthropic.com/v1/messages",
920
- headers: {
921
- "content-type": "application/json",
922
- "x-api-key": key,
923
- "anthropic-version": "2023-06-01"
924
- },
925
- body: JSON.stringify({
926
- model: brain.model,
927
- max_tokens: 4096,
928
- stream: true,
929
- messages: [{ role: "user", content: prompt }]
930
- })
931
- };
736
+ const candidates = j["candidates"];
737
+ const parts = candidates?.[0]?.content?.parts;
738
+ if (!Array.isArray(parts)) return "";
739
+ return parts.map((p) => typeof p.text === "string" ? p.text : "").join("");
740
+ }
741
+ function buildRequest(brain, key, prompt) {
742
+ if (brain.provider === "anthropic") {
743
+ return {
744
+ url: "https://api.anthropic.com/v1/messages",
745
+ headers: {
746
+ "content-type": "application/json",
747
+ "x-api-key": key,
748
+ "anthropic-version": "2023-06-01"
749
+ },
750
+ body: JSON.stringify({
751
+ model: brain.model,
752
+ max_tokens: 4096,
753
+ stream: true,
754
+ messages: [{ role: "user", content: prompt }]
755
+ })
756
+ };
932
757
  }
933
758
  if (brain.provider === "openai") {
934
759
  return {
@@ -988,8 +813,8 @@ async function runApiBrain(brain, prompt, onChunk) {
988
813
  let carry = "";
989
814
  let text = "";
990
815
  try {
991
- for await (const chunk of res.body) {
992
- carry += decoder.decode(chunk, { stream: true });
816
+ for await (const chunk2 of res.body) {
817
+ carry += decoder.decode(chunk2, { stream: true });
993
818
  const { events, rest } = parseSSELines(carry);
994
819
  carry = rest;
995
820
  for (const ev of events) {
@@ -1031,6 +856,625 @@ async function runApiBrain(brain, prompt, onChunk) {
1031
856
  return { ok: false, text: `${brain.provider} returned an empty reply.` };
1032
857
  }
1033
858
 
859
+ // src/skills.ts
860
+ import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync6, statSync as statSync2 } from "fs";
861
+ import { join as join6 } from "path";
862
+ function skillsRoot(scope) {
863
+ return scope === "workspace" ? join6(wsHoltDir(), "skills") : join6(GLOBAL_DIR, "skills");
864
+ }
865
+ function sanitizeName(name) {
866
+ return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
867
+ }
868
+ function parseFrontmatter(md) {
869
+ const text = md.replace(/^/, "");
870
+ const lines = text.split("\n");
871
+ let start = 0;
872
+ while (start < lines.length && lines[start].trim() === "") start++;
873
+ if (start >= lines.length || lines[start].trim() !== "---") {
874
+ return { data: {}, body: text.trim() };
875
+ }
876
+ const data = {};
877
+ let i = start + 1;
878
+ let closed = false;
879
+ for (; i < lines.length; i++) {
880
+ const line = lines[i];
881
+ if (line.trim() === "---") {
882
+ closed = true;
883
+ i++;
884
+ break;
885
+ }
886
+ const idx = line.indexOf(":");
887
+ if (idx === -1) continue;
888
+ const key = line.slice(0, idx).trim();
889
+ if (!key) continue;
890
+ let value = line.slice(idx + 1).trim();
891
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
892
+ value = value.slice(1, -1);
893
+ }
894
+ data[key.toLowerCase()] = value;
895
+ }
896
+ if (!closed) {
897
+ return { data: {}, body: text.trim() };
898
+ }
899
+ return { data, body: lines.slice(i).join("\n").trim() };
900
+ }
901
+ function readSkill(dir, folder, scope) {
902
+ const file = join6(dir, "SKILL.md");
903
+ if (!existsSync6(file)) return null;
904
+ let raw = "";
905
+ try {
906
+ raw = readFileSync6(file, "utf8");
907
+ } catch {
908
+ return null;
909
+ }
910
+ const { data, body } = parseFrontmatter(raw);
911
+ const name = sanitizeName(data.name || folder) || folder;
912
+ let description = data.description || "";
913
+ if (!description) {
914
+ const firstLine2 = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
915
+ description = firstLine2.replace(/^#+\s*/, "");
916
+ }
917
+ return { name, description, dir, scope };
918
+ }
919
+ function listScope(scope) {
920
+ const root = skillsRoot(scope);
921
+ if (!existsSync6(root)) return [];
922
+ let entries;
923
+ try {
924
+ entries = readdirSync(root);
925
+ } catch {
926
+ return [];
927
+ }
928
+ const out = [];
929
+ for (const folder of entries) {
930
+ const dir = join6(root, folder);
931
+ try {
932
+ if (!statSync2(dir).isDirectory()) continue;
933
+ } catch {
934
+ continue;
935
+ }
936
+ const skill = readSkill(dir, folder, scope);
937
+ if (skill) out.push(skill);
938
+ }
939
+ return out;
940
+ }
941
+ function listSkills() {
942
+ const byName = /* @__PURE__ */ new Map();
943
+ for (const s of listScope("global")) byName.set(s.name, s);
944
+ for (const s of listScope("workspace")) byName.set(s.name, s);
945
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
946
+ }
947
+ function loadSkill(name) {
948
+ const want = sanitizeName(name);
949
+ const skill = listSkills().find((s) => s.name === want);
950
+ if (!skill) return null;
951
+ let raw = "";
952
+ try {
953
+ raw = readFileSync6(join6(skill.dir, "SKILL.md"), "utf8");
954
+ } catch {
955
+ return null;
956
+ }
957
+ const { body } = parseFrontmatter(raw);
958
+ return { skill, body };
959
+ }
960
+ function skillsPromptBlock() {
961
+ const skills = listSkills();
962
+ if (skills.length === 0) return "";
963
+ const lines = [
964
+ `Skills available to this user (invoked with "/skill <name>"). When the user invokes a skill, follow that skill's instructions:`,
965
+ ...skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`)
966
+ ];
967
+ return lines.join("\n");
968
+ }
969
+ function resolveSkillInvocation(line) {
970
+ const trimmed = line.trim();
971
+ const m = trimmed.match(/^\/skill\s+(\S+)\s*([\s\S]*)$/i);
972
+ if (!m) return null;
973
+ const name = m[1];
974
+ const input = (m[2] || "").trim();
975
+ const loaded = loadSkill(name);
976
+ if (!loaded) return null;
977
+ const prompt = [
978
+ loaded.body,
979
+ "",
980
+ "Apply the skill above to this request:",
981
+ input || "Introduce what you can do with this skill."
982
+ ].join("\n");
983
+ return { prompt, skillName: loaded.skill.name };
984
+ }
985
+
986
+ // src/runner.ts
987
+ function resolveActive(cfg, id) {
988
+ if (BRAIN_IDS.includes(id)) {
989
+ const b = cfg.brains[id];
990
+ return { kind: "cli", id, label: b.label };
991
+ }
992
+ const api = findApiBrain(cfg, id);
993
+ if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
994
+ return null;
995
+ }
996
+ async function runTask(task, opts = {}) {
997
+ try {
998
+ const cfg = loadConfig();
999
+ if (!cfg || !cfg.defaultBrain) {
1000
+ return { ok: false, text: "No Holt setup in this folder. Run holt init.", brainLabel: "" };
1001
+ }
1002
+ const wantId = opts.brainId ?? cfg.defaultBrain;
1003
+ const active = resolveActive(cfg, wantId);
1004
+ if (!active) {
1005
+ return { ok: false, text: `Brain "${wantId}" is not configured. Run holt setting.`, brainLabel: "" };
1006
+ }
1007
+ if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
1008
+ return {
1009
+ ok: false,
1010
+ text: `${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Install it or run holt setting.`,
1011
+ brainLabel: active.label
1012
+ };
1013
+ }
1014
+ if (active.kind === "api" && !resolveApiKey(active.brain)) {
1015
+ return {
1016
+ ok: false,
1017
+ text: `${active.label} has no API key. Run holt setting to add one.`,
1018
+ brainLabel: active.label
1019
+ };
1020
+ }
1021
+ const runSession = "run-" + newSessionId();
1022
+ const recalled = opts.recall !== false ? await recall(task, runSession, 4) : [];
1023
+ const block = skillsPromptBlock();
1024
+ const base = renderPrompt([], task, recalled);
1025
+ const prompt = block ? block + "\n\n" + base : base;
1026
+ const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, opts.onChunk) : await runApiBrain(active.brain, prompt, opts.onChunk);
1027
+ if (res.ok && opts.store !== false) {
1028
+ const now = Date.now();
1029
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session: runSession, role: "user", content: task, emb: await embed(task) ?? void 0 });
1030
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session: runSession, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
1031
+ }
1032
+ return { ok: res.ok, text: res.text, brainLabel: active.label };
1033
+ } catch (e) {
1034
+ return { ok: false, text: e.message, brainLabel: "" };
1035
+ }
1036
+ }
1037
+
1038
+ // src/commands/telegram.ts
1039
+ var HELP_TEXT = "Holt bot. Send me anything and I will run it through your selected brain and reply.\nCommands: /help this message.";
1040
+ async function telegram(sub, _rest = []) {
1041
+ const action = (sub || "").toLowerCase();
1042
+ if (action === "setup") {
1043
+ await setup();
1044
+ return;
1045
+ }
1046
+ await runBot();
1047
+ }
1048
+ async function setupTelegram(ask) {
1049
+ console.log("\n" + c.accent("Connect Holt to Telegram") + c.dim(" (chat with Holt from your phone)"));
1050
+ console.log(c.dim(" 1. Open Telegram and message @BotFather."));
1051
+ console.log(c.dim(" 2. Send /newbot, follow the prompts, and copy the bot token it gives you."));
1052
+ console.log(c.dim(" A token looks like 123456789:AAF... (keep it secret)."));
1053
+ const token = (await ask("\n Paste your bot token (enter to skip): ") ?? "").trim();
1054
+ if (!token) {
1055
+ console.log(c.dim(' Skipped. Set it up later with "holt telegram setup" or in "holt setting".'));
1056
+ return false;
1057
+ }
1058
+ console.log("\n" + c.dim(" Now I need your chat id (so the bot only answers you)."));
1059
+ console.log(c.dim(" Option A: open your new bot in Telegram and send it any message,"));
1060
+ console.log(c.dim(" then I will auto-detect the chat id."));
1061
+ console.log(c.dim(" Option B: paste a numeric chat id yourself."));
1062
+ const mode = (await ask("\n Auto-detect from a message you just sent? [Y/n] ") ?? "").trim().toLowerCase();
1063
+ let allowedChatId = null;
1064
+ if (mode === "" || mode === "y" || mode === "yes") {
1065
+ saveTelegramConfig({ token, allowedChatId: 0 });
1066
+ console.log(c.dim("\n Checking for a recent message..."));
1067
+ const updates = await getUpdates(0);
1068
+ allowedChatId = latestChatId(updates);
1069
+ if (allowedChatId === null) {
1070
+ console.log(c.red(" Did not find a recent message."));
1071
+ const manual = (await ask(" Paste your numeric chat id instead: ") ?? "").trim();
1072
+ allowedChatId = parseChatId(manual);
1073
+ }
1074
+ } else {
1075
+ const manual = (await ask(" Paste your numeric chat id: ") ?? "").trim();
1076
+ allowedChatId = parseChatId(manual);
1077
+ }
1078
+ if (allowedChatId === null) {
1079
+ console.log(c.red("\n No valid chat id. Nothing saved. Try again anytime."));
1080
+ return false;
1081
+ }
1082
+ const cfg = { token, allowedChatId };
1083
+ saveTelegramConfig(cfg);
1084
+ console.log(
1085
+ "\n" + c.green(" Telegram connected.") + c.dim(` token ...${token.slice(-4)} chat id ${allowedChatId} (~/.holt/telegram.json, mode 600)`)
1086
+ );
1087
+ console.log(c.dim(" Start the bot anytime with: holt telegram"));
1088
+ return true;
1089
+ }
1090
+ async function setup() {
1091
+ const { ask, close } = createReader();
1092
+ try {
1093
+ await setupTelegram(ask);
1094
+ console.log("");
1095
+ } finally {
1096
+ close();
1097
+ }
1098
+ }
1099
+ function parseChatId(s) {
1100
+ if (!/^-?\d+$/.test(s)) return null;
1101
+ const n = Number(s);
1102
+ return Number.isFinite(n) ? n : null;
1103
+ }
1104
+ function latestChatId(updates) {
1105
+ for (let i = updates.length - 1; i >= 0; i--) {
1106
+ const u = updates[i];
1107
+ if (u && u.message) return u.message.chat.id;
1108
+ }
1109
+ return null;
1110
+ }
1111
+ async function runBot() {
1112
+ const cfg = loadTelegramConfig();
1113
+ if (!cfg) {
1114
+ console.log(c.dim('\n Telegram is not set up. Run "holt telegram setup" first.\n'));
1115
+ return;
1116
+ }
1117
+ if (!isTrusted()) {
1118
+ trustDir();
1119
+ process.stderr.write(`[telegram] auto-trusted ${workspace()}
1120
+ `);
1121
+ }
1122
+ process.stderr.write("[telegram] bot started; long-polling for messages. Ctrl-C to stop.\n");
1123
+ let offset = 0;
1124
+ let busy = false;
1125
+ while (true) {
1126
+ let updates = [];
1127
+ try {
1128
+ updates = await getUpdates(offset);
1129
+ } catch (err2) {
1130
+ process.stderr.write(`[telegram] poll error: ${err2.message}
1131
+ `);
1132
+ await sleep(1e3);
1133
+ continue;
1134
+ }
1135
+ for (const u of updates) {
1136
+ if (u && typeof u.update_id === "number") offset = Math.max(offset, u.update_id + 1);
1137
+ try {
1138
+ await handleUpdate(u, cfg, () => busy, (v) => {
1139
+ busy = v;
1140
+ });
1141
+ } catch (err2) {
1142
+ process.stderr.write(`[telegram] update ${u?.update_id} failed: ${err2.message}
1143
+ `);
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+ async function handleUpdate(u, cfg, getBusy, setBusy) {
1149
+ const msg = u?.message;
1150
+ if (!msg || typeof msg.text !== "string") return;
1151
+ if (msg.chat.id !== cfg.allowedChatId) {
1152
+ process.stderr.write(`[telegram] ignoring message from chat ${msg.chat.id}
1153
+ `);
1154
+ return;
1155
+ }
1156
+ const text = msg.text.trim();
1157
+ if (text === "/start" || text === "/help") {
1158
+ await sendMessage(HELP_TEXT, cfg.allowedChatId);
1159
+ return;
1160
+ }
1161
+ if (getBusy()) {
1162
+ await sendMessage("Still working on the previous message. Try again in a moment.", cfg.allowedChatId);
1163
+ return;
1164
+ }
1165
+ setBusy(true);
1166
+ try {
1167
+ process.stderr.write(`[telegram] running task (${text.length} chars)
1168
+ `);
1169
+ const result = await runTask(text);
1170
+ await sendMessage(result.text || "No output.", cfg.allowedChatId);
1171
+ process.stderr.write(`[telegram] replied (brain: ${result.brainLabel})
1172
+ `);
1173
+ } catch (err2) {
1174
+ process.stderr.write(`[telegram] runTask failed: ${err2.message}
1175
+ `);
1176
+ await sendMessage("Sorry, that run failed. Check the Holt logs.", cfg.allowedChatId);
1177
+ } finally {
1178
+ setBusy(false);
1179
+ }
1180
+ }
1181
+ function sleep(ms) {
1182
+ return new Promise((r) => setTimeout(r, ms));
1183
+ }
1184
+
1185
+ // src/commands/setting.ts
1186
+ function printStatus(cfg) {
1187
+ console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
1188
+ for (const id of BRAIN_IDS) {
1189
+ const b = cfg.brains[id];
1190
+ const tags = [
1191
+ b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
1192
+ cfg.defaultBrain === id ? c.accent("default") : ""
1193
+ ].filter(Boolean).join(" ");
1194
+ console.log(` ${id.padEnd(10)} ${b.label.padEnd(16)} ${tags}`);
1195
+ }
1196
+ if (cfg.apiBrains.length) {
1197
+ console.log(c.dim(" api brains:"));
1198
+ for (const a of cfg.apiBrains) {
1199
+ const key = resolveApiKey(a) ? c.green("key ok") : c.red("no key");
1200
+ const def = cfg.defaultBrain === a.id ? c.accent("default") : "";
1201
+ console.log(` ${a.id.padEnd(10)} ${`${a.provider}/${a.model}`.padEnd(30)} ${key} ${def}`);
1202
+ }
1203
+ }
1204
+ console.log(c.dim(` output format: ${cfg.outputFormat}`));
1205
+ console.log(c.dim(` launch command: ${currentAlias() || "holt (default)"}`));
1206
+ const tg = loadTelegramConfig();
1207
+ console.log(c.dim(` telegram: ${tg ? `connected (chat ${tg.allowedChatId})` : "not set up"}`));
1208
+ console.log("\n " + c.dim("[d] default brain [t] toggle brain [c] connect API brain [x] remove API brain"));
1209
+ console.log(" " + c.dim("[a] launch command [m] connect Telegram [enter] done"));
1210
+ }
1211
+ async function connectApiBrain(ask, cfg) {
1212
+ let provider = null;
1213
+ for (let tries = 0; tries < 3 && !provider; tries++) {
1214
+ const provRaw = (await ask(` provider [${PROVIDERS.join("/")}] (enter for ${PROVIDERS[0]}, or "skip"): `) ?? "skip").trim().toLowerCase();
1215
+ if (provRaw === "skip" || provRaw === "q" || provRaw === "n" || provRaw === "no") {
1216
+ console.log(c.dim(' skipped. Add one later with "holt setting" then "c".'));
1217
+ return null;
1218
+ }
1219
+ if (provRaw === "") provider = PROVIDERS[0];
1220
+ else if (PROVIDERS.includes(provRaw)) provider = provRaw;
1221
+ else console.log(c.dim(` "${provRaw}" is not a provider. Type one of: ${PROVIDERS.join(", ")}`));
1222
+ }
1223
+ if (!provider) {
1224
+ console.log(c.dim(' skipped after three tries. Add one later with "holt setting" then "c".'));
1225
+ return null;
1226
+ }
1227
+ const suggestion = PROVIDER_MODEL_SUGGESTION[provider];
1228
+ const modelRaw = (await ask(` model (enter for ${suggestion}): `) ?? "").trim();
1229
+ const model = modelRaw || suggestion;
1230
+ const idRaw = (await ask(" short name for this brain (e.g. sonnet): ") ?? "").trim();
1231
+ if (!idRaw) {
1232
+ console.log(c.dim(" cancelled (no name)."));
1233
+ return null;
1234
+ }
1235
+ if (isReservedBrainId(idRaw)) {
1236
+ console.log(c.red(` "${idRaw}" is reserved for a CLI brain. Pick another name.`));
1237
+ return null;
1238
+ }
1239
+ if (findApiBrain(cfg, idRaw)) {
1240
+ console.log(c.red(` an API brain named "${idRaw}" already exists.`));
1241
+ return null;
1242
+ }
1243
+ console.log(c.dim(" key: paste a raw key (stored locally, mode 600), or type the name of an env var that holds it."));
1244
+ const keyRaw = (await ask(" key or env var name: ") ?? "").trim();
1245
+ let keyEnv;
1246
+ if (!keyRaw) {
1247
+ console.log(c.dim(" no key given now. You can set the standard env var later."));
1248
+ } else if (/^[A-Z][A-Z0-9_]*$/.test(keyRaw)) {
1249
+ keyEnv = keyRaw;
1250
+ console.log(c.dim(` will read the key from env var ${keyEnv}.`));
1251
+ } else {
1252
+ saveCredential(provider, keyRaw);
1253
+ console.log(c.green(` key stored in ~/.holt/credentials.json (mode 600).`));
1254
+ }
1255
+ const brain = { id: idRaw, provider, model, ...keyEnv ? { keyEnv } : {} };
1256
+ cfg.apiBrains.push(brain);
1257
+ const resolvable = resolveApiKey(brain);
1258
+ console.log(c.green(` connected "${idRaw}" -> ${provider}/${model}.`) + (resolvable ? "" : c.dim(" (no key resolves yet)")));
1259
+ return brain;
1260
+ }
1261
+ function selectableIds(cfg) {
1262
+ return [...BRAIN_IDS.filter((id) => cfg.brains[id].enabled), ...cfg.apiBrains.map((a) => a.id)];
1263
+ }
1264
+ async function runSettings(ask) {
1265
+ let cfg = loadConfig() ?? defaultConfig();
1266
+ while (true) {
1267
+ printStatus(cfg);
1268
+ const raw = await ask(" > ");
1269
+ const choice = (raw ?? "").trim().toLowerCase();
1270
+ if (raw === null || choice === "" || choice === "q") break;
1271
+ if (choice === "d") {
1272
+ const options = selectableIds(cfg);
1273
+ if (options.length === 0) {
1274
+ console.log(c.dim(' No brains ready. Toggle a CLI brain on ("t") or connect an API brain ("c").'));
1275
+ continue;
1276
+ }
1277
+ const pick = (await ask(` default brain [${options.join("/")}]: `) ?? "").trim();
1278
+ if (options.includes(pick)) {
1279
+ cfg.defaultBrain = pick;
1280
+ const label = BRAIN_IDS.includes(pick) ? cfg.brains[pick].label : pick;
1281
+ console.log(c.green(` default set to ${label}`));
1282
+ } else console.log(c.dim(" unchanged."));
1283
+ } else if (choice === "t") {
1284
+ const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
1285
+ if (BRAIN_IDS.includes(pick)) {
1286
+ if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
1287
+ console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
1288
+ } else {
1289
+ cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
1290
+ if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
1291
+ if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
1292
+ console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
1293
+ }
1294
+ } else console.log(c.dim(" unchanged."));
1295
+ } else if (choice === "c") {
1296
+ await connectApiBrain(ask, cfg);
1297
+ if (!cfg.defaultBrain && cfg.apiBrains.length) cfg.defaultBrain = cfg.apiBrains[cfg.apiBrains.length - 1]?.id ?? null;
1298
+ } else if (choice === "x") {
1299
+ if (cfg.apiBrains.length === 0) {
1300
+ console.log(c.dim(" no API brains to remove."));
1301
+ continue;
1302
+ }
1303
+ const pick = (await ask(` remove which API brain [${cfg.apiBrains.map((a) => a.id).join("/")}]: `) ?? "").trim();
1304
+ const idx = cfg.apiBrains.findIndex((a) => a.id === pick);
1305
+ if (idx >= 0) {
1306
+ cfg.apiBrains.splice(idx, 1);
1307
+ if (cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
1308
+ console.log(c.dim(` removed "${pick}". (its stored key, if any, stays in ~/.holt/credentials.json)`));
1309
+ } else console.log(c.dim(" unchanged."));
1310
+ } else if (choice === "a") {
1311
+ const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
1312
+ if (name && name !== "holt") {
1313
+ const r = installAlias(name);
1314
+ if (r.ok && r.immediate) console.log(c.green(` "${name}" is ready to use right now.`));
1315
+ else if (r.ok) console.log(c.green(` alias "${name}" added to ${r.file}. Run: source ${r.file} (new terminals will not need it).`));
1316
+ else console.log(c.red(" " + r.message));
1317
+ } else {
1318
+ removeAlias();
1319
+ console.log(c.dim(" reset to holt."));
1320
+ }
1321
+ } else if (choice === "m") {
1322
+ await setupTelegram(ask);
1323
+ } else {
1324
+ console.log(c.dim(" pick d, t, c, x, a, m, or press enter to finish."));
1325
+ }
1326
+ saveConfig(cfg);
1327
+ }
1328
+ saveConfig(cfg);
1329
+ return cfg;
1330
+ }
1331
+ async function setting() {
1332
+ const { ask, close } = createReader();
1333
+ if (!await ensureTrusted(ask)) {
1334
+ close();
1335
+ return;
1336
+ }
1337
+ await runSettings(ask);
1338
+ close();
1339
+ console.log("");
1340
+ }
1341
+
1342
+ // src/commands/init.ts
1343
+ function parseBrains(raw, found) {
1344
+ const s = raw.trim().toLowerCase();
1345
+ if (s === "") return found.length ? found : [...BRAIN_IDS];
1346
+ if (s === "all") return [...BRAIN_IDS];
1347
+ const picked = s.split(/[\s,]+/).filter((x) => BRAIN_IDS.includes(x));
1348
+ if (picked.length) return [...new Set(picked)];
1349
+ return found.length ? found : ["claude"];
1350
+ }
1351
+ async function init() {
1352
+ const { ask, close } = createReader();
1353
+ if (!await ensureTrusted(ask)) {
1354
+ close();
1355
+ return;
1356
+ }
1357
+ console.log(c.accent("Holt setup") + c.dim(` (${workspace()})`) + "\n");
1358
+ console.log("Looking for agent CLIs on your machine...\n");
1359
+ const found = [];
1360
+ for (const id of BRAIN_IDS) {
1361
+ const ok2 = isInstalled(BRAIN_DEFS[id].command);
1362
+ console.log(` ${ok2 ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
1363
+ if (ok2) found.push(id);
1364
+ }
1365
+ console.log("");
1366
+ const chosen = parseBrains(
1367
+ await ask('Which brains do you want? claude, codex, gemini (comma-separated, or "all"): ') ?? "",
1368
+ found
1369
+ );
1370
+ console.log(c.dim(` using: ${chosen.join(", ")}`));
1371
+ const toInstall = chosen.filter((id) => !isInstalled(BRAIN_DEFS[id].command));
1372
+ const loginWanted = /* @__PURE__ */ new Set();
1373
+ for (const id of toInstall) {
1374
+ const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
1375
+ if (a !== "n" && a !== "no") loginWanted.add(id);
1376
+ }
1377
+ const connectedApiBrains = [];
1378
+ const apiAns = (await ask("\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ") ?? "").trim().toLowerCase();
1379
+ if (apiAns === "y" || apiAns === "yes") {
1380
+ const holder = defaultConfig();
1381
+ const brain = await connectApiBrain(ask, holder);
1382
+ if (brain) connectedApiBrains.push(brain);
1383
+ }
1384
+ const defPick = chosen.includes("claude") ? "claude" : chosen[0];
1385
+ const dans = (await ask(`
1386
+ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
1387
+ const defaultBrain = chosen.includes(dans) ? dans : defPick;
1388
+ const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
1389
+ let aliasNote = "";
1390
+ let aliasNeedsSource = "";
1391
+ let aliasWorked = false;
1392
+ if (aliasAns && aliasAns !== "holt") {
1393
+ const r = installAlias(aliasAns);
1394
+ aliasWorked = r.ok;
1395
+ if (r.ok && r.immediate) aliasNote = c.green(` "${aliasAns}" is ready to use right now (launcher at ${r.file}).`);
1396
+ else if (r.ok) {
1397
+ aliasNote = c.green(` alias "${aliasAns}" -> holt chat added to ${r.file}`);
1398
+ aliasNeedsSource = r.file;
1399
+ } else aliasNote = c.red(" " + r.message);
1400
+ }
1401
+ let wantMemorySetup = false;
1402
+ const embedReady = await embeddingsAvailable();
1403
+ if (embedReady) {
1404
+ console.log(c.dim("\nSemantic memory: ready (local Ollama with " + EMBED_MODEL + " detected)."));
1405
+ } else {
1406
+ const ollamaHere = isInstalled("ollama");
1407
+ 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] ";
1408
+ const a = (await ask("\n" + q) ?? "").trim().toLowerCase();
1409
+ wantMemorySetup = a !== "n" && a !== "no";
1410
+ if (!wantMemorySetup) console.log(c.dim(' Okay. Memory still works with keyword recall; run "holt init" again anytime.'));
1411
+ }
1412
+ if (!loadTelegramConfig()) {
1413
+ const tgAns = (await ask("\nChat with Holt from your phone over Telegram? [y/N] ") ?? "").trim().toLowerCase();
1414
+ if (tgAns === "y" || tgAns === "yes") await setupTelegram(ask);
1415
+ }
1416
+ close();
1417
+ for (const id of toInstall) {
1418
+ const s = BRAIN_SETUP[id];
1419
+ console.log("\n" + c.accent(`Installing ${BRAIN_DEFS[id].label}`) + c.dim(` (${s.install.join(" ")})`));
1420
+ const code = await runInteractive(s.install[0], s.install.slice(1));
1421
+ console.log(code === 0 ? c.green(` ${BRAIN_DEFS[id].label} installed.`) : c.red(` Install failed (exit ${code}). Run manually: ${s.install.join(" ")}`));
1422
+ }
1423
+ for (const id of toInstall) {
1424
+ if (!loginWanted.has(id) || !isInstalled(BRAIN_DEFS[id].command)) continue;
1425
+ const s = BRAIN_SETUP[id];
1426
+ console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
1427
+ console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool to return here.`));
1428
+ await runInteractive(s.login[0], s.login.slice(1));
1429
+ }
1430
+ if (wantMemorySetup) {
1431
+ if (!isInstalled("ollama")) {
1432
+ if (process.platform === "darwin" && isInstalled("brew")) {
1433
+ console.log("\n" + c.accent("Installing Ollama") + c.dim(" (brew install ollama)"));
1434
+ const code = await runInteractive("brew", ["install", "ollama"]);
1435
+ if (code === 0) await runInteractive("brew", ["services", "start", "ollama"]);
1436
+ else console.log(c.red(' Install failed. Get Ollama from https://ollama.com/download and run "holt init" again.'));
1437
+ } else {
1438
+ console.log(c.dim('\n Get Ollama from https://ollama.com/download, then run "holt init" again to finish memory setup.'));
1439
+ }
1440
+ }
1441
+ if (isInstalled("ollama")) {
1442
+ console.log("\n" + c.accent("Pulling embed model") + c.dim(` (ollama pull ${EMBED_MODEL})`));
1443
+ const code = await runInteractive("ollama", ["pull", EMBED_MODEL]);
1444
+ if (code !== 0) {
1445
+ console.log(c.dim(' Could not pull. Start Ollama (open the app or run "ollama serve"), then run:'));
1446
+ console.log(c.dim(` ollama pull ${EMBED_MODEL}`));
1447
+ }
1448
+ resetEmbedProbe();
1449
+ if (await embeddingsAvailable()) console.log(c.green(" Semantic memory is ready. Chats in trusted folders are stored and recalled locally."));
1450
+ }
1451
+ }
1452
+ const cfg = loadConfig() ?? defaultConfig();
1453
+ for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
1454
+ for (const b of connectedApiBrains) if (!cfg.apiBrains.some((a) => a.id === b.id)) cfg.apiBrains.push(b);
1455
+ cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
1456
+ saveConfig(cfg);
1457
+ console.log("\n" + c.green("Saved to ./.holt/config.json"));
1458
+ if (aliasNote) console.log(aliasNote);
1459
+ if (cfg.defaultBrain) {
1460
+ if (aliasWorked && !aliasNeedsSource) {
1461
+ console.log("Start chatting: " + c.accent(aliasAns) + "\n");
1462
+ } else if (aliasNeedsSource) {
1463
+ console.log("\nStart chatting:");
1464
+ console.log(" " + c.accent(`source ${aliasNeedsSource}`) + c.dim(" (once; new terminals will not need it)"));
1465
+ console.log(" " + c.accent(aliasAns) + "\n");
1466
+ console.log(c.dim(" Or right now, without sourcing: holt chat\n"));
1467
+ } else {
1468
+ console.log("Start chatting: " + c.accent("holt chat") + "\n");
1469
+ }
1470
+ } else {
1471
+ console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
1472
+ }
1473
+ }
1474
+
1475
+ // src/commands/chat.ts
1476
+ import { randomUUID as randomUUID3 } from "crypto";
1477
+
1034
1478
  // src/facts.ts
1035
1479
  var MIN_EXCHANGES_FOR_EXTRACTION = 3;
1036
1480
  var MAX_TRANSCRIPT_TURNS = 20;
@@ -1141,8 +1585,8 @@ async function extractAndSaveFacts(active, cfg, history, session) {
1141
1585
  }
1142
1586
 
1143
1587
  // src/output.ts
1144
- import { writeFileSync as writeFileSync5 } from "fs";
1145
- import { join as join5 } from "path";
1588
+ import { writeFileSync as writeFileSync6 } from "fs";
1589
+ import { join as join7 } from "path";
1146
1590
  function escapeHtml(s) {
1147
1591
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1148
1592
  }
@@ -1275,139 +1719,19 @@ function saveReply(reply, format, name) {
1275
1719
  let base = (name ?? "").trim();
1276
1720
  if (!base) base = `holt-reply-${timestamp()}`;
1277
1721
  if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
1278
- const path = join5(process.cwd(), base);
1279
- writeFileSync5(path, renderReply(reply, format), "utf8");
1722
+ const path = join7(process.cwd(), base);
1723
+ writeFileSync6(path, renderReply(reply, format), "utf8");
1280
1724
  return path;
1281
1725
  }
1282
1726
 
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
1727
  // src/commands/chat.ts
1728
+ function statusLine(brainLabel, history) {
1729
+ const live = Math.min(history.length, MAX_REPLAY_TURNS);
1730
+ const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
1731
+ const pct = Math.round(frac * 100);
1732
+ const mem = memStats().turns;
1733
+ return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
1734
+ }
1411
1735
  function help() {
1412
1736
  console.log(c.dim([
1413
1737
  " commands:",
@@ -1422,7 +1746,7 @@ function help() {
1422
1746
  " /exit leave"
1423
1747
  ].join("\n")));
1424
1748
  }
1425
- function resolveActive(cfg, id) {
1749
+ function resolveActive2(cfg, id) {
1426
1750
  if (BRAIN_IDS.includes(id)) {
1427
1751
  const b = cfg.brains[id];
1428
1752
  return { kind: "cli", id, label: b.label };
@@ -1449,10 +1773,10 @@ async function chat() {
1449
1773
  console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
1450
1774
  return;
1451
1775
  }
1452
- let active = resolveActive(cfg, cfg.defaultBrain);
1776
+ let active = resolveActive2(cfg, cfg.defaultBrain);
1453
1777
  if (!active) {
1454
1778
  const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
1455
- active = fallback ? resolveActive(cfg, fallback) : null;
1779
+ active = fallback ? resolveActive2(cfg, fallback) : null;
1456
1780
  if (!active) {
1457
1781
  close();
1458
1782
  console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
@@ -1471,7 +1795,8 @@ async function chat() {
1471
1795
  if (embedOk && stats.withEmbeddings < stats.turns) {
1472
1796
  console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
1473
1797
  }
1474
- console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit\n"));
1798
+ console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit"));
1799
+ console.log(statusLine(active.label, history) + "\n");
1475
1800
  while (true) {
1476
1801
  const raw = await ask(c.accent("\u203A "));
1477
1802
  if (raw === null) break;
@@ -1542,8 +1867,8 @@ async function chat() {
1542
1867
  }
1543
1868
  if (cmd === "setting" || cmd === "settings") {
1544
1869
  cfg = await runSettings(ask);
1545
- const next = cfg.defaultBrain ? resolveActive(cfg, cfg.defaultBrain) : null;
1546
- const stillValid = resolveActive(cfg, active.id);
1870
+ const next = cfg.defaultBrain ? resolveActive2(cfg, cfg.defaultBrain) : null;
1871
+ const stillValid = resolveActive2(cfg, active.id);
1547
1872
  active = stillValid ?? next ?? active;
1548
1873
  console.log(c.dim(` brain: ${active.label}`));
1549
1874
  continue;
@@ -1553,7 +1878,7 @@ async function chat() {
1553
1878
  const apiIds = cfg.apiBrains.map((a) => a.id);
1554
1879
  const all = [...cliEnabled, ...apiIds];
1555
1880
  if (arg && all.includes(arg)) {
1556
- const next = resolveActive(cfg, arg);
1881
+ const next = resolveActive2(cfg, arg);
1557
1882
  if (next) {
1558
1883
  active = next;
1559
1884
  const turns = Math.floor(history.length / 2);
@@ -1597,9 +1922,9 @@ async function chat() {
1597
1922
  const base = renderPrompt(history, line, remembered);
1598
1923
  const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
1599
1924
  let streamed = false;
1600
- const onChunk = (chunk) => {
1925
+ const onChunk = (chunk2) => {
1601
1926
  streamed = true;
1602
- process.stdout.write(chunk);
1927
+ process.stdout.write(chunk2);
1603
1928
  };
1604
1929
  const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
1605
1930
  if (res.ok) {
@@ -1610,8 +1935,9 @@ async function chat() {
1610
1935
  history.push({ role: "user", content: line });
1611
1936
  history.push({ role: "assistant", content: res.text });
1612
1937
  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 });
1938
+ appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
1939
+ appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
1940
+ console.log(statusLine(active.label, history) + "\n");
1615
1941
  } else {
1616
1942
  console.log(c.red("\n " + res.text + "\n"));
1617
1943
  }
@@ -1645,7 +1971,7 @@ async function login(which) {
1645
1971
  }
1646
1972
 
1647
1973
  // src/commands/memory.ts
1648
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
1974
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
1649
1975
  async function memoryCmd(sub, rest = []) {
1650
1976
  const { ask, close } = createReader();
1651
1977
  if (!await ensureTrusted(ask)) {
@@ -1692,12 +2018,12 @@ async function memoryCmd(sub, rest = []) {
1692
2018
  return;
1693
2019
  }
1694
2020
  if (action === "facts") {
1695
- if (!existsSync6(factsMdPath())) {
2021
+ if (!existsSync7(factsMdPath())) {
1696
2022
  console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
1697
2023
  close();
1698
2024
  return;
1699
2025
  }
1700
- const body = readFileSync6(factsMdPath(), "utf8").trim();
2026
+ const body = readFileSync7(factsMdPath(), "utf8").trim();
1701
2027
  console.log("\n" + body + "\n");
1702
2028
  close();
1703
2029
  return;
@@ -1743,17 +2069,17 @@ async function memoryCmd(sub, rest = []) {
1743
2069
 
1744
2070
  // src/commands/skill.ts
1745
2071
  import {
1746
- mkdirSync as mkdirSync5,
1747
- writeFileSync as writeFileSync6,
1748
- readFileSync as readFileSync7,
1749
- existsSync as existsSync7,
2072
+ mkdirSync as mkdirSync6,
2073
+ writeFileSync as writeFileSync7,
2074
+ readFileSync as readFileSync8,
2075
+ existsSync as existsSync8,
1750
2076
  readdirSync as readdirSync2,
1751
2077
  statSync as statSync3,
1752
2078
  rmSync as rmSync3,
1753
2079
  cpSync,
1754
2080
  mkdtempSync
1755
2081
  } from "fs";
1756
- import { join as join7, resolve } from "path";
2082
+ import { join as join8, resolve } from "path";
1757
2083
  import { tmpdir } from "os";
1758
2084
  import { spawnSync as spawnSync2 } from "child_process";
1759
2085
  function usage() {
@@ -1799,12 +2125,12 @@ function isGitUrl(s) {
1799
2125
  return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
1800
2126
  }
1801
2127
  function findSkillDir(root) {
1802
- if (existsSync7(join7(root, "SKILL.md"))) return root;
2128
+ if (existsSync8(join8(root, "SKILL.md"))) return root;
1803
2129
  let subs = [];
1804
2130
  try {
1805
2131
  subs = readdirSync2(root).filter((f) => {
1806
2132
  try {
1807
- return statSync3(join7(root, f)).isDirectory();
2133
+ return statSync3(join8(root, f)).isDirectory();
1808
2134
  } catch {
1809
2135
  return false;
1810
2136
  }
@@ -1812,8 +2138,8 @@ function findSkillDir(root) {
1812
2138
  } catch {
1813
2139
  return null;
1814
2140
  }
1815
- const withSkill = subs.filter((f) => existsSync7(join7(root, f, "SKILL.md")));
1816
- if (withSkill.length === 1) return join7(root, withSkill[0]);
2141
+ const withSkill = subs.filter((f) => existsSync8(join8(root, f, "SKILL.md")));
2142
+ if (withSkill.length === 1) return join8(root, withSkill[0]);
1817
2143
  return null;
1818
2144
  }
1819
2145
  function cmdList() {
@@ -1852,7 +2178,7 @@ function cmdShow(name) {
1852
2178
  const { skill, body } = loaded;
1853
2179
  console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
1854
2180
  if (skill.description) console.log(" " + skill.description);
1855
- console.log(c.dim(" " + join7(skill.dir, "SKILL.md")));
2181
+ console.log(c.dim(" " + join8(skill.dir, "SKILL.md")));
1856
2182
  console.log("");
1857
2183
  console.log(c.dim(body));
1858
2184
  console.log("");
@@ -1864,18 +2190,18 @@ function cmdCreate(name, global) {
1864
2190
  return;
1865
2191
  }
1866
2192
  const scope = global ? "global" : "workspace";
1867
- const dir = join7(skillsRoot(scope), clean);
1868
- if (existsSync7(dir)) {
2193
+ const dir = join8(skillsRoot(scope), clean);
2194
+ if (existsSync8(dir)) {
1869
2195
  console.log(c.red(`
1870
2196
  A ${scope} skill named "${clean}" already exists.`));
1871
2197
  console.log(c.dim(" " + dir + "\n"));
1872
2198
  return;
1873
2199
  }
1874
- mkdirSync5(dir, { recursive: true });
1875
- writeFileSync6(join7(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
2200
+ mkdirSync6(dir, { recursive: true });
2201
+ writeFileSync7(join8(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
1876
2202
  console.log(c.green(`
1877
2203
  Created ${scope} skill "${clean}".`));
1878
- console.log(c.dim(" " + join7(dir, "SKILL.md")));
2204
+ console.log(c.dim(" " + join8(dir, "SKILL.md")));
1879
2205
  console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
1880
2206
  }
1881
2207
  function cmdAdd(source, global) {
@@ -1888,7 +2214,7 @@ function cmdAdd(source, global) {
1888
2214
  let tempDir = "";
1889
2215
  try {
1890
2216
  if (isGitUrl(source)) {
1891
- tempDir = mkdtempSync(join7(tmpdir(), "holt-skill-"));
2217
+ tempDir = mkdtempSync(join8(tmpdir(), "holt-skill-"));
1892
2218
  console.log(c.dim(`
1893
2219
  Cloning ${source} ...`));
1894
2220
  const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
@@ -1899,7 +2225,7 @@ function cmdAdd(source, global) {
1899
2225
  fetched = tempDir;
1900
2226
  } else {
1901
2227
  fetched = resolve(source);
1902
- if (!existsSync7(fetched)) {
2228
+ if (!existsSync8(fetched)) {
1903
2229
  console.log(c.red(`
1904
2230
  No such path: ${fetched}
1905
2231
  `));
@@ -1913,7 +2239,7 @@ function cmdAdd(source, global) {
1913
2239
  }
1914
2240
  let skillName = "";
1915
2241
  try {
1916
- const { data } = parseFrontmatter(readFileSync7(join7(skillDir, "SKILL.md"), "utf8"));
2242
+ const { data } = parseFrontmatter(readFileSync8(join8(skillDir, "SKILL.md"), "utf8"));
1917
2243
  skillName = sanitizeName(data.name || "");
1918
2244
  } catch {
1919
2245
  skillName = "";
@@ -1923,22 +2249,22 @@ function cmdAdd(source, global) {
1923
2249
  console.log(c.red("\n Could not determine a valid skill name.\n"));
1924
2250
  return;
1925
2251
  }
1926
- const dest = join7(skillsRoot(scope), skillName);
1927
- if (existsSync7(dest)) {
2252
+ const dest = join8(skillsRoot(scope), skillName);
2253
+ if (existsSync8(dest)) {
1928
2254
  console.log(c.red(`
1929
2255
  A ${scope} skill named "${skillName}" already exists.`));
1930
2256
  console.log(c.dim(" " + dest + "\n"));
1931
2257
  return;
1932
2258
  }
1933
- mkdirSync5(skillsRoot(scope), { recursive: true });
2259
+ mkdirSync6(skillsRoot(scope), { recursive: true });
1934
2260
  cpSync(skillDir, dest, { recursive: true });
1935
- const nestedGit = join7(dest, ".git");
1936
- if (existsSync7(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
2261
+ const nestedGit = join8(dest, ".git");
2262
+ if (existsSync8(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
1937
2263
  console.log(c.green(`
1938
2264
  Installed ${scope} skill "${skillName}".`));
1939
- console.log(c.dim(" " + join7(dest, "SKILL.md") + "\n"));
2265
+ console.log(c.dim(" " + join8(dest, "SKILL.md") + "\n"));
1940
2266
  } finally {
1941
- if (tempDir && existsSync7(tempDir)) {
2267
+ if (tempDir && existsSync8(tempDir)) {
1942
2268
  try {
1943
2269
  rmSync3(tempDir, { recursive: true, force: true });
1944
2270
  } catch {
@@ -1952,9 +2278,9 @@ async function cmdRemove(name, ask) {
1952
2278
  console.log(c.dim("\n Usage: holt skill remove <name>\n"));
1953
2279
  return;
1954
2280
  }
1955
- const wsDir = join7(skillsRoot("workspace"), clean);
1956
- const globalDir = join7(skillsRoot("global"), clean);
1957
- const target = existsSync7(wsDir) ? wsDir : existsSync7(globalDir) ? globalDir : "";
2281
+ const wsDir = join8(skillsRoot("workspace"), clean);
2282
+ const globalDir = join8(skillsRoot("global"), clean);
2283
+ const target = existsSync8(wsDir) ? wsDir : existsSync8(globalDir) ? globalDir : "";
1958
2284
  if (!target) {
1959
2285
  console.log(c.dim(`
1960
2286
  No skill named "${clean}" found.
@@ -2012,8 +2338,8 @@ async function skillCmd(sub, rest = []) {
2012
2338
  }
2013
2339
 
2014
2340
  // 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";
2341
+ import { writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
2342
+ import { join as join9, dirname as dirname2, resolve as resolve2 } from "path";
2017
2343
  import { spawn as spawn3 } from "child_process";
2018
2344
 
2019
2345
  // src/graphview.ts
@@ -2677,9 +3003,9 @@ async function graph(args = []) {
2677
3003
  concepts: conceptCount,
2678
3004
  edges: g.edges.length
2679
3005
  });
2680
- const outPath = opts.out ? resolve2(opts.out) : join8(wsHoltDir(), "graph.html");
2681
- mkdirSync6(dirname2(outPath), { recursive: true });
2682
- writeFileSync7(outPath, html, "utf8");
3006
+ const outPath = opts.out ? resolve2(opts.out) : join9(wsHoltDir(), "graph.html");
3007
+ mkdirSync7(dirname2(outPath), { recursive: true });
3008
+ writeFileSync8(outPath, html, "utf8");
2683
3009
  console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
2684
3010
  console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
2685
3011
  console.log(` edges ${g.edges.length}`);
@@ -2787,7 +3113,7 @@ function recoverId(line) {
2787
3113
  return null;
2788
3114
  }
2789
3115
  function runMcpServer(opts) {
2790
- return new Promise((resolve3) => {
3116
+ return new Promise((resolve4) => {
2791
3117
  const stdin = process.stdin;
2792
3118
  let buffer = "";
2793
3119
  let chain = Promise.resolve();
@@ -2824,8 +3150,8 @@ function runMcpServer(opts) {
2824
3150
  });
2825
3151
  };
2826
3152
  stdin.setEncoding("utf8");
2827
- stdin.on("data", (chunk) => {
2828
- buffer += chunk;
3153
+ stdin.on("data", (chunk2) => {
3154
+ buffer += chunk2;
2829
3155
  let nl = buffer.indexOf("\n");
2830
3156
  while (nl !== -1) {
2831
3157
  const line = buffer.slice(0, nl);
@@ -2839,11 +3165,11 @@ function runMcpServer(opts) {
2839
3165
  processLine(buffer);
2840
3166
  buffer = "";
2841
3167
  }
2842
- chain.then(() => resolve3()).catch(() => resolve3());
3168
+ chain.then(() => resolve4()).catch(() => resolve4());
2843
3169
  });
2844
3170
  stdin.on("error", (e) => {
2845
3171
  console.error(`[mcp] stdin error: ${e.message}`);
2846
- chain.then(() => resolve3()).catch(() => resolve3());
3172
+ chain.then(() => resolve4()).catch(() => resolve4());
2847
3173
  });
2848
3174
  stdin.resume();
2849
3175
  });
@@ -2999,8 +3325,450 @@ async function mcp(sub, rest = []) {
2999
3325
  }
3000
3326
  }
3001
3327
 
3328
+ // src/commands/run.ts
3329
+ import { writeFileSync as writeFileSync9 } from "fs";
3330
+ import { resolve as resolve3 } from "path";
3331
+ var USAGE = [
3332
+ " holt run <task> [options]",
3333
+ "",
3334
+ " Run a single task against your default brain and stream the reply.",
3335
+ "",
3336
+ " options:",
3337
+ " --brain <id> use a specific brain (CLI or API brain id)",
3338
+ " --out <file> also write the reply to a file (relative to this folder)",
3339
+ " --no-store do not save this exchange to memory",
3340
+ " --no-recall do not pull memory into the prompt",
3341
+ " --quiet suppress streaming; still writes --out and sets exit code",
3342
+ "",
3343
+ ' example: holt run "summarize the open items in this folder"'
3344
+ ].join("\n");
3345
+ function parseArgs2(args) {
3346
+ const words2 = [];
3347
+ let brainId;
3348
+ let out;
3349
+ let noStore = false;
3350
+ let noRecall = false;
3351
+ let quiet = false;
3352
+ for (let i = 0; i < args.length; i++) {
3353
+ const a = args[i];
3354
+ if (a === "--brain") {
3355
+ brainId = args[++i];
3356
+ continue;
3357
+ }
3358
+ if (a === "--out") {
3359
+ out = args[++i];
3360
+ continue;
3361
+ }
3362
+ if (a === "--no-store") {
3363
+ noStore = true;
3364
+ continue;
3365
+ }
3366
+ if (a === "--no-recall") {
3367
+ noRecall = true;
3368
+ continue;
3369
+ }
3370
+ if (a === "--quiet") {
3371
+ quiet = true;
3372
+ continue;
3373
+ }
3374
+ words2.push(a);
3375
+ }
3376
+ return { task: words2.join(" ").trim(), brainId, out, noStore, noRecall, quiet };
3377
+ }
3378
+ async function run(args) {
3379
+ const p = parseArgs2(args);
3380
+ if (!p.task) {
3381
+ console.log(USAGE);
3382
+ return;
3383
+ }
3384
+ if (!isTrusted()) {
3385
+ if (process.stdin.isTTY) {
3386
+ const { ask, close } = createReader();
3387
+ const ok2 = await ensureTrusted(ask);
3388
+ close();
3389
+ if (!ok2) return;
3390
+ } else {
3391
+ process.stderr.write("This folder is not trusted. Run holt init or holt chat here once, then schedule/run.\n");
3392
+ process.exitCode = 1;
3393
+ return;
3394
+ }
3395
+ }
3396
+ let streamed = false;
3397
+ const result = await runTask(p.task, {
3398
+ brainId: p.brainId,
3399
+ recall: !p.noRecall,
3400
+ store: !p.noStore,
3401
+ onChunk: p.quiet ? void 0 : (ch) => {
3402
+ streamed = true;
3403
+ process.stdout.write(ch);
3404
+ }
3405
+ });
3406
+ if (!p.quiet && !streamed) {
3407
+ process.stdout.write(result.text);
3408
+ }
3409
+ if (!p.quiet && (streamed || result.text) && !result.text.endsWith("\n")) {
3410
+ process.stdout.write("\n");
3411
+ }
3412
+ if (!result.ok) {
3413
+ process.stderr.write(c.red(result.text) + "\n");
3414
+ process.exitCode = 1;
3415
+ return;
3416
+ }
3417
+ if (p.out) {
3418
+ const target = resolve3(process.cwd(), p.out);
3419
+ writeFileSync9(target, result.text, "utf8");
3420
+ process.stderr.write(c.dim(`saved to ${target}`) + "\n");
3421
+ }
3422
+ }
3423
+
3424
+ // src/commands/schedule.ts
3425
+ import { spawnSync as spawnSync4 } from "child_process";
3426
+ import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync9, rmSync as rmSync4, existsSync as existsSync9 } from "fs";
3427
+ import { dirname as dirname3 } from "path";
3428
+
3429
+ // src/scheduler.ts
3430
+ import { homedir as homedir3 } from "os";
3431
+ import { join as join10 } from "path";
3432
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync10, mkdirSync as mkdirSync8 } from "fs";
3433
+ import { randomUUID as randomUUID4 } from "crypto";
3434
+ import { spawnSync as spawnSync3 } from "child_process";
3435
+ function schedulesPath() {
3436
+ return join10(GLOBAL_DIR, "schedules.json");
3437
+ }
3438
+ function logsDir() {
3439
+ return join10(GLOBAL_DIR, "logs");
3440
+ }
3441
+ function logPath(id) {
3442
+ return join10(logsDir(), `${id}.log`);
3443
+ }
3444
+ function plistPath(id) {
3445
+ return join10(homedir3(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
3446
+ }
3447
+ function loadJobs() {
3448
+ try {
3449
+ const raw = JSON.parse(readFileSync9(schedulesPath(), "utf8"));
3450
+ return Array.isArray(raw) ? raw : [];
3451
+ } catch {
3452
+ return [];
3453
+ }
3454
+ }
3455
+ function saveJobs(jobs) {
3456
+ mkdirSync8(GLOBAL_DIR, { recursive: true });
3457
+ writeFileSync10(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
3458
+ }
3459
+ function addJob(job) {
3460
+ const jobs = loadJobs();
3461
+ jobs.push(job);
3462
+ saveJobs(jobs);
3463
+ return jobs;
3464
+ }
3465
+ function removeJob(id) {
3466
+ const jobs = loadJobs().filter((j) => j.id !== id);
3467
+ saveJobs(jobs);
3468
+ return jobs;
3469
+ }
3470
+ function newJobId() {
3471
+ return randomUUID4().slice(0, 8);
3472
+ }
3473
+ function parseWhen(when) {
3474
+ const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when.trim());
3475
+ if (!m) {
3476
+ throw new Error(
3477
+ `Could not read time "${when}". Use 24h HH:MM for a daily run, e.g. 07:00 or 23:30.`
3478
+ );
3479
+ }
3480
+ const hour = Number(m[1]);
3481
+ const minute = Number(m[2]);
3482
+ return { hour, minute };
3483
+ }
3484
+ function shQuote(s) {
3485
+ return `'` + s.replace(/'/g, `'\\''`) + `'`;
3486
+ }
3487
+ function resolveHoltPath() {
3488
+ try {
3489
+ const finder = process.platform === "win32" ? "where" : "which";
3490
+ const res = spawnSync3(finder, ["holt"], { encoding: "utf8" });
3491
+ if (res.status === 0 && typeof res.stdout === "string") {
3492
+ const first = res.stdout.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
3493
+ if (first) return first;
3494
+ }
3495
+ } catch {
3496
+ }
3497
+ return "holt";
3498
+ }
3499
+ function buildCommand(job, holtPath) {
3500
+ const holt = shQuote(holtPath);
3501
+ const ws = shQuote(job.workspace);
3502
+ const log = shQuote(logPath(job.id));
3503
+ let cmd = `cd ${ws} && ${holt} run ${shQuote(job.task)}`;
3504
+ if (job.brain) cmd += ` --brain ${shQuote(job.brain)}`;
3505
+ cmd += ` --quiet --out ${log}`;
3506
+ if (job.notify) cmd += ` && ${holt} notify "$(cat ${log})"`;
3507
+ return cmd;
3508
+ }
3509
+ function xmlEscape(s) {
3510
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3511
+ }
3512
+ function buildLaunchdPlist(job, holtPath) {
3513
+ const { hour, minute } = parseWhen(job.when);
3514
+ const command = buildCommand(job, holtPath);
3515
+ const log = logPath(job.id);
3516
+ const args = ["/bin/sh", "-lc", command];
3517
+ const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
3518
+ return `<?xml version="1.0" encoding="UTF-8"?>
3519
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3520
+ <plist version="1.0">
3521
+ <dict>
3522
+ <key>Label</key>
3523
+ <string>com.holt.${xmlEscape(job.id)}</string>
3524
+ <key>ProgramArguments</key>
3525
+ <array>
3526
+ ${argXml}
3527
+ </array>
3528
+ <key>StartCalendarInterval</key>
3529
+ <dict>
3530
+ <key>Hour</key>
3531
+ <integer>${hour}</integer>
3532
+ <key>Minute</key>
3533
+ <integer>${minute}</integer>
3534
+ </dict>
3535
+ <key>RunAtLoad</key>
3536
+ <false/>
3537
+ <key>StandardOutPath</key>
3538
+ <string>${xmlEscape(log)}</string>
3539
+ <key>StandardErrorPath</key>
3540
+ <string>${xmlEscape(log)}</string>
3541
+ </dict>
3542
+ </plist>
3543
+ `;
3544
+ }
3545
+ var CRON_MARKER = "holt:";
3546
+ function buildCronLine(job, holtPath) {
3547
+ const { hour, minute } = parseWhen(job.when);
3548
+ const command = buildCommand(job, holtPath);
3549
+ return `${minute} ${hour} * * * ${command} # ${CRON_MARKER}${job.id}`;
3550
+ }
3551
+ function stripCronLines(crontab, id) {
3552
+ const marker = `# ${CRON_MARKER}${id}`;
3553
+ const kept = crontab.split("\n").filter((line) => !line.includes(marker));
3554
+ return kept.join("\n").replace(/\n+$/, "\n");
3555
+ }
3556
+ function appendCronLine(crontab, line) {
3557
+ const base = crontab.replace(/\s*$/, "");
3558
+ return (base ? base + "\n" : "") + line + "\n";
3559
+ }
3560
+ function ensureLogsDir() {
3561
+ mkdirSync8(logsDir(), { recursive: true });
3562
+ }
3563
+
3564
+ // src/commands/schedule.ts
3565
+ function parseAdd(rest) {
3566
+ const positional = [];
3567
+ const opts = { notify: false };
3568
+ for (let i = 0; i < rest.length; i++) {
3569
+ const a = rest[i];
3570
+ if (a === "--notify") opts.notify = true;
3571
+ else if (a === "--name") opts.name = rest[++i];
3572
+ else if (a === "--brain") opts.brain = rest[++i];
3573
+ else if (a && a.startsWith("--")) {
3574
+ const eq = a.indexOf("=");
3575
+ if (eq > 2) {
3576
+ const k = a.slice(2, eq);
3577
+ const v = a.slice(eq + 1);
3578
+ if (k === "name") opts.name = v;
3579
+ else if (k === "brain") opts.brain = v;
3580
+ }
3581
+ } else if (a !== void 0) positional.push(a);
3582
+ }
3583
+ return { task: positional[0], when: positional[1], opts };
3584
+ }
3585
+ function installDarwin(job, holtPath) {
3586
+ const path = plistPath(job.id);
3587
+ mkdirSync9(dirname3(path), { recursive: true });
3588
+ writeFileSync11(path, buildLaunchdPlist(job, holtPath), "utf8");
3589
+ const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
3590
+ if (res.status === 0) {
3591
+ console.log(c.green(" Loaded into launchd."));
3592
+ } else {
3593
+ console.log(c.dim(" Wrote the plist, but launchctl load did not confirm."));
3594
+ console.log(c.dim(` Load it yourself with: launchctl load -w ${path}`));
3595
+ if (res.stderr && res.stderr.trim()) console.log(c.dim(` (${res.stderr.trim()})`));
3596
+ }
3597
+ console.log(c.dim(` plist: ${path}`));
3598
+ }
3599
+ function currentCrontab() {
3600
+ const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
3601
+ return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
3602
+ }
3603
+ function writeCrontab(body) {
3604
+ const res = spawnSync4("crontab", ["-"], { input: body, encoding: "utf8" });
3605
+ return res.status === 0;
3606
+ }
3607
+ function installLinux(job, holtPath) {
3608
+ const line = buildCronLine(job, holtPath);
3609
+ const next = appendCronLine(currentCrontab(), line);
3610
+ if (writeCrontab(next)) {
3611
+ console.log(c.green(" Added to your crontab."));
3612
+ console.log(c.dim(` line: ${line}`));
3613
+ } else {
3614
+ console.log(c.dim(" Could not update the crontab automatically. Add this line yourself:"));
3615
+ console.log(" " + line);
3616
+ }
3617
+ }
3618
+ async function addCmd(rest) {
3619
+ const { task, when, opts } = parseAdd(rest);
3620
+ if (!task || !when) {
3621
+ console.log(c.dim('\n Usage: holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]\n'));
3622
+ return;
3623
+ }
3624
+ try {
3625
+ parseWhen(when);
3626
+ } catch (e) {
3627
+ console.log("\n " + c.red(e.message) + "\n");
3628
+ return;
3629
+ }
3630
+ const ws = workspace();
3631
+ const holtPath = resolveHoltPath();
3632
+ const job = {
3633
+ id: newJobId(),
3634
+ name: opts.name || task.slice(0, 40),
3635
+ task,
3636
+ when,
3637
+ workspace: ws,
3638
+ notify: opts.notify,
3639
+ ...opts.brain ? { brain: opts.brain } : {}
3640
+ };
3641
+ ensureLogsDir();
3642
+ addJob(job);
3643
+ console.log("\n" + c.accent("Scheduled."));
3644
+ console.log(` id ${job.id}`);
3645
+ console.log(` when daily at ${job.when}`);
3646
+ console.log(` workspace ${job.workspace}`);
3647
+ if (job.notify) console.log(` notify on (pushes output to Telegram)`);
3648
+ console.log(` log ${logPath(job.id)}`);
3649
+ if (process.platform === "darwin") installDarwin(job, holtPath);
3650
+ else if (process.platform === "linux") installLinux(job, holtPath);
3651
+ else {
3652
+ console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
3653
+ console.log(" " + buildCronLine(job, holtPath));
3654
+ }
3655
+ console.log("");
3656
+ }
3657
+ function listCmd() {
3658
+ const jobs = loadJobs();
3659
+ if (jobs.length === 0) {
3660
+ console.log(c.dim("\n No scheduled tasks yet. Add one with:"));
3661
+ console.log(c.dim(' holt schedule add "<task>" <HH:MM> [--notify]\n'));
3662
+ return;
3663
+ }
3664
+ console.log("\n" + c.accent("Scheduled tasks"));
3665
+ for (const j of jobs) {
3666
+ console.log(` ${c.bold(j.id)} daily ${j.when} ${c.dim(j.notify ? "(notify)" : "")}`);
3667
+ console.log(` ${j.name}`);
3668
+ console.log(c.dim(` ${j.workspace}`));
3669
+ }
3670
+ console.log("");
3671
+ }
3672
+ function removeDarwin(id) {
3673
+ const path = plistPath(id);
3674
+ if (existsSync9(path)) {
3675
+ spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
3676
+ try {
3677
+ rmSync4(path);
3678
+ } catch {
3679
+ }
3680
+ }
3681
+ }
3682
+ function removeLinux(id) {
3683
+ const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
3684
+ const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
3685
+ const next = stripCronLines(body, id);
3686
+ spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
3687
+ }
3688
+ function removeCmd(rest) {
3689
+ const id = rest[0];
3690
+ if (!id) {
3691
+ console.log(c.dim("\n Usage: holt schedule remove <id> (see ids with: holt schedule list)\n"));
3692
+ return;
3693
+ }
3694
+ const before = loadJobs();
3695
+ const existed = before.some((j) => j.id === id);
3696
+ removeJob(id);
3697
+ if (process.platform === "darwin") removeDarwin(id);
3698
+ else if (process.platform === "linux") removeLinux(id);
3699
+ if (existed) console.log("\n" + c.green(` Removed schedule ${id}.`) + "\n");
3700
+ else console.log(c.dim(`
3701
+ No schedule with id ${id}. Nothing to remove.
3702
+ `));
3703
+ }
3704
+ function usage2() {
3705
+ console.log("\n" + c.accent("holt schedule") + c.dim(" run a task automatically on a timer"));
3706
+ console.log(c.dim('\n holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]'));
3707
+ console.log(c.dim(" holt schedule list"));
3708
+ console.log(c.dim(" holt schedule remove <id>\n"));
3709
+ }
3710
+ async function schedule(sub, rest = []) {
3711
+ const action = (sub || "").toLowerCase();
3712
+ if (action === "list") {
3713
+ listCmd();
3714
+ return;
3715
+ }
3716
+ if (action !== "add" && action !== "remove" && action !== "rm") {
3717
+ usage2();
3718
+ return;
3719
+ }
3720
+ const { ask, close } = createReader();
3721
+ if (!await ensureTrusted(ask)) {
3722
+ close();
3723
+ return;
3724
+ }
3725
+ close();
3726
+ if (action === "add") await addCmd(rest);
3727
+ else removeCmd(rest);
3728
+ }
3729
+
3730
+ // src/commands/notify.ts
3731
+ import { readFileSync as readFileSync10 } from "fs";
3732
+ async function notify(args) {
3733
+ const nonFlag = args.filter((a) => a !== "-" && !a.startsWith("--"));
3734
+ const wantsStdin = args.includes("-") || !process.stdin.isTTY;
3735
+ let message = "";
3736
+ if (nonFlag.length > 0 && !args.includes("-")) {
3737
+ message = nonFlag.join(" ").trim();
3738
+ } else if (wantsStdin) {
3739
+ message = readStdin().trim();
3740
+ } else if (nonFlag.length > 0) {
3741
+ message = nonFlag.join(" ").trim();
3742
+ }
3743
+ if (!message) {
3744
+ process.stderr.write('Nothing to send. Pass a message or pipe text into "holt notify".\n');
3745
+ process.exitCode = 1;
3746
+ return;
3747
+ }
3748
+ const cfg = loadTelegramConfig();
3749
+ if (!cfg) {
3750
+ process.stderr.write('Telegram is not set up. Run "holt telegram setup".\n');
3751
+ process.exitCode = 1;
3752
+ return;
3753
+ }
3754
+ const ok2 = await sendMessage(message, cfg.allowedChatId);
3755
+ if (ok2) {
3756
+ process.stderr.write("Sent to Telegram.\n");
3757
+ } else {
3758
+ process.stderr.write("Failed to send to Telegram. Check the network and your bot token.\n");
3759
+ process.exitCode = 1;
3760
+ }
3761
+ }
3762
+ function readStdin() {
3763
+ try {
3764
+ return readFileSync10(0, "utf8");
3765
+ } catch {
3766
+ return "";
3767
+ }
3768
+ }
3769
+
3002
3770
  // src/cli.ts
3003
- var VERSION = "0.7.0";
3771
+ var VERSION = "0.8.2";
3004
3772
  var BANNER = `
3005
3773
  \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
3006
3774
  \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
@@ -3016,6 +3784,10 @@ Usage: holt <command>
3016
3784
  Commands:
3017
3785
  init Trust this folder, choose and install brains, sign in, set defaults
3018
3786
  chat Start a session. It remembers past sessions in this folder
3787
+ run <task> Run one task non-interactively: recall, brain executes, remember
3788
+ schedule Fire "holt run" on a timer: holt schedule [add | list | remove]
3789
+ telegram Chat with Holt from your phone: holt telegram [setup]
3790
+ notify [msg] Push a message to your phone over Telegram (stdin-friendly)
3019
3791
  memory Inspect memory: holt memory [search <query> | embed | clear]
3020
3792
  graph See your memory as an interactive knowledge graph in the browser
3021
3793
  skill Manage skills: holt skill [list | show | create | add | remove]
@@ -3072,6 +3844,18 @@ async function main() {
3072
3844
  case "mcp":
3073
3845
  await mcp(process.argv[3], process.argv.slice(4));
3074
3846
  break;
3847
+ case "run":
3848
+ await run(process.argv.slice(3));
3849
+ break;
3850
+ case "schedule":
3851
+ await schedule(process.argv[3], process.argv.slice(4));
3852
+ break;
3853
+ case "telegram":
3854
+ await telegram(process.argv[3], process.argv.slice(4));
3855
+ break;
3856
+ case "notify":
3857
+ await notify(process.argv.slice(3));
3858
+ break;
3075
3859
  default:
3076
3860
  console.log(`
3077
3861
  Unknown command: "${cmd}"`);