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