@holt-os/holt 0.4.0 → 0.5.0

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