@holt-os/holt 0.12.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 +12 -1
  2. package/dist/cli.js +296 -58
  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
 
@@ -319,6 +326,8 @@ Install **merges** into any existing `hooks` config, never clobbers unrelated se
319
326
 
320
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.
321
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
+
322
331
  To uninstall, run `holt hook remove` (add `--project` if you installed with `--project`).
323
332
 
324
333
  ## Output format
@@ -435,6 +444,8 @@ holt voice clear # remove it
435
444
 
436
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.
437
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
+
438
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.
439
450
 
440
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 };
@@ -5853,7 +5949,7 @@ function holtTools() {
5853
5949
  }
5854
5950
 
5855
5951
  // src/version.ts
5856
- var VERSION = "0.12.0";
5952
+ var VERSION = "0.12.1";
5857
5953
 
5858
5954
  // src/commands/mcp.ts
5859
5955
  function printSetup() {
@@ -5899,7 +5995,8 @@ async function mcp(sub, rest = []) {
5899
5995
  }
5900
5996
 
5901
5997
  // src/commands/hook.ts
5902
- 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";
5903
6000
  import { join as join15, dirname as dirname5 } from "path";
5904
6001
  import { homedir as homedir3 } from "os";
5905
6002
  var EVENT = {
@@ -6114,25 +6211,87 @@ function guardTrusted(real) {
6114
6211
  if (!existsSync14(memDir())) return false;
6115
6212
  return true;
6116
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
+ }
6117
6230
  async function inject() {
6231
+ const debug = !!process.env.HOLT_HOOK_DEBUG;
6232
+ const dlog = (m) => {
6233
+ if (debug) hookLog(m);
6234
+ };
6118
6235
  try {
6119
6236
  const raw = await readStdin(2e3);
6120
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
+ );
6121
6241
  const prompt = (data.prompt || "").trim();
6122
- if (prompt.length < 3) return;
6242
+ if (prompt.length < 3) {
6243
+ dlog("inject -> prompt too short");
6244
+ return;
6245
+ }
6123
6246
  const real = enterFolder(data.cwd);
6124
- if (!real) return;
6125
- 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
+ }
6126
6255
  const session = data.session_id || newSessionId();
6127
6256
  const hits = await recall(prompt, session, 4);
6128
- if (!hits.length) return;
6257
+ if (!hits.length) {
6258
+ dlog("inject -> recalled 0 notes");
6259
+ return;
6260
+ }
6129
6261
  const lines = hits.map((h) => `- ${h.turn.content.replace(/\s+/g, " ").trim().slice(0, 500)}`);
6130
6262
  process.stdout.write(
6131
- ["[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")
6132
6268
  );
6269
+ dlog(`inject -> injected ${hits.length} note(s)`);
6133
6270
  } catch (e) {
6134
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);
6135
6293
  }
6294
+ return parts.join(" ").trim();
6136
6295
  }
6137
6296
  function readTranscript(path) {
6138
6297
  const out = [];
@@ -6150,56 +6309,133 @@ function readTranscript(path) {
6150
6309
  } catch {
6151
6310
  continue;
6152
6311
  }
6312
+ if (!rec || typeof rec !== "object") continue;
6153
6313
  const d = rec;
6154
- if (d.type !== "user" && d.type !== "assistant") continue;
6155
- const m = d.message;
6156
- if (!m || m.role !== "user" && m.role !== "assistant") continue;
6157
- let text = "";
6158
- if (typeof m.content === "string") {
6159
- text = m.content;
6160
- } else if (Array.isArray(m.content)) {
6161
- text = m.content.map((b) => {
6162
- if (typeof b === "string") return b;
6163
- if (b && typeof b.text === "string") return b.text;
6164
- return "";
6165
- }).join(" ").trim();
6166
- }
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);
6167
6321
  text = text.trim();
6168
6322
  if (text.length < 3) continue;
6169
- out.push({ role: m.role, content: text });
6323
+ out.push({ role, content: text });
6170
6324
  }
6171
6325
  return out;
6172
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
+ }
6173
6353
  function resolveExtractionBrain(cfg) {
6174
6354
  const id = cfg.defaultBrain;
6175
- if (!id) return null;
6355
+ const diag = { defaultBrain: id ?? "(none)", command: "(none)", whichFound: false, resolvedPath: null };
6356
+ if (!id) return { brain: null, diag };
6176
6357
  if (BRAIN_IDS.includes(id)) {
6177
6358
  const bid = id;
6178
- if (!cfg.brains[bid].enabled) return null;
6179
- if (!isInstalled(cfg.brains[bid].command)) return null;
6180
- 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 };
6181
6373
  }
6182
6374
  const api = findApiBrain(cfg, id);
6183
- if (api && resolveApiKey(api)) return { kind: "api", brain: api };
6184
- return null;
6375
+ if (api && resolveApiKey(api)) return { brain: { kind: "api", brain: api }, diag };
6376
+ return { brain: null, diag };
6185
6377
  }
6186
6378
  async function capture() {
6379
+ const cwdForLog = process.cwd();
6187
6380
  try {
6188
6381
  const raw = await readStdin(3e3);
6189
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
+ );
6190
6386
  const real = enterFolder(data.cwd);
6191
- if (!real) return;
6192
- 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
+ }
6193
6395
  const cfg = loadConfig();
6194
- if (!cfg) return;
6195
- if (cfg.memory?.extractFacts === false) return;
6196
- const brain = resolveExtractionBrain(cfg);
6197
- if (!brain) return;
6198
- 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
+ }
6199
6418
  const history = readTranscript(data.transcript_path);
6200
- 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
+ }
6201
6436
  const session = "hook-" + (data.session_id || newSessionId());
6202
6437
  const n = await extractAndSaveFacts(brain, cfg, history, session);
6438
+ hookLog(`capture cwd=${real} event=Stop -> saved ${n} fact${n === 1 ? "" : "s"}`);
6203
6439
  if (n > 0) process.stderr.write(`holt hook capture: saved ${n} fact${n === 1 ? "" : "s"} to ${memDir()}
6204
6440
  `);
6205
6441
  if (cfg.wiki?.autoSync) {
@@ -6212,7 +6448,9 @@ async function capture() {
6212
6448
  }
6213
6449
  }
6214
6450
  } catch (e) {
6215
- 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}`);
6216
6454
  }
6217
6455
  }
6218
6456
  var USAGE2 = [
@@ -6370,7 +6608,7 @@ async function run(args) {
6370
6608
  }
6371
6609
 
6372
6610
  // src/commands/schedule.ts
6373
- import { spawnSync as spawnSync4 } from "child_process";
6611
+ import { spawnSync as spawnSync5 } from "child_process";
6374
6612
  import { writeFileSync as writeFileSync15, mkdirSync as mkdirSync13, rmSync as rmSync6, existsSync as existsSync15 } from "fs";
6375
6613
  import { dirname as dirname6 } from "path";
6376
6614
 
@@ -6379,7 +6617,7 @@ import { homedir as homedir4 } from "os";
6379
6617
  import { join as join16 } from "path";
6380
6618
  import { readFileSync as readFileSync16, writeFileSync as writeFileSync14, mkdirSync as mkdirSync12 } from "fs";
6381
6619
  import { randomUUID as randomUUID4 } from "crypto";
6382
- import { spawnSync as spawnSync3 } from "child_process";
6620
+ import { spawnSync as spawnSync4 } from "child_process";
6383
6621
  function schedulesPath() {
6384
6622
  return join16(GLOBAL_DIR, "schedules.json");
6385
6623
  }
@@ -6435,7 +6673,7 @@ function shQuote(s) {
6435
6673
  function resolveHoltPath() {
6436
6674
  try {
6437
6675
  const finder = process.platform === "win32" ? "where" : "which";
6438
- const res = spawnSync3(finder, ["holt"], { encoding: "utf8" });
6676
+ const res = spawnSync4(finder, ["holt"], { encoding: "utf8" });
6439
6677
  if (res.status === 0 && typeof res.stdout === "string") {
6440
6678
  const first = res.stdout.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
6441
6679
  if (first) return first;
@@ -6538,7 +6776,7 @@ function installDarwin(job, holtPath) {
6538
6776
  const path = plistPath(job.id);
6539
6777
  mkdirSync13(dirname6(path), { recursive: true });
6540
6778
  writeFileSync15(path, buildLaunchdPlist(job, holtPath), "utf8");
6541
- const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
6779
+ const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
6542
6780
  if (res.status === 0) {
6543
6781
  console.log(c.green(" Loaded into launchd."));
6544
6782
  } else {
@@ -6549,11 +6787,11 @@ function installDarwin(job, holtPath) {
6549
6787
  console.log(c.dim(` plist: ${path}`));
6550
6788
  }
6551
6789
  function currentCrontab() {
6552
- const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
6790
+ const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
6553
6791
  return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6554
6792
  }
6555
6793
  function writeCrontab(body) {
6556
- const res = spawnSync4("crontab", ["-"], { input: body, encoding: "utf8" });
6794
+ const res = spawnSync5("crontab", ["-"], { input: body, encoding: "utf8" });
6557
6795
  return res.status === 0;
6558
6796
  }
6559
6797
  function installLinux(job, holtPath) {
@@ -6626,7 +6864,7 @@ function listCmd2() {
6626
6864
  function removeDarwin(id) {
6627
6865
  const path = plistPath(id);
6628
6866
  if (existsSync15(path)) {
6629
- spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
6867
+ spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
6630
6868
  try {
6631
6869
  rmSync6(path);
6632
6870
  } catch {
@@ -6634,10 +6872,10 @@ function removeDarwin(id) {
6634
6872
  }
6635
6873
  }
6636
6874
  function removeLinux(id) {
6637
- const res = spawnSync4("crontab", ["-l"], { encoding: "utf8" });
6875
+ const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
6638
6876
  const body = res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6639
6877
  const next = stripCronLines(body, id);
6640
- spawnSync4("crontab", ["-"], { input: next, encoding: "utf8" });
6878
+ spawnSync5("crontab", ["-"], { input: next, encoding: "utf8" });
6641
6879
  }
6642
6880
  function removeCmd(rest) {
6643
6881
  const id = rest[0];
@@ -6687,7 +6925,7 @@ async function schedule(sub, rest = []) {
6687
6925
  }
6688
6926
 
6689
6927
  // src/commands/routine.ts
6690
- import { spawnSync as spawnSync5 } from "child_process";
6928
+ import { spawnSync as spawnSync6 } from "child_process";
6691
6929
  import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync15, rmSync as rmSync7, existsSync as existsSync16 } from "fs";
6692
6930
  import { dirname as dirname7, resolve as resolve6 } from "path";
6693
6931
 
@@ -6772,7 +7010,7 @@ function installDarwin2(job, holtPath) {
6772
7010
  const path = plistPath(job.id);
6773
7011
  mkdirSync15(dirname7(path), { recursive: true });
6774
7012
  writeFileSync17(path, buildLaunchdPlist(job, holtPath), "utf8");
6775
- const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
7013
+ const res = spawnSync6("launchctl", ["load", "-w", path], { encoding: "utf8" });
6776
7014
  if (res.status === 0) {
6777
7015
  console.log(c.green(" Loaded into launchd."));
6778
7016
  } else {
@@ -6783,11 +7021,11 @@ function installDarwin2(job, holtPath) {
6783
7021
  console.log(c.dim(` plist: ${path}`));
6784
7022
  }
6785
7023
  function currentCrontab2() {
6786
- const res = spawnSync5("crontab", ["-l"], { encoding: "utf8" });
7024
+ const res = spawnSync6("crontab", ["-l"], { encoding: "utf8" });
6787
7025
  return res.status === 0 && typeof res.stdout === "string" ? res.stdout : "";
6788
7026
  }
6789
7027
  function writeCrontab2(body) {
6790
- const res = spawnSync5("crontab", ["-"], { input: body, encoding: "utf8" });
7028
+ const res = spawnSync6("crontab", ["-"], { input: body, encoding: "utf8" });
6791
7029
  return res.status === 0;
6792
7030
  }
6793
7031
  function installLinux2(job, holtPath) {
@@ -6816,7 +7054,7 @@ function installSchedule(r) {
6816
7054
  function removeScheduleDarwin(id) {
6817
7055
  const path = plistPath(id);
6818
7056
  if (existsSync16(path)) {
6819
- spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
7057
+ spawnSync6("launchctl", ["unload", path], { encoding: "utf8" });
6820
7058
  try {
6821
7059
  rmSync7(path);
6822
7060
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.12.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",