@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.
Files changed (3) hide show
  1. package/README.md +48 -5
  2. package/dist/cli.js +779 -23
  3. 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 for config)
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. **Orchestration**: a local model works, a cloud model reviews the risky steps
200
- 6. **Channels and polish**: Telegram, docs site, skill registry publishing
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((resolve3) => {
39
+ const ask = (q) => new Promise((resolve4) => {
40
40
  if (q) process.stdout.write(q);
41
- if (buffer.length) resolve3(buffer.shift());
42
- else if (closed) resolve3(null);
43
- else waiters.push(resolve3);
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((resolve3) => {
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
- resolve3({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
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) => resolve3({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
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) resolve3({ ok: true, text });
247
- else resolve3({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
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((resolve3) => {
396
+ return new Promise((resolve4) => {
397
397
  let child;
398
398
  try {
399
399
  child = spawn2(cmd, args, { stdio: "inherit" });
400
400
  } catch {
401
- resolve3(-1);
401
+ resolve4(-1);
402
402
  return;
403
403
  }
404
- child.on("error", () => resolve3(-1));
405
- child.on("close", (code) => resolve3(code ?? -1));
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 chunk of res.body) {
992
- carry += decoder.decode(chunk, { stream: true });
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 = (chunk) => {
1600
+ const onChunk = (chunk2) => {
1601
1601
  streamed = true;
1602
- process.stdout.write(chunk);
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((resolve3) => {
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", (chunk) => {
2828
- buffer += chunk;
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(() => resolve3()).catch(() => resolve3());
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(() => resolve3()).catch(() => resolve3());
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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.7.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}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "An open-source personal agent OS: any LLM, private memory you can see and walk.",
5
5
  "type": "module",
6
6
  "license": "MIT",