@holt-os/holt 0.11.0 → 0.12.1

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 +16 -3
  2. package/dist/cli.js +346 -70
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,14 @@ Holt is an open-source, self-hosted personal agent OS. Clone it, pick your skill
6
6
 
7
7
  > A *holt* is a small wood: a sheltered place where things are kept and grow. That's the idea. A private home for your knowledge that compounds over time.
8
8
 
9
- > **Status: young but genuinely useful.** Working today: `holt init` and `holt chat`, brains as agent CLIs (Claude Code, Codex, Gemini) or direct API connections with your own key, mid-conversation brain switching that keeps context, persistent per-folder memory with semantic recall, skills in the portable SKILL.md format, and `holt graph`: your memory drawn as an interactive knowledge graph.
9
+ > **Status: shipping and usable today.** Working now:
10
+ >
11
+ > - `holt init` + `holt chat` with any brain (Claude Code, Codex, Gemini, or a direct API key), and mid-conversation brain switching that keeps context.
12
+ > - Per-folder memory with semantic recall and distilled facts, ambient memory for Claude Code (`holt hook`), and opt-in memory shared across folders (`holt memory global`).
13
+ > - A self-maintaining knowledge wiki (`holt wiki`) and an interactive knowledge graph (`holt graph`) that can also ingest your code and docs into communities.
14
+ > - Run a task once, on a schedule, or as a named routine (`holt run` / `schedule` / `routine`), and reach it from your phone over Telegram.
15
+ > - Draft in your own voice with anti-AI checks (`holt write`), and size your setup with `holt doctor`.
16
+ > - Ten built-in skills plus a community registry (`holt skill search`), and an MCP server so Claude Code, Cursor, or Codex can read this folder's memory.
10
17
 
11
18
  ---
12
19
 
@@ -213,11 +220,13 @@ holt skill list list installed skills
213
220
  holt skill show <name> print a skill
214
221
  holt skill create <name> [--global] scaffold a new skill
215
222
  holt skill search <query> find skills in the registry
216
- holt skill add <src|name> [--global] install from a git URL, path, or registry name
223
+ holt skill add <src|name> [--global] install from a git URL, git-url#subdir, path, or registry name
217
224
  holt skill publish [<name>] prepare a skill for the registry (prints a PR entry)
218
225
  holt skill remove <name> delete a skill
219
226
  ```
220
227
 
228
+ A source may carry a `#<subdir>` suffix (split on the first `#`) that points at one skill folder inside a repo or path: `holt skill add https://github.com/holt-os/registry#skills/pm-prd`. The subdir is a path, relative to the source root, that contains a `SKILL.md`. This lets a single repo hold many skills (one monorepo), which is exactly how the community registry is laid out. The subdir is validated to stay inside the source (absolute paths and `..` traversal are refused), since a registry entry is remote-controlled. Without a `#`, behaviour is unchanged: Holt looks for a `SKILL.md` at the source root or in exactly one immediate subfolder.
229
+
221
230
  In chat, run one with `/skill <name> [your input]`. Available skills are also listed to the brain each turn, so it knows what it can be asked to follow.
222
231
 
223
232
  ### Built-in skills
@@ -256,7 +265,7 @@ The index is a JSON document:
256
265
  }
257
266
  ```
258
267
 
259
- `source` is anything `holt skill add` already accepts: a git URL (its `SKILL.md` may sit in one subfolder) or a local path. `tags` is optional. Only `name` and `source` are required per entry; malformed rows are skipped rather than failing the whole index.
268
+ `source` is anything `holt skill add` already accepts: a git URL (its `SKILL.md` may sit in one subfolder), a local path, or either of those with a `#<subdir>` suffix pointing at one skill folder inside the repo. That last form lets one repo hold many skills, so the community registry can be a single monorepo (e.g. `"source": "https://github.com/holt-os/registry#skills/pm-prd"`). `tags` is optional. Only `name` and `source` are required per entry; malformed rows are skipped rather than failing the whole index.
260
269
 
261
270
  - **Find:** `holt skill search <query>` fetches the index and lists skills whose name, description, or tags match (case-insensitive substring, name matches first). An empty query lists everything. It prints name, author, description, and source.
262
271
  - **Install by name:** `holt skill add <name>` resolves the name in the registry to its `source`, then installs through the exact same clone/copy path as `holt skill add <url|path>`. A git URL or an existing local path is still installed directly, unchanged.
@@ -317,6 +326,8 @@ Install **merges** into any existing `hooks` config, never clobbers unrelated se
317
326
 
318
327
  **Trusted-folder guard (important).** The two runtime hooks (`holt hook inject` and `holt hook capture`, invoked by Claude Code, not you) no-op **silently** unless the folder is a trusted Holt workspace with an existing `.holt/memory`. So Holt never injects your private notes into an unrelated project, and never creates memory in a folder you never set up. To make a folder ambient, run `holt init` (or `holt chat`) there once so it becomes trusted and gets its `.holt/memory`. The inject hook keeps stdout clean (only the context block is printed, since Claude Code injects it verbatim); all diagnostics go to stderr, and both hooks always exit 0 so a memory step never blocks or slows your prompt.
319
328
 
329
+ **Seeing what the hooks did (`~/.holt/hooks.log`).** Since the hooks stay silent, every `holt hook capture` run appends **one** line to `~/.holt/hooks.log` with the outcome or the exact skip reason (untrusted folder, no config, `extractFacts` off, no usable brain, missing or empty transcript, too few exchanges, or `saved N facts`), preceded by a line of the raw hook fields it received. Check this file first if capture seems to save nothing. `inject` uses the same log but stays quiet by default; set `HOLT_HOOK_DEBUG=1` to make it log too.
330
+
320
331
  To uninstall, run `holt hook remove` (add `--project` if you installed with `--project`).
321
332
 
322
333
  ## Output format
@@ -433,6 +444,8 @@ holt voice clear # remove it
433
444
 
434
445
  The interview asks you to pick a **depth** up front: `quick` (a few questions) or `detailed` (more). Questions are about **writing and communication style only**: tone, sentence length, first vs third person, emoji habits, words you love or ban, who you write for, and what you want to sound like and not sound like.
435
446
 
447
+ Changed your mind at the first prompt? Answer `n`, `skip`, `cancel`, or just press enter to back out cleanly. Nothing is saved and no profile is built; run `holt voice` again whenever you like. Inside the question list, an empty answer skips only that one question.
448
+
436
449
  Privacy is a hard rule. The interview never asks for personal details (no name, job, location, or life story). If you happen to mention something personal in an answer, Holt stores only what you typed and never asks follow ups.
437
450
 
438
451
  You can also feed it real writing. `holt voice add <file>` reads a file; inside the interview you can paste a sample instead. Samples are stored as a hash and length by default; an excerpt is kept only if you say yes, so Holt can match your rhythm directly.
package/dist/cli.js CHANGED
@@ -27,6 +27,70 @@ function bar(fraction, width = 12) {
27
27
  const filled = Math.round(f * width);
28
28
  return c.green("\u2588".repeat(filled)) + c.dim("\u2591".repeat(width - filled));
29
29
  }
30
+ var CSI = "\x1B[";
31
+ function createStatusBar() {
32
+ const out = process.stdout;
33
+ if (!out.isTTY) {
34
+ return { active: false, set: () => {
35
+ }, detach: () => {
36
+ } };
37
+ }
38
+ let text = "";
39
+ let detached = false;
40
+ const realWrite = out.write.bind(out);
41
+ const rows = () => out.rows || 24;
42
+ const paint = () => {
43
+ if (detached) return;
44
+ const r = rows();
45
+ realWrite(
46
+ `\x1B7${CSI}${r};1H${CSI}2K` + // clear the line
47
+ text + `\x1B8`
48
+ // restore cursor
49
+ );
50
+ };
51
+ const reserve = () => {
52
+ const r = rows();
53
+ realWrite(
54
+ `${CSI}1;${Math.max(1, r - 1)}r${CSI}${Math.max(1, r - 1)};1H`
55
+ // move cursor into the region
56
+ );
57
+ paint();
58
+ };
59
+ const onResize = () => {
60
+ if (!detached) reserve();
61
+ };
62
+ const detach = () => {
63
+ if (detached) return;
64
+ detached = true;
65
+ out.removeListener("resize", onResize);
66
+ process.removeListener("exit", detach);
67
+ process.removeListener("SIGINT", onSignal);
68
+ process.removeListener("SIGTERM", onSignal);
69
+ const r = rows();
70
+ realWrite(
71
+ `${CSI}r\x1B7${CSI}${r};1H${CSI}2K\x1B8${CSI}?25h`
72
+ // ensure cursor visible
73
+ );
74
+ };
75
+ const onSignal = (sig) => {
76
+ detach();
77
+ process.removeListener(sig, onSignal);
78
+ process.kill(process.pid, sig);
79
+ };
80
+ reserve();
81
+ out.on("resize", onResize);
82
+ process.once("exit", detach);
83
+ process.once("SIGINT", onSignal);
84
+ process.once("SIGTERM", onSignal);
85
+ return {
86
+ active: true,
87
+ set(next) {
88
+ text = next;
89
+ paint();
90
+ },
91
+ detach
92
+ };
93
+ }
30
94
  function createReader() {
31
95
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
32
96
  const buffer = [];
@@ -225,6 +289,7 @@ function renderPrompt(history, message, memory = []) {
225
289
  const parts = [];
226
290
  if (recent.length || memory.length) {
227
291
  parts.push(
292
+ "You are the user's assistant, running through Holt, a tool that gives you durable memory of this user across sessions. Notes recalled from their past sessions may appear below; use them naturally and answer as their assistant.",
228
293
  "You are continuing an ongoing conversation. Use the context below and reply only as the assistant to the final User message."
229
294
  );
230
295
  }
@@ -1756,10 +1821,19 @@ var QUESTIONS = [
1756
1821
  { key: "avoid", q: "What do you NOT want to sound like? (e.g. corporate, salesy, robotic)", depth: "detailed" },
1757
1822
  { key: "signature", q: "Any signature moves? (openings you like, how you end, humor, analogies)", depth: "detailed" }
1758
1823
  ];
1824
+ function isSkip(a) {
1825
+ if (a === null) return true;
1826
+ const s = a.trim().toLowerCase();
1827
+ return s === "" || s === "n" || s === "no" || s === "skip" || s === "cancel" || s === "q" || s === "quit";
1828
+ }
1829
+ function skipNotice() {
1830
+ console.log(c.dim("\n Skipped. Run ") + c.accent("holt voice") + c.dim(" anytime.\n"));
1831
+ }
1759
1832
  async function askDepth(ask) {
1760
1833
  const a = await ask('Depth? "quick" (a few questions) or "detailed" (more): [quick] ');
1761
1834
  if (a === null) return null;
1762
1835
  const s = a.trim().toLowerCase();
1836
+ if (s === "n" || s === "no" || s === "skip" || s === "cancel" || s === "q" || s === "quit") return null;
1763
1837
  return s === "detailed" || s === "d" ? "detailed" : "quick";
1764
1838
  }
1765
1839
  async function runQuestions(ask, depth) {
@@ -1887,13 +1961,21 @@ async function voiceInterview() {
1887
1961
  const v = existing ?? emptyVoice();
1888
1962
  console.log("\n" + c.accent("Teach Holt your writing voice"));
1889
1963
  console.log(c.dim(" Style only. No personal questions. You can do the interview, share samples, or both.\n"));
1890
- const mode = (await ask("Do the [i]nterview, add [s]amples, or [b]oth? [i] ") ?? "").trim().toLowerCase();
1891
- const wantInterview = mode === "" || mode === "i" || mode === "b" || mode === "interview" || mode === "both";
1964
+ const modeRaw = await ask("Do the [i]nterview, add [s]amples, or [b]oth? [i] ");
1965
+ if (isSkip(modeRaw)) {
1966
+ close();
1967
+ skipNotice();
1968
+ return;
1969
+ }
1970
+ const mode = (modeRaw ?? "").trim().toLowerCase();
1971
+ const wantInterview = mode === "i" || mode === "b" || mode === "interview" || mode === "both";
1892
1972
  const wantSamples = mode === "s" || mode === "b" || mode === "samples" || mode === "both";
1893
- if (wantInterview) {
1973
+ const wantInterviewOrDefault = wantInterview || !wantSamples;
1974
+ if (wantInterviewOrDefault) {
1894
1975
  const depth = await askDepth(ask);
1895
1976
  if (depth === null) {
1896
1977
  close();
1978
+ skipNotice();
1897
1979
  return;
1898
1980
  }
1899
1981
  v.depth = depth;
@@ -1917,6 +1999,11 @@ async function voiceInterview() {
1917
1999
  }
1918
2000
  }
1919
2001
  close();
2002
+ const gotAnswers = v.answers.some((a) => a.answer.trim());
2003
+ if (!gotAnswers && v.samples.length === 0) {
2004
+ skipNotice();
2005
+ return;
2006
+ }
1920
2007
  saveVoice(v);
1921
2008
  console.log(c.dim("\n Building your style profile from a brain..."));
1922
2009
  const synthed = await synthesizeVoice(v);
@@ -2218,7 +2305,10 @@ async function extractAndSaveFacts(active, cfg, history, session) {
2218
2305
  try {
2219
2306
  if (Math.floor(history.length / 2) < MIN_EXCHANGES_FOR_EXTRACTION) return 0;
2220
2307
  const prompt = buildExtractionPrompt(history);
2221
- const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt) : await runApiBrain(active.brain, prompt);
2308
+ const res = active.kind === "cli" ? await runBrain(
2309
+ active.commandOverride ? { ...cfg.brains[active.id], command: active.commandOverride } : cfg.brains[active.id],
2310
+ prompt
2311
+ ) : await runApiBrain(active.brain, prompt);
2222
2312
  if (!res.ok) return 0;
2223
2313
  const facts = parseFacts(res.text);
2224
2314
  let saved = 0;
@@ -2896,9 +2986,8 @@ function saveReply(reply, format, name) {
2896
2986
  function statusLine(brainLabel, history) {
2897
2987
  const live = Math.min(history.length, MAX_REPLAY_TURNS);
2898
2988
  const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
2899
- const pct = Math.round(frac * 100);
2900
2989
  const mem = memStats().turns;
2901
- return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
2990
+ return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` recent ${live}/${MAX_REPLAY_TURNS} \xB7 ${mem} in memory`);
2902
2991
  }
2903
2992
  function help() {
2904
2993
  console.log(c.dim([
@@ -2954,6 +3043,12 @@ async function chat() {
2954
3043
  const session = newSessionId();
2955
3044
  const history = [];
2956
3045
  let lastReply = "";
3046
+ const sb = createStatusBar();
3047
+ const showStatus = () => {
3048
+ const line = statusLine(active.label, history);
3049
+ if (sb.active) sb.set(line);
3050
+ else console.log(line + "\n");
3051
+ };
2957
3052
  const embedOk = await embeddingsAvailable();
2958
3053
  const stats = memStats();
2959
3054
  console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
@@ -2964,7 +3059,7 @@ async function chat() {
2964
3059
  console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
2965
3060
  }
2966
3061
  console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit"));
2967
- console.log(statusLine(active.label, history) + "\n");
3062
+ showStatus();
2968
3063
  while (true) {
2969
3064
  const raw = await ask(c.accent("\u203A "));
2970
3065
  if (raw === null) break;
@@ -3107,11 +3202,12 @@ async function chat() {
3107
3202
  const now = Date.now();
3108
3203
  appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
3109
3204
  appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
3110
- console.log(statusLine(active.label, history) + "\n");
3205
+ showStatus();
3111
3206
  } else {
3112
3207
  console.log(c.red("\n " + res.text + "\n"));
3113
3208
  }
3114
3209
  }
3210
+ sb.detach();
3115
3211
  if (cfg.memory?.extractFacts !== false) {
3116
3212
  try {
3117
3213
  const arg = active.kind === "cli" ? { kind: "cli", id: active.id } : { kind: "api", brain: active.brain };
@@ -3702,7 +3798,7 @@ import {
3702
3798
  cpSync,
3703
3799
  mkdtempSync
3704
3800
  } from "fs";
3705
- import { join as join12, resolve as resolve3 } from "path";
3801
+ import { join as join12, resolve as resolve3, sep, isAbsolute as isAbsolute3 } from "path";
3706
3802
  import { tmpdir } from "os";
3707
3803
  import { spawnSync as spawnSync2 } from "child_process";
3708
3804
 
@@ -3866,7 +3962,7 @@ function usage() {
3866
3962
  " holt skill show <name> print a skill",
3867
3963
  " holt skill create <name> [--global] scaffold a new skill",
3868
3964
  " holt skill search <query> find skills in the registry",
3869
- " holt skill add <src|name> [--global] install from a git URL, path, or registry name",
3965
+ " holt skill add <src|name> [--global] install from a git URL, git-url#subdir, path, or registry name",
3870
3966
  " holt skill publish [<name>] prepare a skill for the registry (prints a PR entry)",
3871
3967
  " holt skill remove <name> delete a skill",
3872
3968
  "",
@@ -3902,6 +3998,20 @@ var SKILL_TEMPLATE = (name) => [
3902
3998
  function isGitUrl(s) {
3903
3999
  return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
3904
4000
  }
4001
+ function splitSubpath(source) {
4002
+ const i = source.indexOf("#");
4003
+ if (i === -1) return { base: source, subpath: "" };
4004
+ return { base: source.slice(0, i), subpath: source.slice(i + 1) };
4005
+ }
4006
+ function resolveSubpathDir(fetched, subpath) {
4007
+ if (isAbsolute3(subpath)) return null;
4008
+ const segments = subpath.split(/[\\/]/);
4009
+ if (segments.some((s) => s === "..")) return null;
4010
+ const target = resolve3(fetched, subpath);
4011
+ const root = resolve3(fetched);
4012
+ if (target !== root && !target.startsWith(root + sep)) return null;
4013
+ return target;
4014
+ }
3905
4015
  function findSkillDir(root) {
3906
4016
  if (existsSync12(join12(root, "SKILL.md"))) return root;
3907
4017
  let subs = [];
@@ -3986,14 +4096,15 @@ function cmdCreate(name, global) {
3986
4096
  console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
3987
4097
  }
3988
4098
  function installFromSource(source, scope) {
4099
+ const { base, subpath } = splitSubpath(source);
3989
4100
  let fetched = "";
3990
4101
  let tempDir = "";
3991
4102
  try {
3992
- if (isGitUrl(source)) {
4103
+ if (isGitUrl(base)) {
3993
4104
  tempDir = mkdtempSync(join12(tmpdir(), "holt-skill-"));
3994
4105
  console.log(c.dim(`
3995
- Cloning ${source} ...`));
3996
- const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
4106
+ Cloning ${base} ...`));
4107
+ const res = spawnSync2("git", ["clone", "--depth", "1", base, tempDir], { stdio: "ignore" });
3997
4108
  if (res.status !== 0) {
3998
4109
  console.error(c.red(" Clone failed. Check the URL and that git is installed.\n"));
3999
4110
  process.exitCode = 1;
@@ -4001,7 +4112,7 @@ function installFromSource(source, scope) {
4001
4112
  }
4002
4113
  fetched = tempDir;
4003
4114
  } else {
4004
- fetched = resolve3(source);
4115
+ fetched = resolve3(base);
4005
4116
  if (!existsSync12(fetched)) {
4006
4117
  console.error(c.red(`
4007
4118
  No such path: ${fetched}
@@ -4010,9 +4121,31 @@ function installFromSource(source, scope) {
4010
4121
  return false;
4011
4122
  }
4012
4123
  }
4013
- const skillDir = findSkillDir(fetched);
4124
+ let searchRoot = fetched;
4125
+ if (subpath) {
4126
+ const target = resolveSubpathDir(fetched, subpath);
4127
+ if (!target) {
4128
+ console.error(c.red(`
4129
+ Refusing unsafe subpath "${subpath}": it must stay inside the source (no absolute or "..").
4130
+ `));
4131
+ process.exitCode = 1;
4132
+ return false;
4133
+ }
4134
+ if (!existsSync12(target) || !statSync5(target).isDirectory()) {
4135
+ console.error(c.red(`
4136
+ Subpath "${subpath}" does not exist in the source.
4137
+ `));
4138
+ process.exitCode = 1;
4139
+ return false;
4140
+ }
4141
+ searchRoot = target;
4142
+ }
4143
+ const skillDir = findSkillDir(searchRoot);
4014
4144
  if (!skillDir) {
4015
- console.error(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
4145
+ const where = subpath ? `subpath "${subpath}"` : "the source root or in a single subfolder";
4146
+ console.error(c.red(`
4147
+ Could not find a SKILL.md at ${where}.
4148
+ `));
4016
4149
  process.exitCode = 1;
4017
4150
  return false;
4018
4151
  }
@@ -4056,11 +4189,12 @@ function installFromSource(source, scope) {
4056
4189
  }
4057
4190
  async function cmdAdd(source, global, refresh) {
4058
4191
  if (!source) {
4059
- console.log(c.dim("\n Usage: holt skill add <git-url|path|name> [--global]\n"));
4192
+ console.log(c.dim("\n Usage: holt skill add <git-url|git-url#subdir|path|name> [--global]\n"));
4060
4193
  return;
4061
4194
  }
4062
4195
  const scope = global ? "global" : "workspace";
4063
- if (isGitUrl(source) || existsSync12(resolve3(source))) {
4196
+ const { base } = splitSubpath(source);
4197
+ if (isGitUrl(base) || existsSync12(resolve3(base))) {
4064
4198
  installFromSource(source, scope);
4065
4199
  return;
4066
4200
  }
@@ -5061,7 +5195,7 @@ function renderGraphHtml(graph2, meta) {
5061
5195
 
5062
5196
  // src/ingest.ts
5063
5197
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, statSync as statSync6 } from "fs";
5064
- import { join as join13, relative, sep, extname, dirname as dirname3, basename as basename2 } from "path";
5198
+ import { join as join13, relative, sep as sep2, extname, dirname as dirname3, basename as basename2 } from "path";
5065
5199
  var CODE_EXT = /* @__PURE__ */ new Set([
5066
5200
  "js",
5067
5201
  "jsx",
@@ -5112,7 +5246,7 @@ var MAX_FILES = 2e3;
5112
5246
  var MAX_FILE_BYTES = 400 * 1024;
5113
5247
  var MAX_TOTAL_BYTES = 64 * 1024 * 1024;
5114
5248
  function toPosix(p) {
5115
- return sep === "/" ? p : p.split(sep).join("/");
5249
+ return sep2 === "/" ? p : p.split(sep2).join("/");
5116
5250
  }
5117
5251
  function ingestFolder(root, want = { code: true, docs: true }) {
5118
5252
  const files = [];
@@ -5815,7 +5949,7 @@ function holtTools() {
5815
5949
  }
5816
5950
 
5817
5951
  // src/version.ts
5818
- var VERSION = "0.11.0";
5952
+ var VERSION = "0.12.1";
5819
5953
 
5820
5954
  // src/commands/mcp.ts
5821
5955
  function printSetup() {
@@ -5861,7 +5995,8 @@ async function mcp(sub, rest = []) {
5861
5995
  }
5862
5996
 
5863
5997
  // src/commands/hook.ts
5864
- import { readFileSync as readFileSync15, writeFileSync as writeFileSync12, existsSync as existsSync14, mkdirSync as mkdirSync11, copyFileSync, realpathSync as realpathSync2 } from "fs";
5998
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync12, existsSync as existsSync14, mkdirSync as mkdirSync11, copyFileSync, realpathSync as realpathSync2, appendFileSync as appendFileSync2, statSync as statSync7 } from "fs";
5999
+ import { spawnSync as spawnSync3 } from "child_process";
5865
6000
  import { join as join15, dirname as dirname5 } from "path";
5866
6001
  import { homedir as homedir3 } from "os";
5867
6002
  var EVENT = {
@@ -6076,25 +6211,87 @@ function guardTrusted(real) {
6076
6211
  if (!existsSync14(memDir())) return false;
6077
6212
  return true;
6078
6213
  }
6214
+ function hookLog(msg) {
6215
+ try {
6216
+ mkdirSync11(GLOBAL_DIR, { recursive: true });
6217
+ appendFileSync2(join15(GLOBAL_DIR, "hooks.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
6218
+ `, "utf8");
6219
+ } catch {
6220
+ }
6221
+ }
6222
+ function fileBytes(path) {
6223
+ if (!path) return 0;
6224
+ try {
6225
+ return statSync7(path).size;
6226
+ } catch {
6227
+ return 0;
6228
+ }
6229
+ }
6079
6230
  async function inject() {
6231
+ const debug = !!process.env.HOLT_HOOK_DEBUG;
6232
+ const dlog = (m) => {
6233
+ if (debug) hookLog(m);
6234
+ };
6080
6235
  try {
6081
6236
  const raw = await readStdin(2e3);
6082
6237
  const data = parseHookInput(raw);
6238
+ dlog(
6239
+ `inject cwd=${data.cwd ?? "(none)"} event=${data.hook_event_name ?? "UserPromptSubmit"} hasCwd=${!!data.cwd} promptLen=${(data.prompt || "").trim().length}`
6240
+ );
6083
6241
  const prompt = (data.prompt || "").trim();
6084
- if (prompt.length < 3) return;
6242
+ if (prompt.length < 3) {
6243
+ dlog("inject -> prompt too short");
6244
+ return;
6245
+ }
6085
6246
  const real = enterFolder(data.cwd);
6086
- if (!real) return;
6087
- if (!guardTrusted(real)) return;
6247
+ if (!real) {
6248
+ dlog("inject -> no cwd");
6249
+ return;
6250
+ }
6251
+ if (!guardTrusted(real)) {
6252
+ dlog(`inject -> untrusted or no .holt/memory (real=${real})`);
6253
+ return;
6254
+ }
6088
6255
  const session = data.session_id || newSessionId();
6089
6256
  const hits = await recall(prompt, session, 4);
6090
- if (!hits.length) return;
6257
+ if (!hits.length) {
6258
+ dlog("inject -> recalled 0 notes");
6259
+ return;
6260
+ }
6091
6261
  const lines = hits.map((h) => `- ${h.turn.content.replace(/\s+/g, " ").trim().slice(0, 500)}`);
6092
6262
  process.stdout.write(
6093
- ["[Holt memory - relevant notes from earlier sessions in this folder]", ...lines, ""].join("\n")
6263
+ [
6264
+ "Background on this user, recalled from earlier sessions (use it directly, do not mention looking it up):",
6265
+ ...lines,
6266
+ ""
6267
+ ].join("\n")
6094
6268
  );
6269
+ dlog(`inject -> injected ${hits.length} note(s)`);
6095
6270
  } catch (e) {
6096
6271
  process.stderr.write("holt hook inject: " + (e instanceof Error ? e.message : String(e)) + "\n");
6272
+ dlog("inject -> error: " + (e instanceof Error ? e.message : String(e)));
6273
+ }
6274
+ }
6275
+ function countLines(raw) {
6276
+ let n = 0;
6277
+ for (const line of raw.split("\n")) if (line.trim()) n++;
6278
+ return n;
6279
+ }
6280
+ function textFromContent(content) {
6281
+ if (typeof content === "string") return content.trim();
6282
+ if (!Array.isArray(content)) return "";
6283
+ const parts = [];
6284
+ for (const b of content) {
6285
+ if (typeof b === "string") {
6286
+ parts.push(b);
6287
+ continue;
6288
+ }
6289
+ if (!b || typeof b !== "object") continue;
6290
+ const blk = b;
6291
+ if (blk.type === "tool_use" || blk.type === "tool_result" || blk.type === "thinking") continue;
6292
+ if (typeof blk.text === "string") parts.push(blk.text);
6097
6293
  }
6294
+ return parts.join(" ").trim();
6098
6295
  }
6099
6296
  function readTranscript(path) {
6100
6297
  const out = [];
@@ -6112,56 +6309,133 @@ function readTranscript(path) {
6112
6309
  } catch {
6113
6310
  continue;
6114
6311
  }
6312
+ if (!rec || typeof rec !== "object") continue;
6115
6313
  const d = rec;
6116
- if (d.type !== "user" && d.type !== "assistant") continue;
6117
- const m = d.message;
6118
- if (!m || m.role !== "user" && m.role !== "assistant") continue;
6119
- let text = "";
6120
- if (typeof m.content === "string") {
6121
- text = m.content;
6122
- } else if (Array.isArray(m.content)) {
6123
- text = m.content.map((b) => {
6124
- if (typeof b === "string") return b;
6125
- if (b && typeof b.text === "string") return b.text;
6126
- return "";
6127
- }).join(" ").trim();
6128
- }
6314
+ const topType = d.type === "user" || d.type === "assistant" ? d.type : void 0;
6315
+ const m = d.message && typeof d.message === "object" ? d.message : void 0;
6316
+ const msgRole = m && (m.role === "user" || m.role === "assistant") ? m.role : void 0;
6317
+ const role = msgRole ?? topType;
6318
+ if (role !== "user" && role !== "assistant") continue;
6319
+ let text = m ? textFromContent(m.content) : "";
6320
+ if (!text) text = textFromContent(d.content);
6129
6321
  text = text.trim();
6130
6322
  if (text.length < 3) continue;
6131
- out.push({ role: m.role, content: text });
6323
+ out.push({ role, content: text });
6132
6324
  }
6133
6325
  return out;
6134
6326
  }
6327
+ function resolveCommandPath(command) {
6328
+ if (command.startsWith("/") && existsSync14(command)) return command;
6329
+ try {
6330
+ const shell = process.env.SHELL || "/bin/sh";
6331
+ const res = spawnSync3(shell, ["-lc", "command -v " + command], { encoding: "utf8" });
6332
+ if (res.status === 0) {
6333
+ const p = (res.stdout || "").trim().split("\n")[0]?.trim();
6334
+ if (p && p.startsWith("/") && existsSync14(p)) return p;
6335
+ }
6336
+ } catch {
6337
+ }
6338
+ const home = homedir3();
6339
+ const dirs = [
6340
+ join15(home, ".local", "bin"),
6341
+ "/opt/homebrew/bin",
6342
+ "/usr/local/bin",
6343
+ join15(home, ".npm-global", "bin"),
6344
+ "/usr/bin",
6345
+ "/bin"
6346
+ ];
6347
+ for (const dir of dirs) {
6348
+ const candidate = join15(dir, command);
6349
+ if (existsSync14(candidate)) return candidate;
6350
+ }
6351
+ return null;
6352
+ }
6135
6353
  function resolveExtractionBrain(cfg) {
6136
6354
  const id = cfg.defaultBrain;
6137
- if (!id) return null;
6355
+ const diag = { defaultBrain: id ?? "(none)", command: "(none)", whichFound: false, resolvedPath: null };
6356
+ if (!id) return { brain: null, diag };
6138
6357
  if (BRAIN_IDS.includes(id)) {
6139
6358
  const bid = id;
6140
- if (!cfg.brains[bid].enabled) return null;
6141
- if (!isInstalled(cfg.brains[bid].command)) return null;
6142
- return { kind: "cli", id: bid };
6359
+ const command = cfg.brains[bid].command;
6360
+ diag.command = command;
6361
+ if (!cfg.brains[bid].enabled) return { brain: null, diag };
6362
+ const whichFound = isInstalled(command);
6363
+ diag.whichFound = whichFound;
6364
+ if (whichFound) {
6365
+ return { brain: { kind: "cli", id: bid }, diag };
6366
+ }
6367
+ const resolved = resolveCommandPath(command);
6368
+ diag.resolvedPath = resolved;
6369
+ if (resolved) {
6370
+ return { brain: { kind: "cli", id: bid, commandOverride: resolved }, diag };
6371
+ }
6372
+ return { brain: null, diag };
6143
6373
  }
6144
6374
  const api = findApiBrain(cfg, id);
6145
- if (api && resolveApiKey(api)) return { kind: "api", brain: api };
6146
- return null;
6375
+ if (api && resolveApiKey(api)) return { brain: { kind: "api", brain: api }, diag };
6376
+ return { brain: null, diag };
6147
6377
  }
6148
6378
  async function capture() {
6379
+ const cwdForLog = process.cwd();
6149
6380
  try {
6150
6381
  const raw = await readStdin(3e3);
6151
6382
  const data = parseHookInput(raw);
6383
+ hookLog(
6384
+ `capture-fields event=${data.hook_event_name ?? "Stop"} hasCwd=${!!data.cwd} hasTranscriptPath=${!!data.transcript_path} transcriptBytes=${fileBytes(data.transcript_path)} stdinBytes=${raw.length}`
6385
+ );
6152
6386
  const real = enterFolder(data.cwd);
6153
- if (!real) return;
6154
- if (!guardTrusted(real)) return;
6387
+ if (!real) {
6388
+ hookLog(`capture cwd=${data.cwd ?? cwdForLog} event=Stop -> no cwd`);
6389
+ return;
6390
+ }
6391
+ if (!guardTrusted(real)) {
6392
+ hookLog(`capture cwd=${real} event=Stop -> untrusted or no .holt/memory (real=${real})`);
6393
+ return;
6394
+ }
6155
6395
  const cfg = loadConfig();
6156
- if (!cfg) return;
6157
- if (cfg.memory?.extractFacts === false) return;
6158
- const brain = resolveExtractionBrain(cfg);
6159
- if (!brain) return;
6160
- if (!data.transcript_path) return;
6396
+ if (!cfg) {
6397
+ hookLog(`capture cwd=${real} event=Stop -> no config`);
6398
+ return;
6399
+ }
6400
+ if (cfg.memory?.extractFacts === false) {
6401
+ hookLog(`capture cwd=${real} event=Stop -> extractFacts off`);
6402
+ return;
6403
+ }
6404
+ const { brain, diag } = resolveExtractionBrain(cfg);
6405
+ if (!brain) {
6406
+ hookLog(
6407
+ `capture cwd=${real} event=Stop -> no usable brain (defaultBrain=${diag.defaultBrain} command=${diag.command} whichFound=${diag.whichFound} resolvedPath=${diag.resolvedPath ?? "none"})`
6408
+ );
6409
+ return;
6410
+ }
6411
+ if (brain.kind === "cli" && brain.commandOverride) {
6412
+ hookLog(`capture cwd=${real} event=Stop -> brain resolved via absolute path (resolvedPath=${brain.commandOverride})`);
6413
+ }
6414
+ if (!data.transcript_path) {
6415
+ hookLog(`capture cwd=${real} event=Stop -> no transcript_path`);
6416
+ return;
6417
+ }
6161
6418
  const history = readTranscript(data.transcript_path);
6162
- if (!history.length) return;
6419
+ if (!history.length) {
6420
+ let bytes = 0;
6421
+ let lines = 0;
6422
+ try {
6423
+ const rawT = readFileSync15(data.transcript_path, "utf8");
6424
+ bytes = Buffer.byteLength(rawT, "utf8");
6425
+ lines = countLines(rawT);
6426
+ } catch {
6427
+ }
6428
+ hookLog(`capture cwd=${real} event=Stop -> transcript parsed 0 turns (path=${data.transcript_path} bytes=${bytes} lines=${lines})`);
6429
+ return;
6430
+ }
6431
+ const exchanges = Math.floor(history.length / 2);
6432
+ if (exchanges < MIN_EXCHANGES_FOR_EXTRACTION) {
6433
+ hookLog(`capture cwd=${real} event=Stop -> only ${exchanges} exchange(s) (<${MIN_EXCHANGES_FOR_EXTRACTION}, skipped)`);
6434
+ return;
6435
+ }
6163
6436
  const session = "hook-" + (data.session_id || newSessionId());
6164
6437
  const n = await extractAndSaveFacts(brain, cfg, history, session);
6438
+ hookLog(`capture cwd=${real} event=Stop -> saved ${n} fact${n === 1 ? "" : "s"}`);
6165
6439
  if (n > 0) process.stderr.write(`holt hook capture: saved ${n} fact${n === 1 ? "" : "s"} to ${memDir()}
6166
6440
  `);
6167
6441
  if (cfg.wiki?.autoSync) {
@@ -6174,7 +6448,9 @@ async function capture() {
6174
6448
  }
6175
6449
  }
6176
6450
  } catch (e) {
6177
- process.stderr.write("holt hook capture: " + (e instanceof Error ? e.message : String(e)) + "\n");
6451
+ const msg = e instanceof Error ? e.message : String(e);
6452
+ process.stderr.write("holt hook capture: " + msg + "\n");
6453
+ hookLog(`capture cwd=${cwdForLog} event=Stop -> error: ${msg}`);
6178
6454
  }
6179
6455
  }
6180
6456
  var USAGE2 = [
@@ -6332,7 +6608,7 @@ async function run(args) {
6332
6608
  }
6333
6609
 
6334
6610
  // src/commands/schedule.ts
6335
- import { spawnSync as spawnSync4 } from "child_process";
6611
+ import { spawnSync as spawnSync5 } from "child_process";
6336
6612
  import { writeFileSync as writeFileSync15, mkdirSync as mkdirSync13, rmSync as rmSync6, existsSync as existsSync15 } from "fs";
6337
6613
  import { dirname as dirname6 } from "path";
6338
6614
 
@@ -6341,7 +6617,7 @@ import { homedir as homedir4 } from "os";
6341
6617
  import { join as join16 } from "path";
6342
6618
  import { readFileSync as readFileSync16, writeFileSync as writeFileSync14, mkdirSync as mkdirSync12 } from "fs";
6343
6619
  import { randomUUID as randomUUID4 } from "crypto";
6344
- import { spawnSync as spawnSync3 } from "child_process";
6620
+ import { spawnSync as spawnSync4 } from "child_process";
6345
6621
  function schedulesPath() {
6346
6622
  return join16(GLOBAL_DIR, "schedules.json");
6347
6623
  }
@@ -6397,7 +6673,7 @@ function shQuote(s) {
6397
6673
  function resolveHoltPath() {
6398
6674
  try {
6399
6675
  const finder = process.platform === "win32" ? "where" : "which";
6400
- const res = spawnSync3(finder, ["holt"], { encoding: "utf8" });
6676
+ const res = spawnSync4(finder, ["holt"], { encoding: "utf8" });
6401
6677
  if (res.status === 0 && typeof res.stdout === "string") {
6402
6678
  const first = res.stdout.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
6403
6679
  if (first) return first;
@@ -6500,7 +6776,7 @@ function installDarwin(job, holtPath) {
6500
6776
  const path = plistPath(job.id);
6501
6777
  mkdirSync13(dirname6(path), { recursive: true });
6502
6778
  writeFileSync15(path, buildLaunchdPlist(job, holtPath), "utf8");
6503
- const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
6779
+ const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
6504
6780
  if (res.status === 0) {
6505
6781
  console.log(c.green(" Loaded into launchd."));
6506
6782
  } else {
@@ -6511,11 +6787,11 @@ function installDarwin(job, holtPath) {
6511
6787
  console.log(c.dim(` plist: ${path}`));
6512
6788
  }
6513
6789
  function currentCrontab() {
6514
- const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
6790
+ const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
6515
6791
  return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6516
6792
  }
6517
6793
  function writeCrontab(body) {
6518
- const res = spawnSync4("crontab", ["-"], { input: body, encoding: "utf8" });
6794
+ const res = spawnSync5("crontab", ["-"], { input: body, encoding: "utf8" });
6519
6795
  return res.status === 0;
6520
6796
  }
6521
6797
  function installLinux(job, holtPath) {
@@ -6588,7 +6864,7 @@ function listCmd2() {
6588
6864
  function removeDarwin(id) {
6589
6865
  const path = plistPath(id);
6590
6866
  if (existsSync15(path)) {
6591
- spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
6867
+ spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
6592
6868
  try {
6593
6869
  rmSync6(path);
6594
6870
  } catch {
@@ -6596,10 +6872,10 @@ function removeDarwin(id) {
6596
6872
  }
6597
6873
  }
6598
6874
  function removeLinux(id) {
6599
- const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
6875
+ const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
6600
6876
  const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6601
6877
  const next = stripCronLines(body, id);
6602
- spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
6878
+ spawnSync5("crontab", ["-"], { input: next, encoding: "utf8" });
6603
6879
  }
6604
6880
  function removeCmd(rest) {
6605
6881
  const id = rest[0];
@@ -6649,7 +6925,7 @@ async function schedule(sub, rest = []) {
6649
6925
  }
6650
6926
 
6651
6927
  // src/commands/routine.ts
6652
- import { spawnSync as spawnSync5 } from "child_process";
6928
+ import { spawnSync as spawnSync6 } from "child_process";
6653
6929
  import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync15, rmSync as rmSync7, existsSync as existsSync16 } from "fs";
6654
6930
  import { dirname as dirname7, resolve as resolve6 } from "path";
6655
6931
 
@@ -6734,7 +7010,7 @@ function installDarwin2(job, holtPath) {
6734
7010
  const path = plistPath(job.id);
6735
7011
  mkdirSync15(dirname7(path), { recursive: true });
6736
7012
  writeFileSync17(path, buildLaunchdPlist(job, holtPath), "utf8");
6737
- const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
7013
+ const res = spawnSync6("launchctl", ["load", "-w", path], { encoding: "utf8" });
6738
7014
  if (res.status === 0) {
6739
7015
  console.log(c.green(" Loaded into launchd."));
6740
7016
  } else {
@@ -6745,11 +7021,11 @@ function installDarwin2(job, holtPath) {
6745
7021
  console.log(c.dim(` plist: ${path}`));
6746
7022
  }
6747
7023
  function currentCrontab2() {
6748
- const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
7024
+ const res = spawnSync6("crontab", ["-l"], { encoding: "utf8" });
6749
7025
  return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6750
7026
  }
6751
7027
  function writeCrontab2(body) {
6752
- const res = spawnSync5("crontab", ["-"], { input: body, encoding: "utf8" });
7028
+ const res = spawnSync6("crontab", ["-"], { input: body, encoding: "utf8" });
6753
7029
  return res.status === 0;
6754
7030
  }
6755
7031
  function installLinux2(job, holtPath) {
@@ -6778,7 +7054,7 @@ function installSchedule(r) {
6778
7054
  function removeScheduleDarwin(id) {
6779
7055
  const path = plistPath(id);
6780
7056
  if (existsSync16(path)) {
6781
- spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
7057
+ spawnSync6("launchctl", ["unload", path], { encoding: "utf8" });
6782
7058
  try {
6783
7059
  rmSync7(path);
6784
7060
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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",