@holt-os/holt 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -5
- package/dist/cli.js +779 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,15 +164,58 @@ Point the client at the folder whose memory you want it to use; Holt serves what
|
|
|
164
164
|
|
|
165
165
|
Replies print as markdown. `/output html` (or `markdown`) switches the save format and persists it. `/save [name]` writes the last reply to the current folder: `.md`, or a small self-contained dark-theme `.html` page.
|
|
166
166
|
|
|
167
|
+
## Run tasks, schedule them, reach them from your phone
|
|
168
|
+
|
|
169
|
+
Beyond interactive chat, Holt can run a task once, on a timer, or from Telegram. The selected brain does the task; your memory is recalled into it automatically.
|
|
170
|
+
|
|
171
|
+
### holt run
|
|
172
|
+
|
|
173
|
+
Run a single task non-interactively. It recalls relevant memory, injects your skills, runs the brain once, streams the reply, and saves the exchange to this folder's memory.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
holt run "summarize the open items in this folder"
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Options: `--brain <id>` pick a brain, `--out <file>` also write the reply to a file, `--no-store` skip saving, `--no-recall` skip memory, `--quiet` suppress streaming. The folder must be trusted first (`holt init` or `holt chat` once); in a non-interactive context an untrusted folder exits non-zero instead of prompting.
|
|
180
|
+
|
|
181
|
+
### holt schedule
|
|
182
|
+
|
|
183
|
+
Run a task automatically on a timer, using your OS scheduler (launchd on macOS, cron on Linux). A scheduled run behaves exactly like typing `holt run` in that folder.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
holt schedule add "summarize my inbox and flag what is urgent" 07:00 --notify
|
|
187
|
+
holt schedule list
|
|
188
|
+
holt schedule remove <id>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`<HH:MM>` is a 24h daily time. `--notify` pushes the output to your Telegram. Jobs live in `~/.holt/schedules.json`; each run logs to `~/.holt/logs/<id>.log`.
|
|
192
|
+
|
|
193
|
+
### Telegram: chat with Holt from your phone
|
|
194
|
+
|
|
195
|
+
Holt can run as a Telegram bot. Messages you send are run through your brain (with memory) and the reply comes back to your phone. Single user: only your chat id is served.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
holt telegram setup # message @BotFather for a token, paste it, auto-detect your chat id
|
|
199
|
+
holt telegram # run the bot (keep it running under a service manager)
|
|
200
|
+
holt notify "backup done" # push a one-off message
|
|
201
|
+
holt run "daily brief" | holt notify # pipe any output to your phone
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The token is stored in `~/.holt/telegram.json` (mode 600) and never printed in full.
|
|
205
|
+
|
|
167
206
|
## Commands
|
|
168
207
|
|
|
169
208
|
```
|
|
170
209
|
holt init set up (trust, brains, sign-in, defaults) for this folder
|
|
171
210
|
holt chat start a session that remembers past ones
|
|
211
|
+
holt run <task> run one task non-interactively (recall, brain, remember)
|
|
212
|
+
holt schedule run a task on a timer: add | list | remove
|
|
213
|
+
holt telegram chat with Holt from your phone: telegram [setup]
|
|
214
|
+
holt notify [msg] push a message to your phone over Telegram (stdin-friendly)
|
|
172
215
|
holt graph see your memory as an interactive knowledge graph
|
|
173
|
-
holt mcp run an MCP server so other tools use this folder's memory (holt mcp setup
|
|
216
|
+
holt mcp run an MCP server so other tools use this folder's memory (holt mcp setup)
|
|
174
217
|
holt skill manage skills: list | show | create | add | remove
|
|
175
|
-
holt memory inspect memory: holt memory [search <query> | embed | clear]
|
|
218
|
+
holt memory inspect memory: holt memory [search <query> | facts | embed | clear]
|
|
176
219
|
holt setting configure brains, API brains, and launch command
|
|
177
220
|
holt login <brain> sign in to claude, codex, or gemini
|
|
178
221
|
holt version print version
|
|
@@ -181,7 +224,7 @@ holt help show help
|
|
|
181
224
|
|
|
182
225
|
## Configuration
|
|
183
226
|
|
|
184
|
-
`holt init` writes `<folder>/.holt/config.json` (default brain and enabled brains for that folder). Trusted folders live in `~/.holt/trust.json`. Edit settings with `holt setting`.
|
|
227
|
+
`holt init` writes `<folder>/.holt/config.json` (default brain and enabled brains for that folder). Trusted folders live in `~/.holt/trust.json`. Edit settings with `holt setting`. Scheduled jobs live in `~/.holt/schedules.json`, and the Telegram token in `~/.holt/telegram.json` (mode 600).
|
|
185
228
|
|
|
186
229
|
## Architecture
|
|
187
230
|
|
|
@@ -196,8 +239,8 @@ Built in always-shippable phases toward a full-vision v1:
|
|
|
196
239
|
2. **Any LLM directly**: API brains with your own key, HTML or Markdown output *(shipped)*
|
|
197
240
|
3. **Skills**: portable SKILL.md skills, create/add/run *(shipped)*
|
|
198
241
|
4. **Knowledge graph**: see and navigate your own memory with `holt graph` *(shipped)*
|
|
199
|
-
5. **
|
|
200
|
-
6. **
|
|
242
|
+
5. **Everywhere**: MCP server so other tools read Holt's memory, plus `holt run`, scheduling, and Telegram *(shipped)*
|
|
243
|
+
6. **Next**: docs site, skill registry publishing, more channels
|
|
201
244
|
|
|
202
245
|
## Contributing
|
|
203
246
|
|
package/dist/cli.js
CHANGED
|
@@ -36,11 +36,11 @@ function createReader() {
|
|
|
36
36
|
closed = true;
|
|
37
37
|
while (waiters.length) waiters.shift()(null);
|
|
38
38
|
});
|
|
39
|
-
const ask = (q) => new Promise((
|
|
39
|
+
const ask = (q) => new Promise((resolve4) => {
|
|
40
40
|
if (q) process.stdout.write(q);
|
|
41
|
-
if (buffer.length)
|
|
42
|
-
else if (closed)
|
|
43
|
-
else waiters.push(
|
|
41
|
+
if (buffer.length) resolve4(buffer.shift());
|
|
42
|
+
else if (closed) resolve4(null);
|
|
43
|
+
else waiters.push(resolve4);
|
|
44
44
|
});
|
|
45
45
|
return { ask, close: () => rl.close() };
|
|
46
46
|
}
|
|
@@ -222,12 +222,12 @@ function renderPrompt(history, message, memory = []) {
|
|
|
222
222
|
return parts.join("\n");
|
|
223
223
|
}
|
|
224
224
|
function runBrain(brain, prompt, onChunk) {
|
|
225
|
-
return new Promise((
|
|
225
|
+
return new Promise((resolve4) => {
|
|
226
226
|
let child;
|
|
227
227
|
try {
|
|
228
228
|
child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
|
|
229
229
|
} catch (e) {
|
|
230
|
-
|
|
230
|
+
resolve4({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
233
|
let out = "";
|
|
@@ -240,11 +240,11 @@ function runBrain(brain, prompt, onChunk) {
|
|
|
240
240
|
child.stderr.on("data", (d) => {
|
|
241
241
|
err2 += d.toString();
|
|
242
242
|
});
|
|
243
|
-
child.on("error", (e) =>
|
|
243
|
+
child.on("error", (e) => resolve4({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
|
|
244
244
|
child.on("close", (code) => {
|
|
245
245
|
const text = out.trim();
|
|
246
|
-
if (code === 0 && text)
|
|
247
|
-
else
|
|
246
|
+
if (code === 0 && text) resolve4({ ok: true, text });
|
|
247
|
+
else resolve4({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
}
|
|
@@ -393,16 +393,16 @@ function currentAlias() {
|
|
|
393
393
|
// src/install.ts
|
|
394
394
|
import { spawn as spawn2 } from "child_process";
|
|
395
395
|
function runInteractive(cmd, args) {
|
|
396
|
-
return new Promise((
|
|
396
|
+
return new Promise((resolve4) => {
|
|
397
397
|
let child;
|
|
398
398
|
try {
|
|
399
399
|
child = spawn2(cmd, args, { stdio: "inherit" });
|
|
400
400
|
} catch {
|
|
401
|
-
|
|
401
|
+
resolve4(-1);
|
|
402
402
|
return;
|
|
403
403
|
}
|
|
404
|
-
child.on("error", () =>
|
|
405
|
-
child.on("close", (code) =>
|
|
404
|
+
child.on("error", () => resolve4(-1));
|
|
405
|
+
child.on("close", (code) => resolve4(code ?? -1));
|
|
406
406
|
});
|
|
407
407
|
}
|
|
408
408
|
|
|
@@ -988,8 +988,8 @@ async function runApiBrain(brain, prompt, onChunk) {
|
|
|
988
988
|
let carry = "";
|
|
989
989
|
let text = "";
|
|
990
990
|
try {
|
|
991
|
-
for await (const
|
|
992
|
-
carry += decoder.decode(
|
|
991
|
+
for await (const chunk2 of res.body) {
|
|
992
|
+
carry += decoder.decode(chunk2, { stream: true });
|
|
993
993
|
const { events, rest } = parseSSELines(carry);
|
|
994
994
|
carry = rest;
|
|
995
995
|
for (const ev of events) {
|
|
@@ -1597,9 +1597,9 @@ async function chat() {
|
|
|
1597
1597
|
const base = renderPrompt(history, line, remembered);
|
|
1598
1598
|
const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
|
|
1599
1599
|
let streamed = false;
|
|
1600
|
-
const onChunk = (
|
|
1600
|
+
const onChunk = (chunk2) => {
|
|
1601
1601
|
streamed = true;
|
|
1602
|
-
process.stdout.write(
|
|
1602
|
+
process.stdout.write(chunk2);
|
|
1603
1603
|
};
|
|
1604
1604
|
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
|
|
1605
1605
|
if (res.ok) {
|
|
@@ -2787,7 +2787,7 @@ function recoverId(line) {
|
|
|
2787
2787
|
return null;
|
|
2788
2788
|
}
|
|
2789
2789
|
function runMcpServer(opts) {
|
|
2790
|
-
return new Promise((
|
|
2790
|
+
return new Promise((resolve4) => {
|
|
2791
2791
|
const stdin = process.stdin;
|
|
2792
2792
|
let buffer = "";
|
|
2793
2793
|
let chain = Promise.resolve();
|
|
@@ -2824,8 +2824,8 @@ function runMcpServer(opts) {
|
|
|
2824
2824
|
});
|
|
2825
2825
|
};
|
|
2826
2826
|
stdin.setEncoding("utf8");
|
|
2827
|
-
stdin.on("data", (
|
|
2828
|
-
buffer +=
|
|
2827
|
+
stdin.on("data", (chunk2) => {
|
|
2828
|
+
buffer += chunk2;
|
|
2829
2829
|
let nl = buffer.indexOf("\n");
|
|
2830
2830
|
while (nl !== -1) {
|
|
2831
2831
|
const line = buffer.slice(0, nl);
|
|
@@ -2839,11 +2839,11 @@ function runMcpServer(opts) {
|
|
|
2839
2839
|
processLine(buffer);
|
|
2840
2840
|
buffer = "";
|
|
2841
2841
|
}
|
|
2842
|
-
chain.then(() =>
|
|
2842
|
+
chain.then(() => resolve4()).catch(() => resolve4());
|
|
2843
2843
|
});
|
|
2844
2844
|
stdin.on("error", (e) => {
|
|
2845
2845
|
console.error(`[mcp] stdin error: ${e.message}`);
|
|
2846
|
-
chain.then(() =>
|
|
2846
|
+
chain.then(() => resolve4()).catch(() => resolve4());
|
|
2847
2847
|
});
|
|
2848
2848
|
stdin.resume();
|
|
2849
2849
|
});
|
|
@@ -2999,8 +2999,748 @@ async function mcp(sub, rest = []) {
|
|
|
2999
2999
|
}
|
|
3000
3000
|
}
|
|
3001
3001
|
|
|
3002
|
+
// src/commands/run.ts
|
|
3003
|
+
import { writeFileSync as writeFileSync8 } from "fs";
|
|
3004
|
+
import { resolve as resolve3 } from "path";
|
|
3005
|
+
|
|
3006
|
+
// src/runner.ts
|
|
3007
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
3008
|
+
function resolveActive2(cfg, id) {
|
|
3009
|
+
if (BRAIN_IDS.includes(id)) {
|
|
3010
|
+
const b = cfg.brains[id];
|
|
3011
|
+
return { kind: "cli", id, label: b.label };
|
|
3012
|
+
}
|
|
3013
|
+
const api = findApiBrain(cfg, id);
|
|
3014
|
+
if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
|
|
3015
|
+
return null;
|
|
3016
|
+
}
|
|
3017
|
+
async function runTask(task, opts = {}) {
|
|
3018
|
+
try {
|
|
3019
|
+
const cfg = loadConfig();
|
|
3020
|
+
if (!cfg || !cfg.defaultBrain) {
|
|
3021
|
+
return { ok: false, text: "No Holt setup in this folder. Run holt init.", brainLabel: "" };
|
|
3022
|
+
}
|
|
3023
|
+
const wantId = opts.brainId ?? cfg.defaultBrain;
|
|
3024
|
+
const active = resolveActive2(cfg, wantId);
|
|
3025
|
+
if (!active) {
|
|
3026
|
+
return { ok: false, text: `Brain "${wantId}" is not configured. Run holt setting.`, brainLabel: "" };
|
|
3027
|
+
}
|
|
3028
|
+
if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
|
|
3029
|
+
return {
|
|
3030
|
+
ok: false,
|
|
3031
|
+
text: `${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Install it or run holt setting.`,
|
|
3032
|
+
brainLabel: active.label
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
if (active.kind === "api" && !resolveApiKey(active.brain)) {
|
|
3036
|
+
return {
|
|
3037
|
+
ok: false,
|
|
3038
|
+
text: `${active.label} has no API key. Run holt setting to add one.`,
|
|
3039
|
+
brainLabel: active.label
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
const runSession = "run-" + newSessionId();
|
|
3043
|
+
const recalled = opts.recall !== false ? await recall(task, runSession, 4) : [];
|
|
3044
|
+
const block = skillsPromptBlock();
|
|
3045
|
+
const base = renderPrompt([], task, recalled);
|
|
3046
|
+
const prompt = block ? block + "\n\n" + base : base;
|
|
3047
|
+
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, opts.onChunk) : await runApiBrain(active.brain, prompt, opts.onChunk);
|
|
3048
|
+
if (res.ok && opts.store !== false) {
|
|
3049
|
+
const now = Date.now();
|
|
3050
|
+
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session: runSession, role: "user", content: task, emb: await embed(task) ?? void 0 });
|
|
3051
|
+
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session: runSession, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
|
|
3052
|
+
}
|
|
3053
|
+
return { ok: res.ok, text: res.text, brainLabel: active.label };
|
|
3054
|
+
} catch (e) {
|
|
3055
|
+
return { ok: false, text: e.message, brainLabel: "" };
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// src/commands/run.ts
|
|
3060
|
+
var USAGE = [
|
|
3061
|
+
" holt run <task> [options]",
|
|
3062
|
+
"",
|
|
3063
|
+
" Run a single task against your default brain and stream the reply.",
|
|
3064
|
+
"",
|
|
3065
|
+
" options:",
|
|
3066
|
+
" --brain <id> use a specific brain (CLI or API brain id)",
|
|
3067
|
+
" --out <file> also write the reply to a file (relative to this folder)",
|
|
3068
|
+
" --no-store do not save this exchange to memory",
|
|
3069
|
+
" --no-recall do not pull memory into the prompt",
|
|
3070
|
+
" --quiet suppress streaming; still writes --out and sets exit code",
|
|
3071
|
+
"",
|
|
3072
|
+
' example: holt run "summarize the open items in this folder"'
|
|
3073
|
+
].join("\n");
|
|
3074
|
+
function parseArgs2(args) {
|
|
3075
|
+
const words2 = [];
|
|
3076
|
+
let brainId;
|
|
3077
|
+
let out;
|
|
3078
|
+
let noStore = false;
|
|
3079
|
+
let noRecall = false;
|
|
3080
|
+
let quiet = false;
|
|
3081
|
+
for (let i = 0; i < args.length; i++) {
|
|
3082
|
+
const a = args[i];
|
|
3083
|
+
if (a === "--brain") {
|
|
3084
|
+
brainId = args[++i];
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
if (a === "--out") {
|
|
3088
|
+
out = args[++i];
|
|
3089
|
+
continue;
|
|
3090
|
+
}
|
|
3091
|
+
if (a === "--no-store") {
|
|
3092
|
+
noStore = true;
|
|
3093
|
+
continue;
|
|
3094
|
+
}
|
|
3095
|
+
if (a === "--no-recall") {
|
|
3096
|
+
noRecall = true;
|
|
3097
|
+
continue;
|
|
3098
|
+
}
|
|
3099
|
+
if (a === "--quiet") {
|
|
3100
|
+
quiet = true;
|
|
3101
|
+
continue;
|
|
3102
|
+
}
|
|
3103
|
+
words2.push(a);
|
|
3104
|
+
}
|
|
3105
|
+
return { task: words2.join(" ").trim(), brainId, out, noStore, noRecall, quiet };
|
|
3106
|
+
}
|
|
3107
|
+
async function run(args) {
|
|
3108
|
+
const p = parseArgs2(args);
|
|
3109
|
+
if (!p.task) {
|
|
3110
|
+
console.log(USAGE);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
if (!isTrusted()) {
|
|
3114
|
+
if (process.stdin.isTTY) {
|
|
3115
|
+
const { ask, close } = createReader();
|
|
3116
|
+
const ok2 = await ensureTrusted(ask);
|
|
3117
|
+
close();
|
|
3118
|
+
if (!ok2) return;
|
|
3119
|
+
} else {
|
|
3120
|
+
process.stderr.write("This folder is not trusted. Run holt init or holt chat here once, then schedule/run.\n");
|
|
3121
|
+
process.exitCode = 1;
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
let streamed = false;
|
|
3126
|
+
const result = await runTask(p.task, {
|
|
3127
|
+
brainId: p.brainId,
|
|
3128
|
+
recall: !p.noRecall,
|
|
3129
|
+
store: !p.noStore,
|
|
3130
|
+
onChunk: p.quiet ? void 0 : (ch) => {
|
|
3131
|
+
streamed = true;
|
|
3132
|
+
process.stdout.write(ch);
|
|
3133
|
+
}
|
|
3134
|
+
});
|
|
3135
|
+
if (!p.quiet && !streamed) {
|
|
3136
|
+
process.stdout.write(result.text);
|
|
3137
|
+
}
|
|
3138
|
+
if (!p.quiet && (streamed || result.text) && !result.text.endsWith("\n")) {
|
|
3139
|
+
process.stdout.write("\n");
|
|
3140
|
+
}
|
|
3141
|
+
if (!result.ok) {
|
|
3142
|
+
process.stderr.write(c.red(result.text) + "\n");
|
|
3143
|
+
process.exitCode = 1;
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
if (p.out) {
|
|
3147
|
+
const target = resolve3(process.cwd(), p.out);
|
|
3148
|
+
writeFileSync8(target, result.text, "utf8");
|
|
3149
|
+
process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
// src/commands/schedule.ts
|
|
3154
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
3155
|
+
import { writeFileSync as writeFileSync10, mkdirSync as mkdirSync8, rmSync as rmSync4, existsSync as existsSync8 } from "fs";
|
|
3156
|
+
import { dirname as dirname3 } from "path";
|
|
3157
|
+
|
|
3158
|
+
// src/scheduler.ts
|
|
3159
|
+
import { homedir as homedir3 } from "os";
|
|
3160
|
+
import { join as join9 } from "path";
|
|
3161
|
+
import { readFileSync as readFileSync8, writeFileSync as writeFileSync9, mkdirSync as mkdirSync7 } from "fs";
|
|
3162
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
3163
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
3164
|
+
function schedulesPath() {
|
|
3165
|
+
return join9(GLOBAL_DIR, "schedules.json");
|
|
3166
|
+
}
|
|
3167
|
+
function logsDir() {
|
|
3168
|
+
return join9(GLOBAL_DIR, "logs");
|
|
3169
|
+
}
|
|
3170
|
+
function logPath(id) {
|
|
3171
|
+
return join9(logsDir(), `${id}.log`);
|
|
3172
|
+
}
|
|
3173
|
+
function plistPath(id) {
|
|
3174
|
+
return join9(homedir3(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
|
|
3175
|
+
}
|
|
3176
|
+
function loadJobs() {
|
|
3177
|
+
try {
|
|
3178
|
+
const raw = JSON.parse(readFileSync8(schedulesPath(), "utf8"));
|
|
3179
|
+
return Array.isArray(raw) ? raw : [];
|
|
3180
|
+
} catch {
|
|
3181
|
+
return [];
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
function saveJobs(jobs) {
|
|
3185
|
+
mkdirSync7(GLOBAL_DIR, { recursive: true });
|
|
3186
|
+
writeFileSync9(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
|
|
3187
|
+
}
|
|
3188
|
+
function addJob(job) {
|
|
3189
|
+
const jobs = loadJobs();
|
|
3190
|
+
jobs.push(job);
|
|
3191
|
+
saveJobs(jobs);
|
|
3192
|
+
return jobs;
|
|
3193
|
+
}
|
|
3194
|
+
function removeJob(id) {
|
|
3195
|
+
const jobs = loadJobs().filter((j) => j.id !== id);
|
|
3196
|
+
saveJobs(jobs);
|
|
3197
|
+
return jobs;
|
|
3198
|
+
}
|
|
3199
|
+
function newJobId() {
|
|
3200
|
+
return randomUUID4().slice(0, 8);
|
|
3201
|
+
}
|
|
3202
|
+
function parseWhen(when) {
|
|
3203
|
+
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when.trim());
|
|
3204
|
+
if (!m) {
|
|
3205
|
+
throw new Error(
|
|
3206
|
+
`Could not read time "${when}". Use 24h HH:MM for a daily run, e.g. 07:00 or 23:30.`
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3209
|
+
const hour = Number(m[1]);
|
|
3210
|
+
const minute = Number(m[2]);
|
|
3211
|
+
return { hour, minute };
|
|
3212
|
+
}
|
|
3213
|
+
function shQuote(s) {
|
|
3214
|
+
return `'` + s.replace(/'/g, `'\\''`) + `'`;
|
|
3215
|
+
}
|
|
3216
|
+
function resolveHoltPath() {
|
|
3217
|
+
try {
|
|
3218
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
3219
|
+
const res = spawnSync3(finder, ["holt"], { encoding: "utf8" });
|
|
3220
|
+
if (res.status === 0 && typeof res.stdout === "string") {
|
|
3221
|
+
const first = res.stdout.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
3222
|
+
if (first) return first;
|
|
3223
|
+
}
|
|
3224
|
+
} catch {
|
|
3225
|
+
}
|
|
3226
|
+
return "holt";
|
|
3227
|
+
}
|
|
3228
|
+
function buildCommand(job, holtPath) {
|
|
3229
|
+
const holt = shQuote(holtPath);
|
|
3230
|
+
const ws = shQuote(job.workspace);
|
|
3231
|
+
const log = shQuote(logPath(job.id));
|
|
3232
|
+
let cmd = `cd ${ws} && ${holt} run ${shQuote(job.task)}`;
|
|
3233
|
+
if (job.brain) cmd += ` --brain ${shQuote(job.brain)}`;
|
|
3234
|
+
cmd += ` --quiet --out ${log}`;
|
|
3235
|
+
if (job.notify) cmd += ` && ${holt} notify "$(cat ${log})"`;
|
|
3236
|
+
return cmd;
|
|
3237
|
+
}
|
|
3238
|
+
function xmlEscape(s) {
|
|
3239
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3240
|
+
}
|
|
3241
|
+
function buildLaunchdPlist(job, holtPath) {
|
|
3242
|
+
const { hour, minute } = parseWhen(job.when);
|
|
3243
|
+
const command = buildCommand(job, holtPath);
|
|
3244
|
+
const log = logPath(job.id);
|
|
3245
|
+
const args = ["/bin/sh", "-lc", command];
|
|
3246
|
+
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
3247
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3248
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3249
|
+
<plist version="1.0">
|
|
3250
|
+
<dict>
|
|
3251
|
+
<key>Label</key>
|
|
3252
|
+
<string>com.holt.${xmlEscape(job.id)}</string>
|
|
3253
|
+
<key>ProgramArguments</key>
|
|
3254
|
+
<array>
|
|
3255
|
+
${argXml}
|
|
3256
|
+
</array>
|
|
3257
|
+
<key>StartCalendarInterval</key>
|
|
3258
|
+
<dict>
|
|
3259
|
+
<key>Hour</key>
|
|
3260
|
+
<integer>${hour}</integer>
|
|
3261
|
+
<key>Minute</key>
|
|
3262
|
+
<integer>${minute}</integer>
|
|
3263
|
+
</dict>
|
|
3264
|
+
<key>RunAtLoad</key>
|
|
3265
|
+
<false/>
|
|
3266
|
+
<key>StandardOutPath</key>
|
|
3267
|
+
<string>${xmlEscape(log)}</string>
|
|
3268
|
+
<key>StandardErrorPath</key>
|
|
3269
|
+
<string>${xmlEscape(log)}</string>
|
|
3270
|
+
</dict>
|
|
3271
|
+
</plist>
|
|
3272
|
+
`;
|
|
3273
|
+
}
|
|
3274
|
+
var CRON_MARKER = "holt:";
|
|
3275
|
+
function buildCronLine(job, holtPath) {
|
|
3276
|
+
const { hour, minute } = parseWhen(job.when);
|
|
3277
|
+
const command = buildCommand(job, holtPath);
|
|
3278
|
+
return `${minute} ${hour} * * * ${command} # ${CRON_MARKER}${job.id}`;
|
|
3279
|
+
}
|
|
3280
|
+
function stripCronLines(crontab, id) {
|
|
3281
|
+
const marker = `# ${CRON_MARKER}${id}`;
|
|
3282
|
+
const kept = crontab.split("\n").filter((line) => !line.includes(marker));
|
|
3283
|
+
return kept.join("\n").replace(/\n+$/, "\n");
|
|
3284
|
+
}
|
|
3285
|
+
function appendCronLine(crontab, line) {
|
|
3286
|
+
const base = crontab.replace(/\s*$/, "");
|
|
3287
|
+
return (base ? base + "\n" : "") + line + "\n";
|
|
3288
|
+
}
|
|
3289
|
+
function ensureLogsDir() {
|
|
3290
|
+
mkdirSync7(logsDir(), { recursive: true });
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/commands/schedule.ts
|
|
3294
|
+
function parseAdd(rest) {
|
|
3295
|
+
const positional = [];
|
|
3296
|
+
const opts = { notify: false };
|
|
3297
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3298
|
+
const a = rest[i];
|
|
3299
|
+
if (a === "--notify") opts.notify = true;
|
|
3300
|
+
else if (a === "--name") opts.name = rest[++i];
|
|
3301
|
+
else if (a === "--brain") opts.brain = rest[++i];
|
|
3302
|
+
else if (a && a.startsWith("--")) {
|
|
3303
|
+
const eq = a.indexOf("=");
|
|
3304
|
+
if (eq > 2) {
|
|
3305
|
+
const k = a.slice(2, eq);
|
|
3306
|
+
const v = a.slice(eq + 1);
|
|
3307
|
+
if (k === "name") opts.name = v;
|
|
3308
|
+
else if (k === "brain") opts.brain = v;
|
|
3309
|
+
}
|
|
3310
|
+
} else if (a !== void 0) positional.push(a);
|
|
3311
|
+
}
|
|
3312
|
+
return { task: positional[0], when: positional[1], opts };
|
|
3313
|
+
}
|
|
3314
|
+
function installDarwin(job, holtPath) {
|
|
3315
|
+
const path = plistPath(job.id);
|
|
3316
|
+
mkdirSync8(dirname3(path), { recursive: true });
|
|
3317
|
+
writeFileSync10(path, buildLaunchdPlist(job, holtPath), "utf8");
|
|
3318
|
+
const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
|
|
3319
|
+
if (res.status === 0) {
|
|
3320
|
+
console.log(c.green(" Loaded into launchd."));
|
|
3321
|
+
} else {
|
|
3322
|
+
console.log(c.dim(" Wrote the plist, but launchctl load did not confirm."));
|
|
3323
|
+
console.log(c.dim(` Load it yourself with: launchctl load -w ${path}`));
|
|
3324
|
+
if (res.stderr && res.stderr.trim()) console.log(c.dim(` (${res.stderr.trim()})`));
|
|
3325
|
+
}
|
|
3326
|
+
console.log(c.dim(` plist: ${path}`));
|
|
3327
|
+
}
|
|
3328
|
+
function currentCrontab() {
|
|
3329
|
+
const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
|
|
3330
|
+
return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
|
|
3331
|
+
}
|
|
3332
|
+
function writeCrontab(body) {
|
|
3333
|
+
const res = spawnSync4("crontab", ["-"], { input: body, encoding: "utf8" });
|
|
3334
|
+
return res.status === 0;
|
|
3335
|
+
}
|
|
3336
|
+
function installLinux(job, holtPath) {
|
|
3337
|
+
const line = buildCronLine(job, holtPath);
|
|
3338
|
+
const next = appendCronLine(currentCrontab(), line);
|
|
3339
|
+
if (writeCrontab(next)) {
|
|
3340
|
+
console.log(c.green(" Added to your crontab."));
|
|
3341
|
+
console.log(c.dim(` line: ${line}`));
|
|
3342
|
+
} else {
|
|
3343
|
+
console.log(c.dim(" Could not update the crontab automatically. Add this line yourself:"));
|
|
3344
|
+
console.log(" " + line);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
async function addCmd(rest) {
|
|
3348
|
+
const { task, when, opts } = parseAdd(rest);
|
|
3349
|
+
if (!task || !when) {
|
|
3350
|
+
console.log(c.dim('\n Usage: holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]\n'));
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
try {
|
|
3354
|
+
parseWhen(when);
|
|
3355
|
+
} catch (e) {
|
|
3356
|
+
console.log("\n " + c.red(e.message) + "\n");
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
const ws = workspace();
|
|
3360
|
+
const holtPath = resolveHoltPath();
|
|
3361
|
+
const job = {
|
|
3362
|
+
id: newJobId(),
|
|
3363
|
+
name: opts.name || task.slice(0, 40),
|
|
3364
|
+
task,
|
|
3365
|
+
when,
|
|
3366
|
+
workspace: ws,
|
|
3367
|
+
notify: opts.notify,
|
|
3368
|
+
...opts.brain ? { brain: opts.brain } : {}
|
|
3369
|
+
};
|
|
3370
|
+
ensureLogsDir();
|
|
3371
|
+
addJob(job);
|
|
3372
|
+
console.log("\n" + c.accent("Scheduled."));
|
|
3373
|
+
console.log(` id ${job.id}`);
|
|
3374
|
+
console.log(` when daily at ${job.when}`);
|
|
3375
|
+
console.log(` workspace ${job.workspace}`);
|
|
3376
|
+
if (job.notify) console.log(` notify on (pushes output to Telegram)`);
|
|
3377
|
+
console.log(` log ${logPath(job.id)}`);
|
|
3378
|
+
if (process.platform === "darwin") installDarwin(job, holtPath);
|
|
3379
|
+
else if (process.platform === "linux") installLinux(job, holtPath);
|
|
3380
|
+
else {
|
|
3381
|
+
console.log(c.dim("\n Your OS has no built-in installer. Install this entry yourself:"));
|
|
3382
|
+
console.log(" " + buildCronLine(job, holtPath));
|
|
3383
|
+
}
|
|
3384
|
+
console.log("");
|
|
3385
|
+
}
|
|
3386
|
+
function listCmd() {
|
|
3387
|
+
const jobs = loadJobs();
|
|
3388
|
+
if (jobs.length === 0) {
|
|
3389
|
+
console.log(c.dim("\n No scheduled tasks yet. Add one with:"));
|
|
3390
|
+
console.log(c.dim(' holt schedule add "<task>" <HH:MM> [--notify]\n'));
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
console.log("\n" + c.accent("Scheduled tasks"));
|
|
3394
|
+
for (const j of jobs) {
|
|
3395
|
+
console.log(` ${c.bold(j.id)} daily ${j.when} ${c.dim(j.notify ? "(notify)" : "")}`);
|
|
3396
|
+
console.log(` ${j.name}`);
|
|
3397
|
+
console.log(c.dim(` ${j.workspace}`));
|
|
3398
|
+
}
|
|
3399
|
+
console.log("");
|
|
3400
|
+
}
|
|
3401
|
+
function removeDarwin(id) {
|
|
3402
|
+
const path = plistPath(id);
|
|
3403
|
+
if (existsSync8(path)) {
|
|
3404
|
+
spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
|
|
3405
|
+
try {
|
|
3406
|
+
rmSync4(path);
|
|
3407
|
+
} catch {
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
function removeLinux(id) {
|
|
3412
|
+
const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
|
|
3413
|
+
const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
|
|
3414
|
+
const next = stripCronLines(body, id);
|
|
3415
|
+
spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
|
|
3416
|
+
}
|
|
3417
|
+
function removeCmd(rest) {
|
|
3418
|
+
const id = rest[0];
|
|
3419
|
+
if (!id) {
|
|
3420
|
+
console.log(c.dim("\n Usage: holt schedule remove <id> (see ids with: holt schedule list)\n"));
|
|
3421
|
+
return;
|
|
3422
|
+
}
|
|
3423
|
+
const before = loadJobs();
|
|
3424
|
+
const existed = before.some((j) => j.id === id);
|
|
3425
|
+
removeJob(id);
|
|
3426
|
+
if (process.platform === "darwin") removeDarwin(id);
|
|
3427
|
+
else if (process.platform === "linux") removeLinux(id);
|
|
3428
|
+
if (existed) console.log("\n" + c.green(` Removed schedule ${id}.`) + "\n");
|
|
3429
|
+
else console.log(c.dim(`
|
|
3430
|
+
No schedule with id ${id}. Nothing to remove.
|
|
3431
|
+
`));
|
|
3432
|
+
}
|
|
3433
|
+
function usage2() {
|
|
3434
|
+
console.log("\n" + c.accent("holt schedule") + c.dim(" run a task automatically on a timer"));
|
|
3435
|
+
console.log(c.dim('\n holt schedule add "<task>" <HH:MM> [--name <n>] [--notify] [--brain <id>]'));
|
|
3436
|
+
console.log(c.dim(" holt schedule list"));
|
|
3437
|
+
console.log(c.dim(" holt schedule remove <id>\n"));
|
|
3438
|
+
}
|
|
3439
|
+
async function schedule(sub, rest = []) {
|
|
3440
|
+
const action = (sub || "").toLowerCase();
|
|
3441
|
+
if (action === "list") {
|
|
3442
|
+
listCmd();
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
if (action !== "add" && action !== "remove" && action !== "rm") {
|
|
3446
|
+
usage2();
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
const { ask, close } = createReader();
|
|
3450
|
+
if (!await ensureTrusted(ask)) {
|
|
3451
|
+
close();
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
close();
|
|
3455
|
+
if (action === "add") await addCmd(rest);
|
|
3456
|
+
else removeCmd(rest);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
// src/telegram.ts
|
|
3460
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync11, existsSync as existsSync9, mkdirSync as mkdirSync9 } from "fs";
|
|
3461
|
+
import { join as join10 } from "path";
|
|
3462
|
+
var API_BASE = "https://api.telegram.org";
|
|
3463
|
+
var CHUNK_SIZE = 4e3;
|
|
3464
|
+
function telegramConfigPath() {
|
|
3465
|
+
return join10(GLOBAL_DIR, "telegram.json");
|
|
3466
|
+
}
|
|
3467
|
+
function loadTelegramConfig() {
|
|
3468
|
+
const path = telegramConfigPath();
|
|
3469
|
+
if (!existsSync9(path)) return null;
|
|
3470
|
+
try {
|
|
3471
|
+
const raw = JSON.parse(readFileSync9(path, "utf8"));
|
|
3472
|
+
if (typeof raw.token !== "string" || typeof raw.allowedChatId !== "number") return null;
|
|
3473
|
+
return { token: raw.token, allowedChatId: raw.allowedChatId };
|
|
3474
|
+
} catch {
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
function saveTelegramConfig(cfg) {
|
|
3479
|
+
mkdirSync9(GLOBAL_DIR, { recursive: true });
|
|
3480
|
+
writeFileSync11(telegramConfigPath(), JSON.stringify(cfg, null, 2) + "\n", {
|
|
3481
|
+
encoding: "utf8",
|
|
3482
|
+
mode: 384
|
|
3483
|
+
});
|
|
3484
|
+
}
|
|
3485
|
+
function buildSendRequest(token, chatId, text) {
|
|
3486
|
+
return {
|
|
3487
|
+
url: `${API_BASE}/bot${token}/sendMessage`,
|
|
3488
|
+
body: JSON.stringify({ chat_id: chatId, text })
|
|
3489
|
+
};
|
|
3490
|
+
}
|
|
3491
|
+
function buildGetUpdatesUrl(token, offset, timeoutSec) {
|
|
3492
|
+
const params = new URLSearchParams({
|
|
3493
|
+
offset: String(offset),
|
|
3494
|
+
timeout: String(timeoutSec)
|
|
3495
|
+
});
|
|
3496
|
+
return `${API_BASE}/bot${token}/getUpdates?${params.toString()}`;
|
|
3497
|
+
}
|
|
3498
|
+
function chunk(text, size = CHUNK_SIZE) {
|
|
3499
|
+
if (text.length === 0) return [""];
|
|
3500
|
+
const out = [];
|
|
3501
|
+
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
|
|
3502
|
+
return out;
|
|
3503
|
+
}
|
|
3504
|
+
async function sendMessage(text, chatId) {
|
|
3505
|
+
const cfg = loadTelegramConfig();
|
|
3506
|
+
if (!cfg) {
|
|
3507
|
+
process.stderr.write('[telegram] not configured; run "holt telegram setup"\n');
|
|
3508
|
+
return false;
|
|
3509
|
+
}
|
|
3510
|
+
const target = chatId ?? cfg.allowedChatId;
|
|
3511
|
+
const pieces = chunk(text || "No output.");
|
|
3512
|
+
let allOk = true;
|
|
3513
|
+
for (const piece of pieces) {
|
|
3514
|
+
const { url, body } = buildSendRequest(cfg.token, target, piece);
|
|
3515
|
+
try {
|
|
3516
|
+
const res = await fetch(url, {
|
|
3517
|
+
method: "POST",
|
|
3518
|
+
headers: { "content-type": "application/json" },
|
|
3519
|
+
body
|
|
3520
|
+
});
|
|
3521
|
+
if (!res.ok) {
|
|
3522
|
+
allOk = false;
|
|
3523
|
+
process.stderr.write(`[telegram] sendMessage HTTP ${res.status}
|
|
3524
|
+
`);
|
|
3525
|
+
}
|
|
3526
|
+
} catch (err2) {
|
|
3527
|
+
allOk = false;
|
|
3528
|
+
process.stderr.write(`[telegram] sendMessage failed: ${err2.message}
|
|
3529
|
+
`);
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
return allOk;
|
|
3533
|
+
}
|
|
3534
|
+
async function getUpdates(offset) {
|
|
3535
|
+
const cfg = loadTelegramConfig();
|
|
3536
|
+
if (!cfg) return [];
|
|
3537
|
+
const timeoutSec = 30;
|
|
3538
|
+
const url = buildGetUpdatesUrl(cfg.token, offset, timeoutSec);
|
|
3539
|
+
const ac = new AbortController();
|
|
3540
|
+
const timer = setTimeout(() => ac.abort(), (timeoutSec + 10) * 1e3);
|
|
3541
|
+
try {
|
|
3542
|
+
const res = await fetch(url, { signal: ac.signal });
|
|
3543
|
+
if (!res.ok) {
|
|
3544
|
+
process.stderr.write(`[telegram] getUpdates HTTP ${res.status}
|
|
3545
|
+
`);
|
|
3546
|
+
return [];
|
|
3547
|
+
}
|
|
3548
|
+
const data = await res.json();
|
|
3549
|
+
if (!data || data.ok !== true || !Array.isArray(data.result)) return [];
|
|
3550
|
+
return data.result;
|
|
3551
|
+
} catch (err2) {
|
|
3552
|
+
process.stderr.write(`[telegram] getUpdates failed: ${err2.message}
|
|
3553
|
+
`);
|
|
3554
|
+
return [];
|
|
3555
|
+
} finally {
|
|
3556
|
+
clearTimeout(timer);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
// src/commands/telegram.ts
|
|
3561
|
+
var HELP_TEXT = "Holt bot. Send me anything and I will run it through your selected brain and reply.\nCommands: /help this message.";
|
|
3562
|
+
async function telegram(sub, _rest = []) {
|
|
3563
|
+
const action = (sub || "").toLowerCase();
|
|
3564
|
+
if (action === "setup") {
|
|
3565
|
+
await setup();
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
await runBot();
|
|
3569
|
+
}
|
|
3570
|
+
async function setup() {
|
|
3571
|
+
const { ask, close } = createReader();
|
|
3572
|
+
try {
|
|
3573
|
+
console.log("\n" + c.accent("Connect Holt to Telegram"));
|
|
3574
|
+
console.log(c.dim(" 1. Open Telegram and message @BotFather."));
|
|
3575
|
+
console.log(c.dim(" 2. Send /newbot, follow the prompts, and copy the bot token it gives you."));
|
|
3576
|
+
console.log(c.dim(" A token looks like 123456789:AAF... (keep it secret)."));
|
|
3577
|
+
const token = (await ask("\n Paste your bot token: ") ?? "").trim();
|
|
3578
|
+
if (!token) {
|
|
3579
|
+
console.log(c.red(" No token entered. Cancelled.\n"));
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
console.log("\n" + c.dim(" Now I need your chat id (so I only answer you)."));
|
|
3583
|
+
console.log(c.dim(" Option A: open your new bot in Telegram and send it any message,"));
|
|
3584
|
+
console.log(c.dim(" then I will auto-detect the chat id."));
|
|
3585
|
+
console.log(c.dim(" Option B: paste a numeric chat id yourself."));
|
|
3586
|
+
const mode = (await ask("\n Auto-detect from a message you just sent? [Y/n] ") ?? "").trim().toLowerCase();
|
|
3587
|
+
let allowedChatId = null;
|
|
3588
|
+
if (mode === "" || mode === "y" || mode === "yes") {
|
|
3589
|
+
saveTelegramConfig({ token, allowedChatId: 0 });
|
|
3590
|
+
console.log(c.dim("\n Checking for a recent message..."));
|
|
3591
|
+
const updates = await getUpdates(0);
|
|
3592
|
+
allowedChatId = latestChatId(updates);
|
|
3593
|
+
if (allowedChatId === null) {
|
|
3594
|
+
console.log(c.red(" Did not find a recent message."));
|
|
3595
|
+
const manual = (await ask(" Paste your numeric chat id instead: ") ?? "").trim();
|
|
3596
|
+
allowedChatId = parseChatId(manual);
|
|
3597
|
+
}
|
|
3598
|
+
} else {
|
|
3599
|
+
const manual = (await ask(" Paste your numeric chat id: ") ?? "").trim();
|
|
3600
|
+
allowedChatId = parseChatId(manual);
|
|
3601
|
+
}
|
|
3602
|
+
if (allowedChatId === null) {
|
|
3603
|
+
console.log(c.red('\n No valid chat id. Nothing saved. Run "holt telegram setup" again.\n'));
|
|
3604
|
+
return;
|
|
3605
|
+
}
|
|
3606
|
+
const cfg = { token, allowedChatId };
|
|
3607
|
+
saveTelegramConfig(cfg);
|
|
3608
|
+
console.log(
|
|
3609
|
+
"\n" + c.green(" Saved.") + c.dim(` token ...${token.slice(-4)} chat id ${allowedChatId} (~/.holt/telegram.json, mode 600)`)
|
|
3610
|
+
);
|
|
3611
|
+
console.log(c.dim(" Start the bot with: holt telegram\n"));
|
|
3612
|
+
} finally {
|
|
3613
|
+
close();
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
function parseChatId(s) {
|
|
3617
|
+
if (!/^-?\d+$/.test(s)) return null;
|
|
3618
|
+
const n = Number(s);
|
|
3619
|
+
return Number.isFinite(n) ? n : null;
|
|
3620
|
+
}
|
|
3621
|
+
function latestChatId(updates) {
|
|
3622
|
+
for (let i = updates.length - 1; i >= 0; i--) {
|
|
3623
|
+
const u = updates[i];
|
|
3624
|
+
if (u && u.message) return u.message.chat.id;
|
|
3625
|
+
}
|
|
3626
|
+
return null;
|
|
3627
|
+
}
|
|
3628
|
+
async function runBot() {
|
|
3629
|
+
const cfg = loadTelegramConfig();
|
|
3630
|
+
if (!cfg) {
|
|
3631
|
+
console.log(c.dim('\n Telegram is not set up. Run "holt telegram setup" first.\n'));
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
if (!isTrusted()) {
|
|
3635
|
+
trustDir();
|
|
3636
|
+
process.stderr.write(`[telegram] auto-trusted ${workspace()}
|
|
3637
|
+
`);
|
|
3638
|
+
}
|
|
3639
|
+
process.stderr.write("[telegram] bot started; long-polling for messages. Ctrl-C to stop.\n");
|
|
3640
|
+
let offset = 0;
|
|
3641
|
+
let busy = false;
|
|
3642
|
+
while (true) {
|
|
3643
|
+
let updates = [];
|
|
3644
|
+
try {
|
|
3645
|
+
updates = await getUpdates(offset);
|
|
3646
|
+
} catch (err2) {
|
|
3647
|
+
process.stderr.write(`[telegram] poll error: ${err2.message}
|
|
3648
|
+
`);
|
|
3649
|
+
await sleep(1e3);
|
|
3650
|
+
continue;
|
|
3651
|
+
}
|
|
3652
|
+
for (const u of updates) {
|
|
3653
|
+
if (u && typeof u.update_id === "number") offset = Math.max(offset, u.update_id + 1);
|
|
3654
|
+
try {
|
|
3655
|
+
await handleUpdate(u, cfg, () => busy, (v) => {
|
|
3656
|
+
busy = v;
|
|
3657
|
+
});
|
|
3658
|
+
} catch (err2) {
|
|
3659
|
+
process.stderr.write(`[telegram] update ${u?.update_id} failed: ${err2.message}
|
|
3660
|
+
`);
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
async function handleUpdate(u, cfg, getBusy, setBusy) {
|
|
3666
|
+
const msg = u?.message;
|
|
3667
|
+
if (!msg || typeof msg.text !== "string") return;
|
|
3668
|
+
if (msg.chat.id !== cfg.allowedChatId) {
|
|
3669
|
+
process.stderr.write(`[telegram] ignoring message from chat ${msg.chat.id}
|
|
3670
|
+
`);
|
|
3671
|
+
return;
|
|
3672
|
+
}
|
|
3673
|
+
const text = msg.text.trim();
|
|
3674
|
+
if (text === "/start" || text === "/help") {
|
|
3675
|
+
await sendMessage(HELP_TEXT, cfg.allowedChatId);
|
|
3676
|
+
return;
|
|
3677
|
+
}
|
|
3678
|
+
if (getBusy()) {
|
|
3679
|
+
await sendMessage("Still working on the previous message. Try again in a moment.", cfg.allowedChatId);
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
setBusy(true);
|
|
3683
|
+
try {
|
|
3684
|
+
process.stderr.write(`[telegram] running task (${text.length} chars)
|
|
3685
|
+
`);
|
|
3686
|
+
const result = await runTask(text);
|
|
3687
|
+
await sendMessage(result.text || "No output.", cfg.allowedChatId);
|
|
3688
|
+
process.stderr.write(`[telegram] replied (brain: ${result.brainLabel})
|
|
3689
|
+
`);
|
|
3690
|
+
} catch (err2) {
|
|
3691
|
+
process.stderr.write(`[telegram] runTask failed: ${err2.message}
|
|
3692
|
+
`);
|
|
3693
|
+
await sendMessage("Sorry, that run failed. Check the Holt logs.", cfg.allowedChatId);
|
|
3694
|
+
} finally {
|
|
3695
|
+
setBusy(false);
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
function sleep(ms) {
|
|
3699
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
// src/commands/notify.ts
|
|
3703
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
3704
|
+
async function notify(args) {
|
|
3705
|
+
const nonFlag = args.filter((a) => a !== "-" && !a.startsWith("--"));
|
|
3706
|
+
const wantsStdin = args.includes("-") || !process.stdin.isTTY;
|
|
3707
|
+
let message = "";
|
|
3708
|
+
if (nonFlag.length > 0 && !args.includes("-")) {
|
|
3709
|
+
message = nonFlag.join(" ").trim();
|
|
3710
|
+
} else if (wantsStdin) {
|
|
3711
|
+
message = readStdin().trim();
|
|
3712
|
+
} else if (nonFlag.length > 0) {
|
|
3713
|
+
message = nonFlag.join(" ").trim();
|
|
3714
|
+
}
|
|
3715
|
+
if (!message) {
|
|
3716
|
+
process.stderr.write('Nothing to send. Pass a message or pipe text into "holt notify".\n');
|
|
3717
|
+
process.exitCode = 1;
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
const cfg = loadTelegramConfig();
|
|
3721
|
+
if (!cfg) {
|
|
3722
|
+
process.stderr.write('Telegram is not set up. Run "holt telegram setup".\n');
|
|
3723
|
+
process.exitCode = 1;
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
const ok2 = await sendMessage(message, cfg.allowedChatId);
|
|
3727
|
+
if (ok2) {
|
|
3728
|
+
process.stderr.write("Sent to Telegram.\n");
|
|
3729
|
+
} else {
|
|
3730
|
+
process.stderr.write("Failed to send to Telegram. Check the network and your bot token.\n");
|
|
3731
|
+
process.exitCode = 1;
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
function readStdin() {
|
|
3735
|
+
try {
|
|
3736
|
+
return readFileSync10(0, "utf8");
|
|
3737
|
+
} catch {
|
|
3738
|
+
return "";
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3002
3742
|
// src/cli.ts
|
|
3003
|
-
var VERSION = "0.
|
|
3743
|
+
var VERSION = "0.8.0";
|
|
3004
3744
|
var BANNER = `
|
|
3005
3745
|
\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
3746
|
\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 +3756,10 @@ Usage: holt <command>
|
|
|
3016
3756
|
Commands:
|
|
3017
3757
|
init Trust this folder, choose and install brains, sign in, set defaults
|
|
3018
3758
|
chat Start a session. It remembers past sessions in this folder
|
|
3759
|
+
run <task> Run one task non-interactively: recall, brain executes, remember
|
|
3760
|
+
schedule Fire "holt run" on a timer: holt schedule [add | list | remove]
|
|
3761
|
+
telegram Chat with Holt from your phone: holt telegram [setup]
|
|
3762
|
+
notify [msg] Push a message to your phone over Telegram (stdin-friendly)
|
|
3019
3763
|
memory Inspect memory: holt memory [search <query> | embed | clear]
|
|
3020
3764
|
graph See your memory as an interactive knowledge graph in the browser
|
|
3021
3765
|
skill Manage skills: holt skill [list | show | create | add | remove]
|
|
@@ -3072,6 +3816,18 @@ async function main() {
|
|
|
3072
3816
|
case "mcp":
|
|
3073
3817
|
await mcp(process.argv[3], process.argv.slice(4));
|
|
3074
3818
|
break;
|
|
3819
|
+
case "run":
|
|
3820
|
+
await run(process.argv.slice(3));
|
|
3821
|
+
break;
|
|
3822
|
+
case "schedule":
|
|
3823
|
+
await schedule(process.argv[3], process.argv.slice(4));
|
|
3824
|
+
break;
|
|
3825
|
+
case "telegram":
|
|
3826
|
+
await telegram(process.argv[3], process.argv.slice(4));
|
|
3827
|
+
break;
|
|
3828
|
+
case "notify":
|
|
3829
|
+
await notify(process.argv.slice(3));
|
|
3830
|
+
break;
|
|
3075
3831
|
default:
|
|
3076
3832
|
console.log(`
|
|
3077
3833
|
Unknown command: "${cmd}"`);
|