@holt-os/holt 0.4.0 → 0.5.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.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/config.ts
4
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
4
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
5
+ import { join as join2 } from "path";
5
6
 
6
7
  // src/workspace.ts
7
8
  import { homedir } from "os";
@@ -35,11 +36,11 @@ function createReader() {
35
36
  closed = true;
36
37
  while (waiters.length) waiters.shift()(null);
37
38
  });
38
- const ask = (q) => new Promise((resolve) => {
39
+ const ask = (q) => new Promise((resolve3) => {
39
40
  if (q) process.stdout.write(q);
40
- if (buffer.length) resolve(buffer.shift());
41
- else if (closed) resolve(null);
42
- else waiters.push(resolve);
41
+ if (buffer.length) resolve3(buffer.shift());
42
+ else if (closed) resolve3(null);
43
+ else waiters.push(resolve3);
43
44
  });
44
45
  return { ask, close: () => rl.close() };
45
46
  }
@@ -103,13 +104,24 @@ var BRAIN_SETUP = {
103
104
  gemini: { install: ["npm", "install", "-g", "@google/gemini-cli"], login: ["gemini"] }
104
105
  };
105
106
  var BRAIN_IDS = ["claude", "codex", "gemini"];
107
+ var PROVIDERS = ["anthropic", "openai", "gemini"];
108
+ var PROVIDER_MODEL_SUGGESTION = {
109
+ anthropic: "claude-sonnet-4-6",
110
+ openai: "gpt-5",
111
+ gemini: "gemini-2.5-flash"
112
+ };
113
+ var PROVIDER_ENV = {
114
+ anthropic: "ANTHROPIC_API_KEY",
115
+ openai: "OPENAI_API_KEY",
116
+ gemini: "GEMINI_API_KEY"
117
+ };
106
118
  function defaultConfig() {
107
119
  const brains = {};
108
120
  for (const id of BRAIN_IDS) {
109
121
  const d = BRAIN_DEFS[id];
110
122
  brains[id] = { id, label: d.label, command: d.command, args: [...d.args], enabled: false };
111
123
  }
112
- return { version: 2, defaultBrain: null, brains };
124
+ return { version: 3, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown" };
113
125
  }
114
126
  function loadConfig() {
115
127
  const path = wsConfigPath();
@@ -117,8 +129,15 @@ function loadConfig() {
117
129
  try {
118
130
  const cfg = JSON.parse(readFileSync2(path, "utf8"));
119
131
  const base = defaultConfig();
120
- for (const id of BRAIN_IDS) if (!cfg.brains?.[id]) cfg.brains[id] = base.brains[id];
121
- return cfg;
132
+ const brains = cfg.brains ?? {};
133
+ for (const id of BRAIN_IDS) if (!brains[id]) brains[id] = base.brains[id];
134
+ return {
135
+ version: 3,
136
+ defaultBrain: cfg.defaultBrain ?? null,
137
+ brains,
138
+ apiBrains: Array.isArray(cfg.apiBrains) ? cfg.apiBrains : [],
139
+ outputFormat: cfg.outputFormat === "html" ? "html" : "markdown"
140
+ };
122
141
  } catch {
123
142
  return null;
124
143
  }
@@ -127,6 +146,47 @@ function saveConfig(cfg) {
127
146
  ensureWsDir();
128
147
  writeFileSync2(wsConfigPath(), JSON.stringify(cfg, null, 2) + "\n", "utf8");
129
148
  }
149
+ function isReservedBrainId(id) {
150
+ return BRAIN_IDS.includes(id.toLowerCase());
151
+ }
152
+ function findApiBrain(cfg, id) {
153
+ return cfg.apiBrains.find((b) => b.id === id);
154
+ }
155
+ function credentialsPath() {
156
+ return join2(GLOBAL_DIR, "credentials.json");
157
+ }
158
+ function readCredentials() {
159
+ try {
160
+ return JSON.parse(readFileSync2(credentialsPath(), "utf8"));
161
+ } catch {
162
+ return {};
163
+ }
164
+ }
165
+ function saveCredential(provider, key) {
166
+ mkdirSync2(GLOBAL_DIR, { recursive: true });
167
+ const creds = readCredentials();
168
+ creds[provider] = key;
169
+ writeFileSync2(credentialsPath(), JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 384 });
170
+ }
171
+ function resolveApiKey(brain) {
172
+ if (brain.keyEnv) {
173
+ const v = process.env[brain.keyEnv];
174
+ if (v && v.trim()) return v.trim();
175
+ }
176
+ const creds = readCredentials();
177
+ const stored = creds[brain.provider];
178
+ if (stored && stored.trim()) return stored.trim();
179
+ const std = process.env[PROVIDER_ENV[brain.provider]];
180
+ if (std && std.trim()) return std.trim();
181
+ return null;
182
+ }
183
+ function keyHint(brain) {
184
+ const parts = [];
185
+ if (brain.keyEnv) parts.push(`set env var ${brain.keyEnv}`);
186
+ parts.push(`set ${PROVIDER_ENV[brain.provider]}`);
187
+ parts.push("or store a key via /setting > connect API brain");
188
+ return parts.join(", ");
189
+ }
130
190
 
131
191
  // src/brains.ts
132
192
  import { spawn, spawnSync } from "child_process";
@@ -159,12 +219,12 @@ function renderPrompt(history, message, memory = []) {
159
219
  return parts.join("\n");
160
220
  }
161
221
  function runBrain(brain, prompt, onChunk) {
162
- return new Promise((resolve) => {
222
+ return new Promise((resolve3) => {
163
223
  let child;
164
224
  try {
165
225
  child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
166
226
  } catch (e) {
167
- resolve({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
227
+ resolve3({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
168
228
  return;
169
229
  }
170
230
  let out = "";
@@ -177,26 +237,26 @@ function runBrain(brain, prompt, onChunk) {
177
237
  child.stderr.on("data", (d) => {
178
238
  err += d.toString();
179
239
  });
180
- child.on("error", (e) => resolve({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
240
+ child.on("error", (e) => resolve3({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
181
241
  child.on("close", (code) => {
182
242
  const text = out.trim();
183
- if (code === 0 && text) resolve({ ok: true, text });
184
- else resolve({ ok: false, text: err.trim() || text || `"${brain.command}" exited with code ${code}` });
243
+ if (code === 0 && text) resolve3({ ok: true, text });
244
+ else resolve3({ ok: false, text: err.trim() || text || `"${brain.command}" exited with code ${code}` });
185
245
  });
186
246
  });
187
247
  }
188
248
 
189
249
  // src/alias.ts
190
250
  import { homedir as homedir2 } from "os";
191
- import { join as join2 } from "path";
251
+ import { join as join3 } from "path";
192
252
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
193
253
  var START = "# >>> holt launch alias >>>";
194
254
  var END = "# <<< holt launch alias <<<";
195
255
  function rcFile() {
196
256
  const shell = process.env.SHELL || "";
197
- if (shell.includes("zsh")) return join2(homedir2(), ".zshrc");
198
- if (shell.includes("bash")) return join2(homedir2(), ".bashrc");
199
- return join2(homedir2(), ".profile");
257
+ if (shell.includes("zsh")) return join3(homedir2(), ".zshrc");
258
+ if (shell.includes("bash")) return join3(homedir2(), ".bashrc");
259
+ return join3(homedir2(), ".profile");
200
260
  }
201
261
  function installAlias(name) {
202
262
  const file = rcFile();
@@ -239,30 +299,30 @@ function removeAlias() {
239
299
  // src/install.ts
240
300
  import { spawn as spawn2 } from "child_process";
241
301
  function runInteractive(cmd, args) {
242
- return new Promise((resolve) => {
302
+ return new Promise((resolve3) => {
243
303
  let child;
244
304
  try {
245
305
  child = spawn2(cmd, args, { stdio: "inherit" });
246
306
  } catch {
247
- resolve(-1);
307
+ resolve3(-1);
248
308
  return;
249
309
  }
250
- child.on("error", () => resolve(-1));
251
- child.on("close", (code) => resolve(code ?? -1));
310
+ child.on("error", () => resolve3(-1));
311
+ child.on("close", (code) => resolve3(code ?? -1));
252
312
  });
253
313
  }
254
314
 
255
315
  // src/memory.ts
256
- import { appendFileSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync, statSync } from "fs";
257
- import { join as join3 } from "path";
316
+ import { appendFileSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync3, rmSync, statSync } from "fs";
317
+ import { join as join4 } from "path";
258
318
  import { randomUUID } from "crypto";
259
319
  var OLLAMA_URL = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
260
320
  var EMBED_MODEL = process.env.HOLT_EMBED_MODEL || "nomic-embed-text";
261
321
  function memDir() {
262
- return join3(wsHoltDir(), "memory");
322
+ return join4(wsHoltDir(), "memory");
263
323
  }
264
324
  function memPath() {
265
- return join3(memDir(), "turns.jsonl");
325
+ return join4(memDir(), "turns.jsonl");
266
326
  }
267
327
  function newSessionId() {
268
328
  return randomUUID().slice(0, 8);
@@ -313,7 +373,7 @@ function loadTurns() {
313
373
  return out;
314
374
  }
315
375
  function appendTurn(t) {
316
- mkdirSync2(memDir(), { recursive: true });
376
+ mkdirSync3(memDir(), { recursive: true });
317
377
  appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
318
378
  }
319
379
  function clearMemory() {
@@ -382,11 +442,162 @@ async function backfillEmbeddings(onProgress) {
382
442
  done++;
383
443
  if (onProgress) onProgress(done, missing.length);
384
444
  }
385
- mkdirSync2(memDir(), { recursive: true });
445
+ mkdirSync3(memDir(), { recursive: true });
386
446
  writeFileSync4(memPath(), turns.map((t) => JSON.stringify(t)).join("\n") + "\n", "utf8");
387
447
  return { embedded, total: missing.length };
388
448
  }
389
449
 
450
+ // src/commands/setting.ts
451
+ function printStatus(cfg) {
452
+ console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
453
+ for (const id of BRAIN_IDS) {
454
+ const b = cfg.brains[id];
455
+ const tags = [
456
+ b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
457
+ cfg.defaultBrain === id ? c.accent("default") : ""
458
+ ].filter(Boolean).join(" ");
459
+ console.log(` ${id.padEnd(10)} ${b.label.padEnd(16)} ${tags}`);
460
+ }
461
+ if (cfg.apiBrains.length) {
462
+ console.log(c.dim(" api brains:"));
463
+ for (const a of cfg.apiBrains) {
464
+ const key = resolveApiKey(a) ? c.green("key ok") : c.red("no key");
465
+ const def = cfg.defaultBrain === a.id ? c.accent("default") : "";
466
+ console.log(` ${a.id.padEnd(10)} ${`${a.provider}/${a.model}`.padEnd(30)} ${key} ${def}`);
467
+ }
468
+ }
469
+ console.log(c.dim(` output format: ${cfg.outputFormat}`));
470
+ console.log(c.dim(` launch command: ${currentAlias() || "holt (default)"}`));
471
+ console.log("\n " + c.dim("[d] default brain [t] toggle brain [c] connect API brain [x] remove API brain [a] launch command [enter] done"));
472
+ }
473
+ async function connectApiBrain(ask, cfg) {
474
+ let provider = null;
475
+ for (let tries = 0; tries < 3 && !provider; tries++) {
476
+ const provRaw = (await ask(` provider [${PROVIDERS.join("/")}] (enter for ${PROVIDERS[0]}, or "skip"): `) ?? "skip").trim().toLowerCase();
477
+ if (provRaw === "skip" || provRaw === "q" || provRaw === "n" || provRaw === "no") {
478
+ console.log(c.dim(' skipped. Add one later with "holt setting" then "c".'));
479
+ return null;
480
+ }
481
+ if (provRaw === "") provider = PROVIDERS[0];
482
+ else if (PROVIDERS.includes(provRaw)) provider = provRaw;
483
+ else console.log(c.dim(` "${provRaw}" is not a provider. Type one of: ${PROVIDERS.join(", ")}`));
484
+ }
485
+ if (!provider) {
486
+ console.log(c.dim(' skipped after three tries. Add one later with "holt setting" then "c".'));
487
+ return null;
488
+ }
489
+ const suggestion = PROVIDER_MODEL_SUGGESTION[provider];
490
+ const modelRaw = (await ask(` model (enter for ${suggestion}): `) ?? "").trim();
491
+ const model = modelRaw || suggestion;
492
+ const idRaw = (await ask(" short name for this brain (e.g. sonnet): ") ?? "").trim();
493
+ if (!idRaw) {
494
+ console.log(c.dim(" cancelled (no name)."));
495
+ return null;
496
+ }
497
+ if (isReservedBrainId(idRaw)) {
498
+ console.log(c.red(` "${idRaw}" is reserved for a CLI brain. Pick another name.`));
499
+ return null;
500
+ }
501
+ if (findApiBrain(cfg, idRaw)) {
502
+ console.log(c.red(` an API brain named "${idRaw}" already exists.`));
503
+ return null;
504
+ }
505
+ console.log(c.dim(" key: paste a raw key (stored locally, mode 600), or type the name of an env var that holds it."));
506
+ const keyRaw = (await ask(" key or env var name: ") ?? "").trim();
507
+ let keyEnv;
508
+ if (!keyRaw) {
509
+ console.log(c.dim(" no key given now. You can set the standard env var later."));
510
+ } else if (/^[A-Z][A-Z0-9_]*$/.test(keyRaw)) {
511
+ keyEnv = keyRaw;
512
+ console.log(c.dim(` will read the key from env var ${keyEnv}.`));
513
+ } else {
514
+ saveCredential(provider, keyRaw);
515
+ console.log(c.green(` key stored in ~/.holt/credentials.json (mode 600).`));
516
+ }
517
+ const brain = { id: idRaw, provider, model, ...keyEnv ? { keyEnv } : {} };
518
+ cfg.apiBrains.push(brain);
519
+ const resolvable = resolveApiKey(brain);
520
+ console.log(c.green(` connected "${idRaw}" -> ${provider}/${model}.`) + (resolvable ? "" : c.dim(" (no key resolves yet)")));
521
+ return brain;
522
+ }
523
+ function selectableIds(cfg) {
524
+ return [...BRAIN_IDS.filter((id) => cfg.brains[id].enabled), ...cfg.apiBrains.map((a) => a.id)];
525
+ }
526
+ async function runSettings(ask) {
527
+ let cfg = loadConfig() ?? defaultConfig();
528
+ while (true) {
529
+ printStatus(cfg);
530
+ const raw = await ask(" > ");
531
+ const choice = (raw ?? "").trim().toLowerCase();
532
+ if (raw === null || choice === "" || choice === "q") break;
533
+ if (choice === "d") {
534
+ const options = selectableIds(cfg);
535
+ if (options.length === 0) {
536
+ console.log(c.dim(' No brains ready. Toggle a CLI brain on ("t") or connect an API brain ("c").'));
537
+ continue;
538
+ }
539
+ const pick = (await ask(` default brain [${options.join("/")}]: `) ?? "").trim();
540
+ if (options.includes(pick)) {
541
+ cfg.defaultBrain = pick;
542
+ const label = BRAIN_IDS.includes(pick) ? cfg.brains[pick].label : pick;
543
+ console.log(c.green(` default set to ${label}`));
544
+ } else console.log(c.dim(" unchanged."));
545
+ } else if (choice === "t") {
546
+ const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
547
+ if (BRAIN_IDS.includes(pick)) {
548
+ if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
549
+ console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
550
+ } else {
551
+ cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
552
+ if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
553
+ if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
554
+ console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
555
+ }
556
+ } else console.log(c.dim(" unchanged."));
557
+ } else if (choice === "c") {
558
+ await connectApiBrain(ask, cfg);
559
+ if (!cfg.defaultBrain && cfg.apiBrains.length) cfg.defaultBrain = cfg.apiBrains[cfg.apiBrains.length - 1]?.id ?? null;
560
+ } else if (choice === "x") {
561
+ if (cfg.apiBrains.length === 0) {
562
+ console.log(c.dim(" no API brains to remove."));
563
+ continue;
564
+ }
565
+ const pick = (await ask(` remove which API brain [${cfg.apiBrains.map((a) => a.id).join("/")}]: `) ?? "").trim();
566
+ const idx = cfg.apiBrains.findIndex((a) => a.id === pick);
567
+ if (idx >= 0) {
568
+ cfg.apiBrains.splice(idx, 1);
569
+ if (cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
570
+ console.log(c.dim(` removed "${pick}". (its stored key, if any, stays in ~/.holt/credentials.json)`));
571
+ } else console.log(c.dim(" unchanged."));
572
+ } else if (choice === "a") {
573
+ const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
574
+ if (name && name !== "holt") {
575
+ if (isInstalled(name)) console.log(c.dim(` note: "${name}" already exists; the alias will shadow it in new shells.`));
576
+ const r = installAlias(name);
577
+ console.log(r.ok ? c.green(` alias "${name}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message));
578
+ } else {
579
+ removeAlias();
580
+ console.log(c.dim(" reset to holt."));
581
+ }
582
+ } else {
583
+ console.log(c.dim(" pick d, t, c, x, a, or press enter to finish."));
584
+ }
585
+ saveConfig(cfg);
586
+ }
587
+ saveConfig(cfg);
588
+ return cfg;
589
+ }
590
+ async function setting() {
591
+ const { ask, close } = createReader();
592
+ if (!await ensureTrusted(ask)) {
593
+ close();
594
+ return;
595
+ }
596
+ await runSettings(ask);
597
+ close();
598
+ console.log("");
599
+ }
600
+
390
601
  // src/commands/init.ts
391
602
  function parseBrains(raw, found) {
392
603
  const s = raw.trim().toLowerCase();
@@ -422,16 +633,25 @@ async function init() {
422
633
  const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
423
634
  if (a !== "n" && a !== "no") loginWanted.add(id);
424
635
  }
636
+ const connectedApiBrains = [];
637
+ const apiAns = (await ask("\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ") ?? "").trim().toLowerCase();
638
+ if (apiAns === "y" || apiAns === "yes") {
639
+ const holder = defaultConfig();
640
+ const brain = await connectApiBrain(ask, holder);
641
+ if (brain) connectedApiBrains.push(brain);
642
+ }
425
643
  const defPick = chosen.includes("claude") ? "claude" : chosen[0];
426
644
  const dans = (await ask(`
427
645
  Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
428
646
  const defaultBrain = chosen.includes(dans) ? dans : defPick;
429
647
  const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
430
648
  let aliasNote = "";
649
+ let aliasRcFile = "";
431
650
  if (aliasAns && aliasAns !== "holt") {
432
651
  if (isInstalled(aliasAns)) console.log(c.dim(` note: "${aliasAns}" already exists; the alias will shadow it in new shells.`));
433
652
  const r = installAlias(aliasAns);
434
- aliasNote = r.ok ? c.green(` alias "${aliasAns}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message);
653
+ if (r.ok) aliasRcFile = r.file;
654
+ aliasNote = r.ok ? c.green(` alias "${aliasAns}" -> holt chat added to ${r.file}`) : c.red(" " + r.message);
435
655
  }
436
656
  let wantMemorySetup = false;
437
657
  const embedReady = await embeddingsAvailable();
@@ -482,102 +702,472 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
482
702
  }
483
703
  const cfg = loadConfig() ?? defaultConfig();
484
704
  for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
485
- cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? null;
705
+ for (const b of connectedApiBrains) if (!cfg.apiBrains.some((a) => a.id === b.id)) cfg.apiBrains.push(b);
706
+ cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
486
707
  saveConfig(cfg);
487
708
  console.log("\n" + c.green("Saved to ./.holt/config.json"));
488
709
  if (aliasNote) console.log(aliasNote);
489
- if (cfg.defaultBrain) console.log("Start chatting: " + c.accent(aliasAns && aliasAns !== "holt" ? aliasAns : "holt chat") + "\n");
490
- else console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
710
+ if (cfg.defaultBrain) {
711
+ if (aliasRcFile) {
712
+ console.log("\nStart chatting:");
713
+ console.log(" " + c.accent(`source ${aliasRcFile}`) + c.dim(" (once; new terminals will not need it)"));
714
+ console.log(" " + c.accent(aliasAns) + "\n");
715
+ console.log(c.dim(" Or right now, without sourcing: holt chat\n"));
716
+ } else {
717
+ console.log("Start chatting: " + c.accent("holt chat") + "\n");
718
+ }
719
+ } else {
720
+ console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
721
+ }
491
722
  }
492
723
 
493
724
  // src/commands/chat.ts
494
725
  import { randomUUID as randomUUID2 } from "crypto";
495
726
 
496
- // src/commands/setting.ts
497
- function printStatus(cfg) {
498
- console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
499
- for (const id of BRAIN_IDS) {
500
- const b = cfg.brains[id];
501
- const tags = [
502
- b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
503
- cfg.defaultBrain === id ? c.accent("default") : ""
504
- ].filter(Boolean).join(" ");
505
- console.log(` ${id.padEnd(7)} ${b.label.padEnd(16)} ${tags}`);
727
+ // src/apibrain.ts
728
+ function parseSSELines(buffer) {
729
+ const normalized = buffer.replace(/\r\n/g, "\n");
730
+ const parts = normalized.split("\n\n");
731
+ const rest = parts.pop() ?? "";
732
+ const events = parts.filter((p) => p.trim() !== "");
733
+ return { events, rest };
734
+ }
735
+ function dataLines(event) {
736
+ const out = [];
737
+ for (const line of event.split("\n")) {
738
+ const t = line.replace(/\r$/, "");
739
+ if (t.startsWith("data:")) out.push(t.slice(5).replace(/^ /, ""));
506
740
  }
507
- console.log(c.dim(` launch command: ${currentAlias() || "holt (default)"}`));
508
- console.log("\n " + c.dim("[d] default brain [t] toggle brain [a] launch command [enter] done"));
741
+ return out;
509
742
  }
510
- async function runSettings(ask) {
511
- let cfg = loadConfig() ?? defaultConfig();
512
- while (true) {
513
- printStatus(cfg);
514
- const raw = await ask(" > ");
515
- const choice = (raw ?? "").trim().toLowerCase();
516
- if (raw === null || choice === "" || choice === "q") break;
517
- if (choice === "d") {
518
- const enabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
519
- if (enabled.length === 0) {
520
- console.log(c.dim(' No enabled brains. Toggle one on first with "t".'));
521
- continue;
743
+ function extractDelta(provider, json) {
744
+ const j = json;
745
+ if (provider === "anthropic") {
746
+ if (j["type"] === "content_block_delta") {
747
+ const delta = j["delta"];
748
+ if (delta && delta.type === "text_delta" && typeof delta.text === "string") return delta.text;
749
+ }
750
+ return "";
751
+ }
752
+ if (provider === "openai") {
753
+ const choices = j["choices"];
754
+ const content = choices?.[0]?.delta?.content;
755
+ return typeof content === "string" ? content : "";
756
+ }
757
+ const candidates = j["candidates"];
758
+ const parts = candidates?.[0]?.content?.parts;
759
+ if (!Array.isArray(parts)) return "";
760
+ return parts.map((p) => typeof p.text === "string" ? p.text : "").join("");
761
+ }
762
+ function buildRequest(brain, key, prompt) {
763
+ if (brain.provider === "anthropic") {
764
+ return {
765
+ url: "https://api.anthropic.com/v1/messages",
766
+ headers: {
767
+ "content-type": "application/json",
768
+ "x-api-key": key,
769
+ "anthropic-version": "2023-06-01"
770
+ },
771
+ body: JSON.stringify({
772
+ model: brain.model,
773
+ max_tokens: 4096,
774
+ stream: true,
775
+ messages: [{ role: "user", content: prompt }]
776
+ })
777
+ };
778
+ }
779
+ if (brain.provider === "openai") {
780
+ return {
781
+ url: "https://api.openai.com/v1/chat/completions",
782
+ headers: {
783
+ "content-type": "application/json",
784
+ authorization: `Bearer ${key}`
785
+ },
786
+ body: JSON.stringify({
787
+ model: brain.model,
788
+ stream: true,
789
+ messages: [{ role: "user", content: prompt }]
790
+ })
791
+ };
792
+ }
793
+ return {
794
+ url: `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(brain.model)}:streamGenerateContent?alt=sse`,
795
+ headers: {
796
+ "content-type": "application/json",
797
+ "x-goog-api-key": key
798
+ },
799
+ body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
800
+ };
801
+ }
802
+ async function runApiBrain(brain, prompt, onChunk) {
803
+ const key = resolveApiKey(brain);
804
+ if (!key) {
805
+ return {
806
+ ok: false,
807
+ text: `No API key for "${brain.id}" (${brain.provider}). Fix: ${keyHint(brain)}. Standard env var: ${PROVIDER_ENV[brain.provider]}.`
808
+ };
809
+ }
810
+ const spec = buildRequest(brain, key, prompt);
811
+ let res;
812
+ try {
813
+ res = await fetch(spec.url, { method: "POST", headers: spec.headers, body: spec.body });
814
+ } catch (e) {
815
+ return { ok: false, text: `Could not reach ${brain.provider}: ${e.message}` };
816
+ }
817
+ if (!res.ok) {
818
+ let body = "";
819
+ try {
820
+ body = await res.text();
821
+ } catch {
822
+ }
823
+ return { ok: false, text: `${brain.provider} error ${res.status}: ${body.slice(0, 200)}` };
824
+ }
825
+ if (!res.body) {
826
+ let body = "";
827
+ try {
828
+ body = await res.text();
829
+ } catch {
830
+ }
831
+ return { ok: false, text: `${brain.provider} returned no stream. Body: ${body.slice(0, 200)}` };
832
+ }
833
+ const decoder = new TextDecoder();
834
+ let carry = "";
835
+ let text = "";
836
+ try {
837
+ for await (const chunk of res.body) {
838
+ carry += decoder.decode(chunk, { stream: true });
839
+ const { events, rest } = parseSSELines(carry);
840
+ carry = rest;
841
+ for (const ev of events) {
842
+ for (const payload of dataLines(ev)) {
843
+ if (payload === "[DONE]") continue;
844
+ let json;
845
+ try {
846
+ json = JSON.parse(payload);
847
+ } catch {
848
+ continue;
849
+ }
850
+ const piece = extractDelta(brain.provider, json);
851
+ if (piece) {
852
+ text += piece;
853
+ if (onChunk) onChunk(piece);
854
+ }
855
+ }
522
856
  }
523
- const pick = (await ask(` default brain [${enabled.join("/")}]: `) ?? "").trim();
524
- if (enabled.includes(pick)) {
525
- cfg.defaultBrain = pick;
526
- console.log(c.green(` default set to ${cfg.brains[pick].label}`));
527
- } else console.log(c.dim(" unchanged."));
528
- } else if (choice === "t") {
529
- const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
530
- if (BRAIN_IDS.includes(pick)) {
531
- if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
532
- console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
533
- } else {
534
- cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
535
- if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? null;
536
- if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
537
- console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
857
+ }
858
+ } catch (e) {
859
+ if (text) return { ok: true, text };
860
+ return { ok: false, text: `Stream from ${brain.provider} failed: ${e.message}` };
861
+ }
862
+ if (carry.trim()) {
863
+ for (const payload of dataLines(carry)) {
864
+ if (payload === "[DONE]") continue;
865
+ try {
866
+ const piece = extractDelta(brain.provider, JSON.parse(payload));
867
+ if (piece) {
868
+ text += piece;
869
+ if (onChunk) onChunk(piece);
538
870
  }
539
- } else console.log(c.dim(" unchanged."));
540
- } else if (choice === "a") {
541
- const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
542
- if (name && name !== "holt") {
543
- if (isInstalled(name)) console.log(c.dim(` note: "${name}" already exists; the alias will shadow it in new shells.`));
544
- const r = installAlias(name);
545
- console.log(r.ok ? c.green(` alias "${name}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message));
546
- } else {
547
- removeAlias();
548
- console.log(c.dim(" reset to holt."));
871
+ } catch {
549
872
  }
550
- } else {
551
- console.log(c.dim(" pick d, t, a, or press enter to finish."));
552
873
  }
553
- saveConfig(cfg);
554
874
  }
555
- saveConfig(cfg);
556
- return cfg;
875
+ const trimmed = text.trim();
876
+ if (trimmed) return { ok: true, text: trimmed };
877
+ return { ok: false, text: `${brain.provider} returned an empty reply.` };
557
878
  }
558
- async function setting() {
559
- const { ask, close } = createReader();
560
- if (!await ensureTrusted(ask)) {
561
- close();
562
- return;
879
+
880
+ // src/output.ts
881
+ import { writeFileSync as writeFileSync5 } from "fs";
882
+ import { join as join5 } from "path";
883
+ function escapeHtml(s) {
884
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
885
+ }
886
+ function renderInline(text) {
887
+ const codes = [];
888
+ let s = text.replace(/`([^`]+)`/g, (_m, code) => {
889
+ codes.push(`<code>${escapeHtml(code)}</code>`);
890
+ return `\0${codes.length - 1}\0`;
891
+ });
892
+ s = escapeHtml(s);
893
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, url) => {
894
+ const safe = url.replace(/"/g, "%22");
895
+ return `<a href="${safe}">${label}</a>`;
896
+ });
897
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
898
+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
899
+ s = s.replace(/(\d+)/g, (_m, i) => codes[Number(i)] ?? "");
900
+ return s;
901
+ }
902
+ function markdownToHtmlBody(md) {
903
+ const lines = md.replace(/\r\n/g, "\n").split("\n");
904
+ const out = [];
905
+ let i = 0;
906
+ let listOpen = false;
907
+ let paragraph = [];
908
+ const flushParagraph = () => {
909
+ if (paragraph.length) {
910
+ out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
911
+ paragraph = [];
912
+ }
913
+ };
914
+ const closeList = () => {
915
+ if (listOpen) {
916
+ out.push("</ul>");
917
+ listOpen = false;
918
+ }
919
+ };
920
+ while (i < lines.length) {
921
+ const line = lines[i] ?? "";
922
+ const fence = line.match(/^```(.*)$/);
923
+ if (fence) {
924
+ flushParagraph();
925
+ closeList();
926
+ const code = [];
927
+ i++;
928
+ while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
929
+ code.push(lines[i] ?? "");
930
+ i++;
931
+ }
932
+ i++;
933
+ out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
934
+ continue;
935
+ }
936
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
937
+ if (h) {
938
+ flushParagraph();
939
+ closeList();
940
+ const level = (h[1] ?? "#").length;
941
+ out.push(`<h${level}>${renderInline((h[2] ?? "").trim())}</h${level}>`);
942
+ i++;
943
+ continue;
944
+ }
945
+ const li = line.match(/^\s*[-*]\s+(.*)$/);
946
+ if (li) {
947
+ flushParagraph();
948
+ if (!listOpen) {
949
+ out.push("<ul>");
950
+ listOpen = true;
951
+ }
952
+ out.push(`<li>${renderInline((li[1] ?? "").trim())}</li>`);
953
+ i++;
954
+ continue;
955
+ }
956
+ if (line.trim() === "") {
957
+ flushParagraph();
958
+ closeList();
959
+ i++;
960
+ continue;
961
+ }
962
+ closeList();
963
+ paragraph.push(line.trim());
964
+ i++;
563
965
  }
564
- await runSettings(ask);
565
- close();
566
- console.log("");
966
+ flushParagraph();
967
+ closeList();
968
+ return out.join("\n");
969
+ }
970
+ function wrapHtmlPage(body, title = "Holt reply") {
971
+ return `<!doctype html>
972
+ <html lang="en">
973
+ <head>
974
+ <meta charset="utf-8">
975
+ <meta name="viewport" content="width=device-width, initial-scale=1">
976
+ <title>${escapeHtml(title)}</title>
977
+ <style>
978
+ :root { --bg:#0f1115; --fg:#e7e9ee; --accent:#f0b91e; }
979
+ * { box-sizing: border-box; }
980
+ body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
981
+ font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
982
+ main { max-width: 760px; margin: 0 auto; }
983
+ h1,h2,h3 { color:var(--accent); line-height:1.25; margin:1.4em 0 .5em; }
984
+ h1 { font-size:1.6rem; } h2 { font-size:1.3rem; } h3 { font-size:1.1rem; }
985
+ a { color:var(--accent); }
986
+ code { background:#1b1e26; padding:.1em .35em; border-radius:4px; font-size:.9em; }
987
+ pre { background:#1b1e26; padding:1rem; border-radius:8px; overflow:auto; }
988
+ pre code { background:none; padding:0; }
989
+ ul { padding-left:1.3rem; }
990
+ p { margin:.7em 0; }
991
+ </style>
992
+ </head>
993
+ <body>
994
+ <main>
995
+ ${body}
996
+ </main>
997
+ </body>
998
+ </html>
999
+ `;
1000
+ }
1001
+ function renderReply(reply, format) {
1002
+ if (format === "html") return wrapHtmlPage(markdownToHtmlBody(reply));
1003
+ return reply.endsWith("\n") ? reply : reply + "\n";
1004
+ }
1005
+ function timestamp() {
1006
+ const d = /* @__PURE__ */ new Date();
1007
+ const p = (n) => String(n).padStart(2, "0");
1008
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
1009
+ }
1010
+ function saveReply(reply, format, name) {
1011
+ const ext = format === "html" ? ".html" : ".md";
1012
+ let base = (name ?? "").trim();
1013
+ if (!base) base = `holt-reply-${timestamp()}`;
1014
+ if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
1015
+ const path = join5(process.cwd(), base);
1016
+ writeFileSync5(path, renderReply(reply, format), "utf8");
1017
+ return path;
1018
+ }
1019
+
1020
+ // src/skills.ts
1021
+ import { readFileSync as readFileSync5, readdirSync, existsSync as existsSync5, statSync as statSync2 } from "fs";
1022
+ import { join as join6 } from "path";
1023
+ function skillsRoot(scope) {
1024
+ return scope === "workspace" ? join6(wsHoltDir(), "skills") : join6(GLOBAL_DIR, "skills");
1025
+ }
1026
+ function sanitizeName(name) {
1027
+ return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
1028
+ }
1029
+ function parseFrontmatter(md) {
1030
+ const text = md.replace(/^/, "");
1031
+ const lines = text.split("\n");
1032
+ let start = 0;
1033
+ while (start < lines.length && lines[start].trim() === "") start++;
1034
+ if (start >= lines.length || lines[start].trim() !== "---") {
1035
+ return { data: {}, body: text.trim() };
1036
+ }
1037
+ const data = {};
1038
+ let i = start + 1;
1039
+ let closed = false;
1040
+ for (; i < lines.length; i++) {
1041
+ const line = lines[i];
1042
+ if (line.trim() === "---") {
1043
+ closed = true;
1044
+ i++;
1045
+ break;
1046
+ }
1047
+ const idx = line.indexOf(":");
1048
+ if (idx === -1) continue;
1049
+ const key = line.slice(0, idx).trim();
1050
+ if (!key) continue;
1051
+ let value = line.slice(idx + 1).trim();
1052
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
1053
+ value = value.slice(1, -1);
1054
+ }
1055
+ data[key.toLowerCase()] = value;
1056
+ }
1057
+ if (!closed) {
1058
+ return { data: {}, body: text.trim() };
1059
+ }
1060
+ return { data, body: lines.slice(i).join("\n").trim() };
1061
+ }
1062
+ function readSkill(dir, folder, scope) {
1063
+ const file = join6(dir, "SKILL.md");
1064
+ if (!existsSync5(file)) return null;
1065
+ let raw = "";
1066
+ try {
1067
+ raw = readFileSync5(file, "utf8");
1068
+ } catch {
1069
+ return null;
1070
+ }
1071
+ const { data, body } = parseFrontmatter(raw);
1072
+ const name = sanitizeName(data.name || folder) || folder;
1073
+ let description = data.description || "";
1074
+ if (!description) {
1075
+ const firstLine2 = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
1076
+ description = firstLine2.replace(/^#+\s*/, "");
1077
+ }
1078
+ return { name, description, dir, scope };
1079
+ }
1080
+ function listScope(scope) {
1081
+ const root = skillsRoot(scope);
1082
+ if (!existsSync5(root)) return [];
1083
+ let entries;
1084
+ try {
1085
+ entries = readdirSync(root);
1086
+ } catch {
1087
+ return [];
1088
+ }
1089
+ const out = [];
1090
+ for (const folder of entries) {
1091
+ const dir = join6(root, folder);
1092
+ try {
1093
+ if (!statSync2(dir).isDirectory()) continue;
1094
+ } catch {
1095
+ continue;
1096
+ }
1097
+ const skill = readSkill(dir, folder, scope);
1098
+ if (skill) out.push(skill);
1099
+ }
1100
+ return out;
1101
+ }
1102
+ function listSkills() {
1103
+ const byName = /* @__PURE__ */ new Map();
1104
+ for (const s of listScope("global")) byName.set(s.name, s);
1105
+ for (const s of listScope("workspace")) byName.set(s.name, s);
1106
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
1107
+ }
1108
+ function loadSkill(name) {
1109
+ const want = sanitizeName(name);
1110
+ const skill = listSkills().find((s) => s.name === want);
1111
+ if (!skill) return null;
1112
+ let raw = "";
1113
+ try {
1114
+ raw = readFileSync5(join6(skill.dir, "SKILL.md"), "utf8");
1115
+ } catch {
1116
+ return null;
1117
+ }
1118
+ const { body } = parseFrontmatter(raw);
1119
+ return { skill, body };
1120
+ }
1121
+ function skillsPromptBlock() {
1122
+ const skills = listSkills();
1123
+ if (skills.length === 0) return "";
1124
+ const lines = [
1125
+ `Skills available to this user (invoked with "/skill <name>"). When the user invokes a skill, follow that skill's instructions:`,
1126
+ ...skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`)
1127
+ ];
1128
+ return lines.join("\n");
1129
+ }
1130
+ function resolveSkillInvocation(line) {
1131
+ const trimmed = line.trim();
1132
+ const m = trimmed.match(/^\/skill\s+(\S+)\s*([\s\S]*)$/i);
1133
+ if (!m) return null;
1134
+ const name = m[1];
1135
+ const input = (m[2] || "").trim();
1136
+ const loaded = loadSkill(name);
1137
+ if (!loaded) return null;
1138
+ const prompt = [
1139
+ loaded.body,
1140
+ "",
1141
+ "Apply the skill above to this request:",
1142
+ input || "Introduce what you can do with this skill."
1143
+ ].join("\n");
1144
+ return { prompt, skillName: loaded.skill.name };
567
1145
  }
568
1146
 
569
1147
  // src/commands/chat.ts
570
1148
  function help() {
571
1149
  console.log(c.dim([
572
1150
  " commands:",
573
- " /brain [name] switch brain (claude, codex, gemini). context is kept.",
1151
+ " /brain [name] switch brain (CLI or API). context is kept.",
574
1152
  " /memory [query] memory stats, or preview what a query would recall",
575
- " /setting configure brains and your launch command",
1153
+ ' /skill [name] [input] run a skill, or list them. "holt skill" manages them.',
1154
+ " /output [fmt] show or set output format: markdown | html",
1155
+ " /save [name] save the last reply to a file in this folder",
1156
+ " /setting configure brains, API brains, and your launch command",
576
1157
  " /clear forget this session so far (saved memory stays)",
577
1158
  " /help this list",
578
1159
  " /exit leave"
579
1160
  ].join("\n")));
580
1161
  }
1162
+ function resolveActive(cfg, id) {
1163
+ if (BRAIN_IDS.includes(id)) {
1164
+ const b = cfg.brains[id];
1165
+ return { kind: "cli", id, label: b.label };
1166
+ }
1167
+ const api = findApiBrain(cfg, id);
1168
+ if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
1169
+ return null;
1170
+ }
581
1171
  async function chat() {
582
1172
  const { ask, close } = createReader();
583
1173
  if (!await ensureTrusted(ask)) {
@@ -596,25 +1186,44 @@ async function chat() {
596
1186
  console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
597
1187
  return;
598
1188
  }
599
- let current = cfg.defaultBrain;
1189
+ let active = resolveActive(cfg, cfg.defaultBrain);
1190
+ if (!active) {
1191
+ const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
1192
+ active = fallback ? resolveActive(cfg, fallback) : null;
1193
+ if (!active) {
1194
+ close();
1195
+ console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
1196
+ return;
1197
+ }
1198
+ }
600
1199
  const session = newSessionId();
601
1200
  const history = [];
1201
+ let lastReply = "";
602
1202
  const embedOk = await embeddingsAvailable();
603
1203
  const stats = memStats();
604
- console.log("\n" + c.accent("Holt") + c.dim(` brain: ${cfg.brains[current].label}`));
1204
+ console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
605
1205
  console.log(c.dim(
606
1206
  `Memory: ${stats.turns} moments from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
607
1207
  ));
608
1208
  if (embedOk && stats.withEmbeddings < stats.turns) {
609
1209
  console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
610
1210
  }
611
- console.log(c.dim("Type a message. Commands: /brain /memory /setting /clear /help /exit\n"));
1211
+ console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit\n"));
612
1212
  while (true) {
613
1213
  const raw = await ask(c.accent("\u203A "));
614
1214
  if (raw === null) break;
615
1215
  const line = raw.trim();
616
1216
  if (!line) continue;
617
- if (line.startsWith("/")) {
1217
+ let promptOverride = null;
1218
+ if (/^\/skills?\s+\S/.test(line)) {
1219
+ const inv = resolveSkillInvocation(line.replace(/^\/skills\b/, "/skill"));
1220
+ if (!inv) {
1221
+ console.log(c.dim(' no such skill. Try "holt skill list".'));
1222
+ continue;
1223
+ }
1224
+ promptOverride = inv.prompt;
1225
+ console.log(c.dim(` running skill "${inv.skillName}"...`));
1226
+ } else if (line.startsWith("/")) {
618
1227
  const parts = line.slice(1).split(/\s+/);
619
1228
  const cmd = (parts[0] || "").toLowerCase();
620
1229
  const rest = parts.slice(1).join(" ");
@@ -641,46 +1250,100 @@ async function chat() {
641
1250
  }
642
1251
  continue;
643
1252
  }
1253
+ if (cmd === "output") {
1254
+ const want = arg;
1255
+ if (want === "markdown" || want === "md") {
1256
+ cfg.outputFormat = "markdown";
1257
+ saveConfig(cfg);
1258
+ console.log(c.green(" output format: markdown"));
1259
+ } else if (want === "html") {
1260
+ cfg.outputFormat = "html";
1261
+ saveConfig(cfg);
1262
+ console.log(c.green(" output format: html"));
1263
+ } else if (want) console.log(c.dim(" usage: /output markdown | html"));
1264
+ else console.log(c.dim(` output format: ${cfg.outputFormat} (change with /output markdown | html)`));
1265
+ continue;
1266
+ }
1267
+ if (cmd === "save") {
1268
+ if (!lastReply) {
1269
+ console.log(c.dim(" nothing to save yet. Ask something first."));
1270
+ continue;
1271
+ }
1272
+ try {
1273
+ const path = saveReply(lastReply, cfg.outputFormat, rest || void 0);
1274
+ console.log(c.green(` saved ${cfg.outputFormat} to ${path}`));
1275
+ } catch (e) {
1276
+ console.log(c.red(` could not save: ${e.message}`));
1277
+ }
1278
+ continue;
1279
+ }
644
1280
  if (cmd === "setting" || cmd === "settings") {
645
1281
  cfg = await runSettings(ask);
646
- if (!cfg.brains[current].enabled && cfg.defaultBrain) current = cfg.defaultBrain;
647
- console.log(c.dim(` brain: ${cfg.brains[current].label}`));
1282
+ const next = cfg.defaultBrain ? resolveActive(cfg, cfg.defaultBrain) : null;
1283
+ const stillValid = resolveActive(cfg, active.id);
1284
+ active = stillValid ?? next ?? active;
1285
+ console.log(c.dim(` brain: ${active.label}`));
648
1286
  continue;
649
1287
  }
650
1288
  if (cmd === "brain") {
651
- const enabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
652
- if (arg && enabled.includes(arg)) {
653
- current = arg;
654
- const turns = Math.floor(history.length / 2);
655
- console.log(c.green(` switched to ${cfg.brains[current].label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
1289
+ const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
1290
+ const apiIds = cfg.apiBrains.map((a) => a.id);
1291
+ const all = [...cliEnabled, ...apiIds];
1292
+ if (arg && all.includes(arg)) {
1293
+ const next = resolveActive(cfg, arg);
1294
+ if (next) {
1295
+ active = next;
1296
+ const turns = Math.floor(history.length / 2);
1297
+ console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
1298
+ }
656
1299
  } else if (arg) {
657
- console.log(c.dim(` "${arg}" is not available. Installed: ${enabled.join(", ") || "none"}`));
1300
+ console.log(c.dim(` "${arg}" is not available. Available: ${all.join(", ") || "none"}`));
658
1301
  } else {
659
- console.log(c.dim(" brains: " + enabled.map((id) => id === current ? c.accent(id + " (current)") : id).join(" ")));
1302
+ const cfgRef = cfg;
1303
+ const activeId = active.id;
1304
+ const labels = all.map((id) => {
1305
+ const a = findApiBrain(cfgRef, id);
1306
+ const shown = a ? `${id} (api: ${a.provider}/${a.model})` : id;
1307
+ return id === activeId ? c.accent(shown + " (current)") : shown;
1308
+ });
1309
+ console.log(c.dim(" brains: " + labels.join(" ")));
660
1310
  console.log(c.dim(" usage: /brain <name>"));
661
1311
  }
662
1312
  continue;
663
1313
  }
1314
+ if (cmd === "skill" || cmd === "skills") {
1315
+ const names = listSkills().map((s) => s.name);
1316
+ console.log(c.dim(" skills: " + (names.join(" ") || "none") + " usage: /skill <name> [input]"));
1317
+ continue;
1318
+ }
664
1319
  console.log(c.dim(` unknown command: /${cmd} (try /help)`));
665
1320
  continue;
666
1321
  }
667
- const brain = cfg.brains[current];
668
- if (!isInstalled(brain.command)) {
669
- console.log(c.red(` ${brain.label} (${brain.command}) is not on your PATH. Use /brain to switch or /setting.`));
1322
+ if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
1323
+ console.log(c.red(` ${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Use /brain to switch or /setting.`));
1324
+ continue;
1325
+ }
1326
+ if (active.kind === "api" && !resolveApiKey(active.brain)) {
1327
+ console.log(c.red(` ${active.label} has no API key. Use /setting to add one, or /brain to switch.`));
670
1328
  continue;
671
1329
  }
672
1330
  const remembered = await recall(line, session, 4);
673
- const label = remembered.length ? `${brain.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${brain.label} is thinking...`;
1331
+ const label = remembered.length ? `${active.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${active.label} is thinking...`;
674
1332
  console.log(c.dim(` ${label}`) + "\n");
1333
+ const skillsBlock = skillsPromptBlock();
1334
+ const base = renderPrompt(history, line, remembered);
1335
+ const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
675
1336
  let streamed = false;
676
- const res = await runBrain(brain, renderPrompt(history, line, remembered), (chunk) => {
1337
+ const onChunk = (chunk) => {
677
1338
  streamed = true;
678
1339
  process.stdout.write(chunk);
679
- });
1340
+ };
1341
+ const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
680
1342
  if (res.ok) {
681
1343
  if (!streamed) console.log(res.text);
682
1344
  if (!res.text.endsWith("\n")) console.log("");
683
1345
  console.log("");
1346
+ lastReply = res.text;
684
1347
  history.push({ role: "user", content: line });
685
1348
  history.push({ role: "assistant", content: res.text });
686
1349
  const now = Date.now();
@@ -793,8 +1456,959 @@ async function memoryCmd(sub, rest = []) {
793
1456
  close();
794
1457
  }
795
1458
 
1459
+ // src/commands/skill.ts
1460
+ import {
1461
+ mkdirSync as mkdirSync4,
1462
+ writeFileSync as writeFileSync6,
1463
+ readFileSync as readFileSync6,
1464
+ existsSync as existsSync6,
1465
+ readdirSync as readdirSync2,
1466
+ statSync as statSync3,
1467
+ rmSync as rmSync2,
1468
+ cpSync,
1469
+ mkdtempSync
1470
+ } from "fs";
1471
+ import { join as join7, resolve } from "path";
1472
+ import { tmpdir } from "os";
1473
+ import { spawnSync as spawnSync2 } from "child_process";
1474
+ function usage() {
1475
+ console.log(c.dim([
1476
+ "",
1477
+ " " + c.accent("holt skill") + c.dim(" - manage SKILL.md skills"),
1478
+ "",
1479
+ " holt skill list list installed skills",
1480
+ " holt skill show <name> print a skill",
1481
+ " holt skill create <name> [--global] scaffold a new skill",
1482
+ " holt skill add <source> [--global] install from a git URL or local path",
1483
+ " holt skill remove <name> delete a skill",
1484
+ "",
1485
+ " --global installs into ~/.holt/skills (available in every folder).",
1486
+ " Without it, skills live in this folder at ./.holt/skills.",
1487
+ ""
1488
+ ].join("\n")));
1489
+ }
1490
+ var SKILL_TEMPLATE = (name) => [
1491
+ "---",
1492
+ `name: ${name}`,
1493
+ "description: One sentence on what this skill does and when to use it.",
1494
+ "---",
1495
+ "",
1496
+ `# ${name}`,
1497
+ "",
1498
+ "## When to use",
1499
+ "",
1500
+ "Describe the situations where this skill applies.",
1501
+ "",
1502
+ "## Instructions",
1503
+ "",
1504
+ "1. Step one.",
1505
+ "2. Step two.",
1506
+ "3. Step three.",
1507
+ "",
1508
+ "## Example",
1509
+ "",
1510
+ "Show a short example of the skill in action.",
1511
+ ""
1512
+ ].join("\n");
1513
+ function isGitUrl(s) {
1514
+ return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
1515
+ }
1516
+ function findSkillDir(root) {
1517
+ if (existsSync6(join7(root, "SKILL.md"))) return root;
1518
+ let subs = [];
1519
+ try {
1520
+ subs = readdirSync2(root).filter((f) => {
1521
+ try {
1522
+ return statSync3(join7(root, f)).isDirectory();
1523
+ } catch {
1524
+ return false;
1525
+ }
1526
+ });
1527
+ } catch {
1528
+ return null;
1529
+ }
1530
+ const withSkill = subs.filter((f) => existsSync6(join7(root, f, "SKILL.md")));
1531
+ if (withSkill.length === 1) return join7(root, withSkill[0]);
1532
+ return null;
1533
+ }
1534
+ function cmdList() {
1535
+ const skills = listSkills();
1536
+ if (skills.length === 0) {
1537
+ console.log(c.dim('\n No skills yet. Create one with "holt skill create <name>".\n'));
1538
+ return;
1539
+ }
1540
+ const nameW = Math.max(4, ...skills.map((s) => s.name.length));
1541
+ const scopeW = Math.max(5, ...skills.map((s) => s.scope.length));
1542
+ console.log("\n" + c.accent("Skills") + c.dim(" (workspace shadows global on name clash)"));
1543
+ console.log(
1544
+ " " + c.dim("name".padEnd(nameW)) + " " + c.dim("scope".padEnd(scopeW)) + " " + c.dim("description")
1545
+ );
1546
+ for (const s of skills) {
1547
+ const desc = s.description.length > 68 ? s.description.slice(0, 67) + "\u2026" : s.description;
1548
+ console.log(
1549
+ " " + c.bold(s.name.padEnd(nameW)) + " " + (s.scope === "workspace" ? c.cyan(s.scope.padEnd(scopeW)) : c.dim(s.scope.padEnd(scopeW))) + " " + (desc || c.dim("(no description)"))
1550
+ );
1551
+ }
1552
+ console.log(c.dim("\n holt skill show <name> print a skill"));
1553
+ console.log(c.dim(" In chat: /skill <name> [input] run a skill\n"));
1554
+ }
1555
+ function cmdShow(name) {
1556
+ if (!name) {
1557
+ console.log(c.dim("\n Usage: holt skill show <name>\n"));
1558
+ return;
1559
+ }
1560
+ const loaded = loadSkill(name);
1561
+ if (!loaded) {
1562
+ console.log(c.dim(`
1563
+ No skill named "${sanitizeName(name)}". Try "holt skill list".
1564
+ `));
1565
+ return;
1566
+ }
1567
+ const { skill, body } = loaded;
1568
+ console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
1569
+ if (skill.description) console.log(" " + skill.description);
1570
+ console.log(c.dim(" " + join7(skill.dir, "SKILL.md")));
1571
+ console.log("");
1572
+ console.log(c.dim(body));
1573
+ console.log("");
1574
+ }
1575
+ function cmdCreate(name, global) {
1576
+ const clean = sanitizeName(name || "");
1577
+ if (!clean) {
1578
+ console.log(c.dim("\n Usage: holt skill create <name> [--global]\n"));
1579
+ return;
1580
+ }
1581
+ const scope = global ? "global" : "workspace";
1582
+ const dir = join7(skillsRoot(scope), clean);
1583
+ if (existsSync6(dir)) {
1584
+ console.log(c.red(`
1585
+ A ${scope} skill named "${clean}" already exists.`));
1586
+ console.log(c.dim(" " + dir + "\n"));
1587
+ return;
1588
+ }
1589
+ mkdirSync4(dir, { recursive: true });
1590
+ writeFileSync6(join7(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
1591
+ console.log(c.green(`
1592
+ Created ${scope} skill "${clean}".`));
1593
+ console.log(c.dim(" " + join7(dir, "SKILL.md")));
1594
+ console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
1595
+ }
1596
+ function cmdAdd(source, global) {
1597
+ if (!source) {
1598
+ console.log(c.dim("\n Usage: holt skill add <git-url|path> [--global]\n"));
1599
+ return;
1600
+ }
1601
+ const scope = global ? "global" : "workspace";
1602
+ let fetched = "";
1603
+ let tempDir = "";
1604
+ try {
1605
+ if (isGitUrl(source)) {
1606
+ tempDir = mkdtempSync(join7(tmpdir(), "holt-skill-"));
1607
+ console.log(c.dim(`
1608
+ Cloning ${source} ...`));
1609
+ const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
1610
+ if (res.status !== 0) {
1611
+ console.log(c.red(" Clone failed. Check the URL and that git is installed.\n"));
1612
+ return;
1613
+ }
1614
+ fetched = tempDir;
1615
+ } else {
1616
+ fetched = resolve(source);
1617
+ if (!existsSync6(fetched)) {
1618
+ console.log(c.red(`
1619
+ No such path: ${fetched}
1620
+ `));
1621
+ return;
1622
+ }
1623
+ }
1624
+ const skillDir = findSkillDir(fetched);
1625
+ if (!skillDir) {
1626
+ console.log(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
1627
+ return;
1628
+ }
1629
+ let skillName = "";
1630
+ try {
1631
+ const { data } = parseFrontmatter(readFileSync6(join7(skillDir, "SKILL.md"), "utf8"));
1632
+ skillName = sanitizeName(data.name || "");
1633
+ } catch {
1634
+ skillName = "";
1635
+ }
1636
+ if (!skillName) skillName = sanitizeName(skillDir.split(/[\\/]/).pop() || "");
1637
+ if (!skillName) {
1638
+ console.log(c.red("\n Could not determine a valid skill name.\n"));
1639
+ return;
1640
+ }
1641
+ const dest = join7(skillsRoot(scope), skillName);
1642
+ if (existsSync6(dest)) {
1643
+ console.log(c.red(`
1644
+ A ${scope} skill named "${skillName}" already exists.`));
1645
+ console.log(c.dim(" " + dest + "\n"));
1646
+ return;
1647
+ }
1648
+ mkdirSync4(skillsRoot(scope), { recursive: true });
1649
+ cpSync(skillDir, dest, { recursive: true });
1650
+ const nestedGit = join7(dest, ".git");
1651
+ if (existsSync6(nestedGit)) rmSync2(nestedGit, { recursive: true, force: true });
1652
+ console.log(c.green(`
1653
+ Installed ${scope} skill "${skillName}".`));
1654
+ console.log(c.dim(" " + join7(dest, "SKILL.md") + "\n"));
1655
+ } finally {
1656
+ if (tempDir && existsSync6(tempDir)) {
1657
+ try {
1658
+ rmSync2(tempDir, { recursive: true, force: true });
1659
+ } catch {
1660
+ }
1661
+ }
1662
+ }
1663
+ }
1664
+ async function cmdRemove(name, ask) {
1665
+ const clean = sanitizeName(name || "");
1666
+ if (!clean) {
1667
+ console.log(c.dim("\n Usage: holt skill remove <name>\n"));
1668
+ return;
1669
+ }
1670
+ const wsDir = join7(skillsRoot("workspace"), clean);
1671
+ const globalDir = join7(skillsRoot("global"), clean);
1672
+ const target = existsSync6(wsDir) ? wsDir : existsSync6(globalDir) ? globalDir : "";
1673
+ if (!target) {
1674
+ console.log(c.dim(`
1675
+ No skill named "${clean}" found.
1676
+ `));
1677
+ return;
1678
+ }
1679
+ const scope = target === wsDir ? "workspace" : "global";
1680
+ const a = (await ask(`
1681
+ Delete ${scope} skill "${clean}"? [y/N] `) ?? "").trim().toLowerCase();
1682
+ if (a === "y" || a === "yes") {
1683
+ rmSync2(target, { recursive: true, force: true });
1684
+ console.log(c.green(" Removed.\n"));
1685
+ } else {
1686
+ console.log(c.dim(" Kept.\n"));
1687
+ }
1688
+ }
1689
+ async function skillCmd(sub, rest = []) {
1690
+ const { ask, close } = createReader();
1691
+ if (!await ensureTrusted(ask)) {
1692
+ close();
1693
+ return;
1694
+ }
1695
+ const global = rest.includes("--global");
1696
+ const args = rest.filter((a) => a !== "--global");
1697
+ const action = (sub || "").toLowerCase();
1698
+ try {
1699
+ switch (action) {
1700
+ case "list":
1701
+ case "ls":
1702
+ cmdList();
1703
+ break;
1704
+ case "show":
1705
+ case "view":
1706
+ cmdShow(args[0]);
1707
+ break;
1708
+ case "create":
1709
+ case "new":
1710
+ cmdCreate(args[0], global);
1711
+ break;
1712
+ case "add":
1713
+ case "install":
1714
+ cmdAdd(args[0], global);
1715
+ break;
1716
+ case "remove":
1717
+ case "rm":
1718
+ case "delete":
1719
+ await cmdRemove(args[0], ask);
1720
+ break;
1721
+ default:
1722
+ usage();
1723
+ }
1724
+ } finally {
1725
+ close();
1726
+ }
1727
+ }
1728
+
1729
+ // src/commands/graph.ts
1730
+ import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync5 } from "fs";
1731
+ import { join as join8, dirname, resolve as resolve2 } from "path";
1732
+ import { spawn as spawn3 } from "child_process";
1733
+
1734
+ // src/graphview.ts
1735
+ var STOPWORDS = /* @__PURE__ */ new Set([
1736
+ "the",
1737
+ "and",
1738
+ "that",
1739
+ "have",
1740
+ "this",
1741
+ "with",
1742
+ "from",
1743
+ "they",
1744
+ "what",
1745
+ "when",
1746
+ "your",
1747
+ "would",
1748
+ "there",
1749
+ "their",
1750
+ "will",
1751
+ "about",
1752
+ "which",
1753
+ "them",
1754
+ "then",
1755
+ "than",
1756
+ "were",
1757
+ "been",
1758
+ "being",
1759
+ "into",
1760
+ "over",
1761
+ "some",
1762
+ "such",
1763
+ "only",
1764
+ "also",
1765
+ "because",
1766
+ "these",
1767
+ "those",
1768
+ "here",
1769
+ "more",
1770
+ "most",
1771
+ "other",
1772
+ "want",
1773
+ "need",
1774
+ "like",
1775
+ "just",
1776
+ "make",
1777
+ "made",
1778
+ "does",
1779
+ "done",
1780
+ "each",
1781
+ "very",
1782
+ "much",
1783
+ "many",
1784
+ "could",
1785
+ "should",
1786
+ "shall",
1787
+ "must",
1788
+ "might",
1789
+ "even",
1790
+ "still",
1791
+ "while",
1792
+ "where",
1793
+ "good",
1794
+ "know",
1795
+ "take",
1796
+ "them",
1797
+ "thing",
1798
+ "things",
1799
+ "okay",
1800
+ "yeah",
1801
+ "sure",
1802
+ "help",
1803
+ "please",
1804
+ "thanks",
1805
+ "lets"
1806
+ ]);
1807
+ function cosine2(a, b) {
1808
+ let dot = 0;
1809
+ let na = 0;
1810
+ let nb = 0;
1811
+ const n = Math.min(a.length, b.length);
1812
+ for (let i = 0; i < n; i++) {
1813
+ const x = a[i];
1814
+ const y = b[i];
1815
+ dot += x * y;
1816
+ na += x * x;
1817
+ nb += y * y;
1818
+ }
1819
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
1820
+ }
1821
+ function firstLine(s, max = 60) {
1822
+ const flat = s.replace(/\s+/g, " ").trim();
1823
+ return flat.length > max ? flat.slice(0, max - 1).trimEnd() + "\u2026" : flat;
1824
+ }
1825
+ function words(s) {
1826
+ return s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w));
1827
+ }
1828
+ var SEMANTIC_MIN = 0.55;
1829
+ var SEMANTIC_PAIR_CAP = 300;
1830
+ var SEMANTIC_TURN_CAP = 2e3;
1831
+ var CONCEPT_CAP = 40;
1832
+ var CONCEPT_MIN_TURNS = 2;
1833
+ function buildGraph(turns) {
1834
+ const nodes = [];
1835
+ const edges = [];
1836
+ for (const t of turns) {
1837
+ nodes.push({
1838
+ id: t.id,
1839
+ kind: "turn",
1840
+ label: firstLine(t.content),
1841
+ role: t.role,
1842
+ session: t.session,
1843
+ ts: t.ts,
1844
+ content: t.content
1845
+ });
1846
+ }
1847
+ const bySession = /* @__PURE__ */ new Map();
1848
+ for (const t of turns) {
1849
+ const arr = bySession.get(t.session);
1850
+ if (arr) arr.push(t);
1851
+ else bySession.set(t.session, [t]);
1852
+ }
1853
+ for (const arr of bySession.values()) {
1854
+ for (let i = 1; i < arr.length; i++) {
1855
+ const prev = arr[i - 1];
1856
+ const cur = arr[i];
1857
+ edges.push({ source: prev.id, target: cur.id, kind: "sequential", width: 1 });
1858
+ }
1859
+ }
1860
+ if (turns.length <= SEMANTIC_TURN_CAP) {
1861
+ const withEmb = turns.filter((t) => Array.isArray(t.emb) && t.emb.length > 0);
1862
+ const pairs = [];
1863
+ for (let i = 0; i < withEmb.length; i++) {
1864
+ const a = withEmb[i];
1865
+ for (let j = i + 1; j < withEmb.length; j++) {
1866
+ const b = withEmb[j];
1867
+ const sim = cosine2(a.emb, b.emb);
1868
+ if (sim >= SEMANTIC_MIN) pairs.push({ source: a.id, target: b.id, sim });
1869
+ }
1870
+ }
1871
+ pairs.sort((x, y) => y.sim - x.sim);
1872
+ for (const p of pairs.slice(0, SEMANTIC_PAIR_CAP)) {
1873
+ const width = 1.5 + (p.sim - SEMANTIC_MIN) / (1 - SEMANTIC_MIN) * 3.5;
1874
+ edges.push({ source: p.source, target: p.target, kind: "semantic", width: Math.round(width * 100) / 100 });
1875
+ }
1876
+ }
1877
+ const conceptTurns = /* @__PURE__ */ new Map();
1878
+ for (const t of turns) {
1879
+ for (const w of new Set(words(t.content))) {
1880
+ const set = conceptTurns.get(w);
1881
+ if (set) set.add(t.id);
1882
+ else conceptTurns.set(w, /* @__PURE__ */ new Set([t.id]));
1883
+ }
1884
+ }
1885
+ const concepts = [...conceptTurns.entries()].filter(([, ids]) => ids.size >= CONCEPT_MIN_TURNS).sort((a, b) => b[1].size - a[1].size || a[0].localeCompare(b[0])).slice(0, CONCEPT_CAP);
1886
+ for (const [word, ids] of concepts) {
1887
+ const cid = "concept:" + word;
1888
+ nodes.push({ id: cid, kind: "concept", label: word, freq: ids.size });
1889
+ for (const turnId of ids) {
1890
+ edges.push({ source: cid, target: turnId, kind: "concept", width: 1 });
1891
+ }
1892
+ }
1893
+ return { nodes, edges };
1894
+ }
1895
+ function escapeForScript(json) {
1896
+ return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\!--");
1897
+ }
1898
+ function escapeHtml2(s) {
1899
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1900
+ }
1901
+ function renderGraphHtml(graph2, meta) {
1902
+ const dataJson = escapeForScript(JSON.stringify(graph2));
1903
+ const wsSafe = escapeHtml2(meta.workspace);
1904
+ return `<!doctype html>
1905
+ <html lang="en">
1906
+ <head>
1907
+ <meta charset="utf-8" />
1908
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1909
+ <title>Holt memory graph</title>
1910
+ <style>
1911
+ :root {
1912
+ --bg: #0f1115;
1913
+ --panel: #171a21;
1914
+ --text: #e7e9ee;
1915
+ --muted: #9aa3b2;
1916
+ --amber: #f0b91e;
1917
+ --cyan: #35d0d6;
1918
+ --violet: #9a8cff;
1919
+ --line: #232733;
1920
+ }
1921
+ * { box-sizing: border-box; }
1922
+ html, body { margin: 0; height: 100%; }
1923
+ body {
1924
+ background: var(--bg); color: var(--text); overflow: hidden;
1925
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1926
+ -webkit-font-smoothing: antialiased;
1927
+ }
1928
+ header {
1929
+ position: fixed; top: 0; left: 0; right: 0; z-index: 5;
1930
+ display: flex; align-items: center; gap: 16px;
1931
+ padding: 10px 16px; background: rgba(15,17,21,0.82);
1932
+ border-bottom: 1px solid var(--line); backdrop-filter: blur(6px);
1933
+ }
1934
+ .wordmark { color: var(--amber); font-weight: 700; font-size: 18px; letter-spacing: 1px; }
1935
+ .ws { color: var(--muted); font-size: 12px; max-width: 34vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1936
+ .stats { color: var(--muted); font-size: 12px; margin-left: auto; }
1937
+ .stats b { color: var(--text); font-weight: 600; }
1938
+ #search {
1939
+ background: var(--panel); border: 1px solid var(--line); color: var(--text);
1940
+ border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 13px; width: 220px;
1941
+ }
1942
+ #search:focus { outline: none; border-color: var(--amber); }
1943
+ #stage { position: fixed; inset: 0; display: block; }
1944
+ #tooltip {
1945
+ position: fixed; z-index: 8; pointer-events: none; display: none;
1946
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
1947
+ padding: 6px 9px; font-size: 12px; max-width: 320px; color: var(--text);
1948
+ box-shadow: 0 6px 20px rgba(0,0,0,0.5);
1949
+ }
1950
+ #tooltip .t-meta { color: var(--muted); font-size: 11px; margin-top: 3px; }
1951
+ #panel {
1952
+ position: fixed; top: 0; right: 0; bottom: 0; z-index: 7; width: 380px; max-width: 92vw;
1953
+ background: var(--panel); border-left: 1px solid var(--line);
1954
+ transform: translateX(100%); transition: transform 160ms ease;
1955
+ display: flex; flex-direction: column; padding: 18px; overflow-y: auto;
1956
+ }
1957
+ #panel.open { transform: translateX(0); }
1958
+ #panel .p-close { position: absolute; top: 12px; right: 14px; cursor: pointer; color: var(--muted); font-size: 18px; border: none; background: none; }
1959
+ #panel .p-close:hover { color: var(--text); }
1960
+ .chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
1961
+ .chip .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
1962
+ #p-role { font-size: 13px; color: var(--amber); text-transform: uppercase; letter-spacing: 1px; margin: 2px 0 10px; }
1963
+ #p-content { white-space: pre-wrap; word-break: break-word; line-height: 1.5; font-size: 13px; color: var(--text); }
1964
+ #p-time { color: var(--muted); font-size: 12px; margin-top: 14px; }
1965
+ #legend {
1966
+ position: fixed; left: 12px; bottom: 12px; z-index: 6;
1967
+ background: rgba(23,26,33,0.85); border: 1px solid var(--line); border-radius: 8px;
1968
+ padding: 10px 12px; font-size: 12px; color: var(--muted);
1969
+ }
1970
+ #legend .row { display: flex; align-items: center; gap: 8px; margin: 3px 0; }
1971
+ #legend .swatch { width: 11px; height: 11px; border-radius: 50%; }
1972
+ #hint { position: fixed; right: 12px; bottom: 12px; z-index: 6; color: var(--muted); font-size: 11px; opacity: 0.7; }
1973
+ #empty { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; color: var(--muted); }
1974
+ </style>
1975
+ </head>
1976
+ <body>
1977
+ <header>
1978
+ <span class="wordmark">Holt</span>
1979
+ <span class="ws" title="${wsSafe}">${wsSafe}</span>
1980
+ <input id="search" type="search" placeholder="search memory..." autocomplete="off" spellcheck="false" />
1981
+ <span class="stats"><b>${meta.turns}</b> turns &middot; <b>${meta.sessions}</b> sessions &middot; <b>${meta.concepts}</b> concepts &middot; <b>${meta.edges}</b> edges</span>
1982
+ </header>
1983
+
1984
+ <canvas id="stage"></canvas>
1985
+
1986
+ <div id="tooltip"></div>
1987
+
1988
+ <aside id="panel">
1989
+ <button class="p-close" id="p-close" aria-label="close">&times;</button>
1990
+ <div class="chip"><span class="dot" id="p-dot"></span><span id="p-session"></span></div>
1991
+ <div id="p-role"></div>
1992
+ <div id="p-content"></div>
1993
+ <div id="p-time"></div>
1994
+ </aside>
1995
+
1996
+ <div id="legend">
1997
+ <div class="row"><span class="swatch" style="background:var(--amber)"></span>you</div>
1998
+ <div class="row"><span class="swatch" style="background:var(--cyan)"></span>assistant</div>
1999
+ <div class="row"><span class="swatch" style="background:var(--violet)"></span>concept</div>
2000
+ <div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session</div>
2001
+ </div>
2002
+ <div id="hint">drag to pan &middot; wheel to zoom &middot; click a node &middot; Esc to clear</div>
2003
+ <div id="empty">This graph has no nodes yet.</div>
2004
+
2005
+ <script id="graph-data" type="application/json">${dataJson}</script>
2006
+ <script>
2007
+ (function () {
2008
+ "use strict";
2009
+ var DATA = JSON.parse(document.getElementById("graph-data").textContent);
2010
+ var nodes = DATA.nodes || [];
2011
+ var edges = DATA.edges || [];
2012
+
2013
+ var canvas = document.getElementById("stage");
2014
+ var ctx = canvas.getContext("2d");
2015
+ var tooltip = document.getElementById("tooltip");
2016
+ var panel = document.getElementById("panel");
2017
+ var searchBox = document.getElementById("search");
2018
+ var reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2019
+
2020
+ if (!nodes.length) { document.getElementById("empty").style.display = "flex"; return; }
2021
+
2022
+ // ---- color per session (distinct hues) ----
2023
+ var sessionList = [];
2024
+ nodes.forEach(function (n) { if (n.session && sessionList.indexOf(n.session) < 0) sessionList.push(n.session); });
2025
+ function sessionHue(session) {
2026
+ var i = sessionList.indexOf(session);
2027
+ if (i < 0) i = 0;
2028
+ return (i * 137.508) % 360; // golden-angle spread
2029
+ }
2030
+
2031
+ // ---- index + degree ----
2032
+ var byId = {};
2033
+ nodes.forEach(function (n) { byId[n.id] = n; n._deg = 0; });
2034
+ var links = [];
2035
+ edges.forEach(function (e) {
2036
+ var s = byId[e.source], t = byId[e.target];
2037
+ if (!s || !t) return;
2038
+ s._deg++; t._deg++;
2039
+ links.push({ s: s, t: t, kind: e.kind, width: e.width });
2040
+ });
2041
+
2042
+ function radiusOf(n) {
2043
+ var base = n.kind === "concept" ? 3 : 5;
2044
+ return base + Math.min(9, Math.sqrt(n._deg) * 1.6);
2045
+ }
2046
+
2047
+ // ---- initial layout: spread on a spiral so physics has somewhere to start ----
2048
+ nodes.forEach(function (n, i) {
2049
+ var ang = i * 2.399963;
2050
+ var rad = 22 * Math.sqrt(i);
2051
+ n.x = Math.cos(ang) * rad;
2052
+ n.y = Math.sin(ang) * rad;
2053
+ n.vx = 0; n.vy = 0;
2054
+ });
2055
+
2056
+ // ---- physics ----
2057
+ var REPULSION = 5200, SPRING = 0.02, GRAVITY = 0.015, DAMP = 0.86;
2058
+ var restLen = { sequential: 60, semantic: 45, concept: 70 };
2059
+
2060
+ function step() {
2061
+ var i, j, n, m, dx, dy, d2, d, f;
2062
+ // repulsion (all pairs; fine for the sizes Holt produces)
2063
+ for (i = 0; i < nodes.length; i++) {
2064
+ n = nodes[i];
2065
+ for (j = i + 1; j < nodes.length; j++) {
2066
+ m = nodes[j];
2067
+ dx = n.x - m.x; dy = n.y - m.y;
2068
+ d2 = dx * dx + dy * dy || 0.01;
2069
+ if (d2 > 90000) continue; // ignore far-apart pairs
2070
+ d = Math.sqrt(d2);
2071
+ f = REPULSION / d2;
2072
+ var ux = dx / d, uy = dy / d;
2073
+ n.vx += ux * f; n.vy += uy * f;
2074
+ m.vx -= ux * f; m.vy -= uy * f;
2075
+ }
2076
+ }
2077
+ // springs
2078
+ for (i = 0; i < links.length; i++) {
2079
+ var lk = links[i];
2080
+ dx = lk.t.x - lk.s.x; dy = lk.t.y - lk.s.y;
2081
+ d = Math.sqrt(dx * dx + dy * dy) || 0.01;
2082
+ var rest = restLen[lk.kind] || 60;
2083
+ f = (d - rest) * SPRING;
2084
+ var ax = (dx / d) * f, ay = (dy / d) * f;
2085
+ lk.s.vx += ax; lk.s.vy += ay;
2086
+ lk.t.vx -= ax; lk.t.vy -= ay;
2087
+ }
2088
+ // gravity to center + integrate
2089
+ var moved = 0;
2090
+ for (i = 0; i < nodes.length; i++) {
2091
+ n = nodes[i];
2092
+ n.vx -= n.x * GRAVITY; n.vy -= n.y * GRAVITY;
2093
+ n.vx *= DAMP; n.vy *= DAMP;
2094
+ if (n === dragNode) continue;
2095
+ n.x += n.vx; n.y += n.vy;
2096
+ moved += Math.abs(n.vx) + Math.abs(n.vy);
2097
+ }
2098
+ return moved / nodes.length;
2099
+ }
2100
+
2101
+ // ---- view transform ----
2102
+ var view = { x: 0, y: 0, scale: 1 };
2103
+ var W = 0, H = 0, dpr = Math.max(1, window.devicePixelRatio || 1);
2104
+ function resize() {
2105
+ W = window.innerWidth; H = window.innerHeight;
2106
+ canvas.width = Math.floor(W * dpr); canvas.height = Math.floor(H * dpr);
2107
+ canvas.style.width = W + "px"; canvas.style.height = H + "px";
2108
+ }
2109
+ window.addEventListener("resize", resize);
2110
+ resize();
2111
+ view.x = W / 2; view.y = H / 2;
2112
+
2113
+ function worldToScreen(x, y) { return { x: x * view.scale + view.x, y: y * view.scale + view.y }; }
2114
+ function screenToWorld(x, y) { return { x: (x - view.x) / view.scale, y: (y - view.y) / view.scale }; }
2115
+
2116
+ // ---- selection + search state ----
2117
+ var selected = null; // a node id
2118
+ var highlightSet = null; // Set of ids to emphasise (search or concept click)
2119
+ var query = "";
2120
+
2121
+ function roleColor(n) {
2122
+ if (n.kind === "concept") return "#9a8cff";
2123
+ return n.role === "user" ? "#f0b91e" : "#35d0d6";
2124
+ }
2125
+
2126
+ function matchesQuery(n) {
2127
+ if (!query) return false;
2128
+ var hay = (n.label || "") + " " + (n.content || "");
2129
+ return hay.toLowerCase().indexOf(query) >= 0;
2130
+ }
2131
+
2132
+ function draw() {
2133
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2134
+ ctx.clearRect(0, 0, W, H);
2135
+
2136
+ // edges
2137
+ for (var i = 0; i < links.length; i++) {
2138
+ var lk = links[i];
2139
+ var a = worldToScreen(lk.s.x, lk.s.y), b = worldToScreen(lk.t.x, lk.t.y);
2140
+ var dim = 0.16;
2141
+ if (highlightSet) {
2142
+ if (highlightSet.has(lk.s.id) || highlightSet.has(lk.t.id)) dim = 0.55; else dim = 0.05;
2143
+ } else {
2144
+ dim = lk.kind === "semantic" ? 0.4 : lk.kind === "concept" ? 0.14 : 0.22;
2145
+ }
2146
+ ctx.globalAlpha = dim;
2147
+ ctx.lineWidth = Math.max(0.4, lk.width * view.scale * 0.5);
2148
+ ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : "#4a5262";
2149
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
2150
+ }
2151
+ ctx.globalAlpha = 1;
2152
+
2153
+ // nodes
2154
+ for (var k = 0; k < nodes.length; k++) {
2155
+ var n = nodes[k];
2156
+ var p = worldToScreen(n.x, n.y);
2157
+ var r = radiusOf(n) * view.scale;
2158
+ var isHot = (highlightSet && highlightSet.has(n.id)) || (query && matchesQuery(n));
2159
+ var faded = (highlightSet && !highlightSet.has(n.id)) || (query && !matchesQuery(n));
2160
+ ctx.globalAlpha = faded ? 0.18 : 1;
2161
+
2162
+ // session ring
2163
+ if (n.kind === "turn") {
2164
+ ctx.beginPath();
2165
+ ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
2166
+ ctx.strokeStyle = "hsl(" + sessionHue(n.session) + ",70%,58%)";
2167
+ ctx.lineWidth = 1.6; ctx.stroke();
2168
+ }
2169
+
2170
+ ctx.beginPath();
2171
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
2172
+ ctx.fillStyle = roleColor(n);
2173
+ ctx.fill();
2174
+
2175
+ if (n.id === selected || isHot) {
2176
+ ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.stroke();
2177
+ }
2178
+
2179
+ // concept labels always; turn labels when zoomed in
2180
+ if (n.kind === "concept" || view.scale > 1.4) {
2181
+ ctx.globalAlpha = faded ? 0.25 : 0.9;
2182
+ ctx.fillStyle = "#e7e9ee";
2183
+ ctx.font = (n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
2184
+ ctx.fillText(n.label, p.x + r + 4, p.y + 3);
2185
+ }
2186
+ }
2187
+ ctx.globalAlpha = 1;
2188
+ }
2189
+
2190
+ // ---- animation loop with auto-settle ----
2191
+ var settleTicks = 0, running = true;
2192
+ function frame() {
2193
+ var m = step();
2194
+ draw();
2195
+ if (m < 0.05) { settleTicks++; } else { settleTicks = 0; }
2196
+ if (settleTicks > 30 && !dragNode) { running = false; return; } // rest
2197
+ requestAnimationFrame(frame);
2198
+ }
2199
+ function kick() { if (!running) { running = true; settleTicks = 0; requestAnimationFrame(frame); } }
2200
+
2201
+ if (reduceMotion) {
2202
+ for (var s = 0; s < 400; s++) step(); // settle off-screen
2203
+ draw();
2204
+ } else {
2205
+ requestAnimationFrame(frame);
2206
+ }
2207
+
2208
+ // ---- hit testing ----
2209
+ function nodeAt(sx, sy) {
2210
+ for (var i = nodes.length - 1; i >= 0; i--) {
2211
+ var n = nodes[i];
2212
+ var p = worldToScreen(n.x, n.y);
2213
+ var r = radiusOf(n) * view.scale + 3;
2214
+ var dx = sx - p.x, dy = sy - p.y;
2215
+ if (dx * dx + dy * dy <= r * r) return n;
2216
+ }
2217
+ return null;
2218
+ }
2219
+
2220
+ // ---- pointer: pan, drag node, hover ----
2221
+ var dragNode = null, panning = false, last = { x: 0, y: 0 }, downAt = null, moved = false;
2222
+
2223
+ canvas.addEventListener("mousedown", function (e) {
2224
+ var n = nodeAt(e.clientX, e.clientY);
2225
+ downAt = { x: e.clientX, y: e.clientY }; moved = false;
2226
+ if (n) { dragNode = n; } else { panning = true; }
2227
+ last = { x: e.clientX, y: e.clientY };
2228
+ kick();
2229
+ });
2230
+ window.addEventListener("mousemove", function (e) {
2231
+ if (Math.abs(e.clientX - (downAt ? downAt.x : e.clientX)) + Math.abs(e.clientY - (downAt ? downAt.y : e.clientY)) > 3) moved = true;
2232
+ if (dragNode) {
2233
+ var w = screenToWorld(e.clientX, e.clientY);
2234
+ dragNode.x = w.x; dragNode.y = w.y; dragNode.vx = 0; dragNode.vy = 0;
2235
+ kick();
2236
+ } else if (panning) {
2237
+ view.x += e.clientX - last.x; view.y += e.clientY - last.y;
2238
+ last = { x: e.clientX, y: e.clientY };
2239
+ if (!running) draw();
2240
+ } else {
2241
+ var hov = nodeAt(e.clientX, e.clientY);
2242
+ if (hov) {
2243
+ canvas.style.cursor = "pointer";
2244
+ var when = hov.ts ? new Date(hov.ts).toISOString().slice(0, 10) : "";
2245
+ var meta = hov.kind === "concept"
2246
+ ? ("concept &middot; in " + (hov.freq || 0) + " turns")
2247
+ : (esc(hov.session || "") + (when ? " &middot; " + when : ""));
2248
+ tooltip.innerHTML = "<div>" + esc(hov.label) + "</div><div class='t-meta'>" + meta + "</div>";
2249
+ tooltip.style.display = "block";
2250
+ tooltip.style.left = (e.clientX + 14) + "px";
2251
+ tooltip.style.top = (e.clientY + 14) + "px";
2252
+ } else {
2253
+ canvas.style.cursor = "default";
2254
+ tooltip.style.display = "none";
2255
+ }
2256
+ }
2257
+ });
2258
+ window.addEventListener("mouseup", function (e) {
2259
+ if (!moved) {
2260
+ var n = nodeAt(e.clientX, e.clientY);
2261
+ if (n) onNodeClick(n); else clearSelection();
2262
+ }
2263
+ dragNode = null; panning = false; downAt = null;
2264
+ if (!running) draw();
2265
+ });
2266
+
2267
+ // ---- wheel zoom, cursor-anchored ----
2268
+ canvas.addEventListener("wheel", function (e) {
2269
+ e.preventDefault();
2270
+ var before = screenToWorld(e.clientX, e.clientY);
2271
+ var factor = Math.exp(-e.deltaY * 0.0015);
2272
+ view.scale = Math.max(0.15, Math.min(6, view.scale * factor));
2273
+ var after = screenToWorld(e.clientX, e.clientY);
2274
+ view.x += (after.x - before.x) * view.scale;
2275
+ view.y += (after.y - before.y) * view.scale;
2276
+ if (!running) draw();
2277
+ }, { passive: false });
2278
+
2279
+ // ---- clicks ----
2280
+ function neighbors(id) {
2281
+ var set = new Set([id]);
2282
+ for (var i = 0; i < links.length; i++) {
2283
+ if (links[i].s.id === id) set.add(links[i].t.id);
2284
+ if (links[i].t.id === id) set.add(links[i].s.id);
2285
+ }
2286
+ return set;
2287
+ }
2288
+ function onNodeClick(n) {
2289
+ selected = n.id;
2290
+ if (n.kind === "concept") {
2291
+ highlightSet = neighbors(n.id); // light up its connected turns
2292
+ closePanel();
2293
+ } else {
2294
+ highlightSet = null;
2295
+ openPanel(n);
2296
+ }
2297
+ if (!running) draw();
2298
+ }
2299
+ function clearSelection() {
2300
+ selected = null; highlightSet = null; closePanel();
2301
+ if (!running) draw();
2302
+ }
2303
+
2304
+ // ---- side panel ----
2305
+ function openPanel(n) {
2306
+ document.getElementById("p-session").textContent = n.session || "";
2307
+ document.getElementById("p-dot").style.background = "hsl(" + sessionHue(n.session) + ",70%,58%)";
2308
+ document.getElementById("p-role").textContent = n.role || "";
2309
+ document.getElementById("p-role").style.color = roleColor(n);
2310
+ document.getElementById("p-content").textContent = n.content || n.label || "";
2311
+ document.getElementById("p-time").textContent = n.ts ? new Date(n.ts).toLocaleString() : "";
2312
+ panel.classList.add("open");
2313
+ }
2314
+ function closePanel() { panel.classList.remove("open"); }
2315
+ document.getElementById("p-close").addEventListener("click", clearSelection);
2316
+
2317
+ // ---- search ----
2318
+ searchBox.addEventListener("input", function () {
2319
+ query = searchBox.value.trim().toLowerCase();
2320
+ if (!running) draw();
2321
+ });
2322
+
2323
+ // ---- keyboard ----
2324
+ window.addEventListener("keydown", function (e) {
2325
+ if (e.key === "Escape") {
2326
+ searchBox.value = ""; query = "";
2327
+ clearSelection();
2328
+ searchBox.blur();
2329
+ }
2330
+ });
2331
+
2332
+ function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
2333
+ })();
2334
+ </script>
2335
+ </body>
2336
+ </html>
2337
+ `;
2338
+ }
2339
+
2340
+ // src/commands/graph.ts
2341
+ function parseArgs(args) {
2342
+ const opts = { open: true };
2343
+ for (let i = 0; i < args.length; i++) {
2344
+ const a = args[i];
2345
+ if (a === "--no-open") opts.open = false;
2346
+ else if (a === "--out") opts.out = args[++i];
2347
+ else if (a && a.startsWith("--out=")) opts.out = a.slice("--out=".length);
2348
+ }
2349
+ return opts;
2350
+ }
2351
+ function openInBrowser(path) {
2352
+ try {
2353
+ let cmd;
2354
+ let cmdArgs;
2355
+ if (process.platform === "darwin") {
2356
+ cmd = "open";
2357
+ cmdArgs = [path];
2358
+ } else if (process.platform === "win32") {
2359
+ cmd = "cmd";
2360
+ cmdArgs = ["/c", "start", "", path];
2361
+ } else {
2362
+ cmd = "xdg-open";
2363
+ cmdArgs = [path];
2364
+ }
2365
+ const child = spawn3(cmd, cmdArgs, { detached: true, stdio: "ignore" });
2366
+ child.on("error", () => {
2367
+ });
2368
+ child.unref();
2369
+ } catch {
2370
+ }
2371
+ }
2372
+ async function graph(args = []) {
2373
+ const { ask, close } = createReader();
2374
+ if (!await ensureTrusted(ask)) {
2375
+ close();
2376
+ return;
2377
+ }
2378
+ close();
2379
+ const opts = parseArgs(args);
2380
+ const turns = loadTurns();
2381
+ if (turns.length === 0) {
2382
+ console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
2383
+ return;
2384
+ }
2385
+ const g = buildGraph(turns);
2386
+ const conceptCount = g.nodes.filter((n) => n.kind === "concept").length;
2387
+ const sessions = new Set(turns.map((t) => t.session)).size;
2388
+ const html = renderGraphHtml(g, {
2389
+ workspace: workspace(),
2390
+ turns: turns.length,
2391
+ sessions,
2392
+ concepts: conceptCount,
2393
+ edges: g.edges.length
2394
+ });
2395
+ const outPath = opts.out ? resolve2(opts.out) : join8(wsHoltDir(), "graph.html");
2396
+ mkdirSync5(dirname(outPath), { recursive: true });
2397
+ writeFileSync7(outPath, html, "utf8");
2398
+ console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
2399
+ console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
2400
+ console.log(` edges ${g.edges.length}`);
2401
+ console.log(` file ${outPath}`);
2402
+ if (opts.open) {
2403
+ openInBrowser(outPath);
2404
+ console.log(c.dim("\n Opening in your browser...\n"));
2405
+ } else {
2406
+ console.log(c.dim("\n Open it in any browser to explore.\n"));
2407
+ }
2408
+ }
2409
+
796
2410
  // src/cli.ts
797
- var VERSION = "0.4.0";
2411
+ var VERSION = "0.5.1";
798
2412
  var BANNER = `
799
2413
  \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
800
2414
  \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
@@ -810,14 +2424,17 @@ Usage: holt <command>
810
2424
  Commands:
811
2425
  init Trust this folder, choose and install brains, sign in, set defaults
812
2426
  chat Start a session. It remembers past sessions in this folder
813
- memory Inspect memory: holt memory [search <query> | clear]
814
- setting Configure brains and your launch command (per folder)
2427
+ memory Inspect memory: holt memory [search <query> | embed | clear]
2428
+ graph See your memory as an interactive knowledge graph in the browser
2429
+ skill Manage skills: holt skill [list | show | create | add | remove]
2430
+ setting Configure brains, API brains, and your launch command (per folder)
815
2431
  login <brain> Sign in to a brain: claude, codex, or gemini
816
2432
  version Print the Holt version
817
2433
  help Show this help
818
2434
 
819
2435
  Holt runs in the folder you launch it from and asks to trust it first.
820
- Brains are the agent CLIs on your machine: claude (Claude Code), codex, gemini.
2436
+ Brains are agent CLIs on your machine (claude, codex, gemini) or direct
2437
+ API connections you add in settings.
821
2438
 
822
2439
  Docs: https://productsdecoded.com/holt
823
2440
  Repo: https://github.com/holt-os/holt
@@ -852,6 +2469,13 @@ async function main() {
852
2469
  case "memory":
853
2470
  await memoryCmd(process.argv[3], process.argv.slice(4));
854
2471
  break;
2472
+ case "skill":
2473
+ case "skills":
2474
+ await skillCmd(process.argv[3], process.argv.slice(4));
2475
+ break;
2476
+ case "graph":
2477
+ await graph(process.argv.slice(3));
2478
+ break;
855
2479
  default:
856
2480
  console.log(`
857
2481
  Unknown command: "${cmd}"`);