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