@holt-os/holt 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +29 -4
  2. package/dist/cli.js +331 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ Holt is an open-source, self-hosted personal agent OS. Clone it, pick your skill
6
6
 
7
7
  > A *holt* is a small wood: a sheltered place where things are kept and grow. That's the idea. A private home for your knowledge that compounds over time.
8
8
 
9
- > **Status: early but usable.** `holt init` and `holt chat` work today. A "brain" is an agent CLI (Claude Code, Codex, or Gemini). Holt can install a missing one for you and hand off to its sign-in, and you can switch brains mid-conversation without losing context. Memory, skills, and the knowledge graph are the next phases.
9
+ > **Status: early but usable, and it remembers now.** `holt init`, `holt chat`, and persistent memory work today. A "brain" is an agent CLI (Claude Code, Codex, or Gemini). Holt can install a missing one for you and hand off to its sign-in, you can switch brains mid-conversation without losing context, and every session adds to a private memory in that folder that future sessions recall from. Skills and the knowledge graph view are the next phases.
10
10
 
11
11
  ---
12
12
 
@@ -38,6 +38,7 @@ During `holt init` you:
38
38
  2. **Choose brains** (claude, codex, gemini). Holt installs any you pick that are missing.
39
39
  3. **Sign in.** For a newly installed brain, Holt starts that tool's own login (browser or its own prompt). Holt never stores your credentials.
40
40
  4. **Pick a default** brain and, optionally, a **launch command** (a short word like `ai` that runs `holt chat`).
41
+ 5. **Enable semantic memory.** If you say yes, Holt sets up a local [Ollama](https://ollama.com) with a small embed model so recall works by meaning, fully offline.
41
42
 
42
43
  ## Using it
43
44
 
@@ -46,14 +47,37 @@ Inside `holt chat`:
46
47
  ```
47
48
  /brain list your brains and see which is active
48
49
  /brain gemini switch brain. your conversation context is kept
50
+ /memory memory stats. /memory <query> previews what recall would surface
49
51
  /setting configure brains and your launch command
50
- /clear forget the conversation so far
52
+ /clear forget this session (saved memory stays)
51
53
  /help show commands
52
54
  /exit leave
53
55
  ```
54
56
 
55
57
  The point of `/brain`: Holt owns the transcript, so you can start a thread on one model and hand it to another mid-conversation. The new brain picks up with the full context.
56
58
 
59
+ ## Memory
60
+
61
+ Every exchange is saved to `<folder>/.holt/memory/turns.jsonl`, private and local. On each message, Holt recalls the most relevant moments from your *past* sessions in that folder and hands them to the brain, so it remembers what you told it last week.
62
+
63
+ Two recall modes, picked automatically:
64
+
65
+ - **Semantic** (best): a local [Ollama](https://ollama.com) with an embedding model, which `holt init` offers to set up for you. Recall matches by meaning: asking "who owns my apartment" finds "my landlord is called Pieter". No API keys, nothing leaves your machine.
66
+ - **Keyword** (fallback): with no Ollama, recall matches by word overlap. Still useful, zero setup.
67
+
68
+ Inspect it any time:
69
+
70
+ ```bash
71
+ holt memory # stats for this folder
72
+ holt memory search <query> # find remembered moments
73
+ holt memory embed # embed older moments for semantic recall
74
+ holt memory clear # wipe this folder's memory
75
+ ```
76
+
77
+ Turns saved before semantic memory was enabled are upgraded in one pass with `holt memory embed`.
78
+
79
+ Long conversations stay cheap: only recent turns are replayed verbatim, older context comes back through recall.
80
+
57
81
  ## Brains
58
82
 
59
83
  A brain is an agent CLI installed and logged in on your machine. No API keys to paste.
@@ -70,7 +94,8 @@ A brain is an agent CLI installed and logged in on your machine. No API keys to
70
94
 
71
95
  ```
72
96
  holt init set up (trust, brains, sign-in, defaults) for this folder
73
- holt chat start a session
97
+ holt chat start a session that remembers past ones
98
+ holt memory inspect memory: holt memory [search <query> | clear]
74
99
  holt setting configure brains and launch command
75
100
  holt login <brain> sign in to claude, codex, or gemini
76
101
  holt version print version
@@ -90,7 +115,7 @@ Small strongly-typed **TypeScript core** (command dispatch, brain router, transc
90
115
  Built in always-shippable phases toward a full-vision v1:
91
116
 
92
117
  0. **Skeleton and chat**: trust, init with install and sign-in, chat, brain switching with kept context *(shipped)*
93
- 1. **Memory**: sqlite-vec store, local or cloud embeddings, recall across sessions
118
+ 1. **Memory**: per-folder store, semantic recall via local embeddings with keyword fallback, streaming replies *(shipped)*
94
119
  2. **Any LLM directly**: raw provider brains and an HTML or Markdown output toggle
95
120
  3. **Skills**: install, search, and publish in the agentskills.io format
96
121
  4. **Knowledge graph**: a view where you can see and navigate your own memory
package/dist/cli.js CHANGED
@@ -130,25 +130,35 @@ function saveConfig(cfg) {
130
130
 
131
131
  // src/brains.ts
132
132
  import { spawn, spawnSync } from "child_process";
133
+ var MAX_REPLAY_TURNS = 12;
133
134
  function isInstalled(command) {
134
135
  const finder = process.platform === "win32" ? "where" : "which";
135
136
  const res = spawnSync(finder, [command], { stdio: "ignore" });
136
137
  return res.status === 0;
137
138
  }
138
- function renderPrompt(history, message) {
139
- if (history.length === 0) return message;
140
- const lines = history.map((t) => `${t.role === "user" ? "User" : "Assistant"}: ${t.content}`);
141
- lines.push(`User: ${message}`);
142
- return [
143
- "You are continuing an ongoing conversation. Below is the transcript so far.",
144
- "Read it for context and reply only as the assistant to the final User message.",
145
- "",
146
- lines.join("\n\n"),
147
- "",
148
- "Assistant:"
149
- ].join("\n");
150
- }
151
- function runBrain(brain, prompt) {
139
+ function renderPrompt(history, message, memory = []) {
140
+ const recent = history.slice(-MAX_REPLAY_TURNS);
141
+ const parts = [];
142
+ if (recent.length || memory.length) {
143
+ parts.push(
144
+ "You are continuing an ongoing conversation. Use the context below and reply only as the assistant to the final User message."
145
+ );
146
+ }
147
+ if (memory.length) {
148
+ parts.push(
149
+ "",
150
+ "Relevant notes from this user's earlier sessions:",
151
+ ...memory.map((m) => `- (${m.turn.role}) ${m.turn.content.slice(0, 500)}`)
152
+ );
153
+ }
154
+ if (recent.length) {
155
+ parts.push("", "Transcript so far:", ...recent.map((t) => `${t.role === "user" ? "User" : "Assistant"}: ${t.content}`));
156
+ }
157
+ if (parts.length === 0) return message;
158
+ parts.push("", `User: ${message}`, "", "Assistant:");
159
+ return parts.join("\n");
160
+ }
161
+ function runBrain(brain, prompt, onChunk) {
152
162
  return new Promise((resolve) => {
153
163
  let child;
154
164
  try {
@@ -160,7 +170,9 @@ function runBrain(brain, prompt) {
160
170
  let out = "";
161
171
  let err = "";
162
172
  child.stdout.on("data", (d) => {
163
- out += d.toString();
173
+ const s = d.toString();
174
+ out += s;
175
+ if (onChunk) onChunk(s);
164
176
  });
165
177
  child.stderr.on("data", (d) => {
166
178
  err += d.toString();
@@ -240,6 +252,141 @@ function runInteractive(cmd, args) {
240
252
  });
241
253
  }
242
254
 
255
+ // 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";
258
+ import { randomUUID } from "crypto";
259
+ var OLLAMA_URL = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
260
+ var EMBED_MODEL = process.env.HOLT_EMBED_MODEL || "nomic-embed-text";
261
+ function memDir() {
262
+ return join3(wsHoltDir(), "memory");
263
+ }
264
+ function memPath() {
265
+ return join3(memDir(), "turns.jsonl");
266
+ }
267
+ function newSessionId() {
268
+ return randomUUID().slice(0, 8);
269
+ }
270
+ var embedProbe = null;
271
+ function resetEmbedProbe() {
272
+ embedProbe = null;
273
+ }
274
+ async function embeddingsAvailable() {
275
+ if (embedProbe !== null) return embedProbe;
276
+ try {
277
+ const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1200) });
278
+ if (!res.ok) return embedProbe = false;
279
+ const data = await res.json();
280
+ embedProbe = !!data.models?.some((m) => (m.name || "").startsWith(EMBED_MODEL));
281
+ } catch {
282
+ embedProbe = false;
283
+ }
284
+ return embedProbe;
285
+ }
286
+ async function embed(text) {
287
+ if (!await embeddingsAvailable()) return null;
288
+ try {
289
+ const res = await fetch(`${OLLAMA_URL}/api/embeddings`, {
290
+ method: "POST",
291
+ headers: { "content-type": "application/json" },
292
+ body: JSON.stringify({ model: EMBED_MODEL, prompt: text.slice(0, 4e3) }),
293
+ signal: AbortSignal.timeout(1e4)
294
+ });
295
+ if (!res.ok) return null;
296
+ const data = await res.json();
297
+ if (!Array.isArray(data.embedding)) return null;
298
+ return data.embedding.map((x) => Math.round(x * 1e4) / 1e4);
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+ function loadTurns() {
304
+ if (!existsSync4(memPath())) return [];
305
+ const out = [];
306
+ for (const line of readFileSync4(memPath(), "utf8").split("\n")) {
307
+ if (!line.trim()) continue;
308
+ try {
309
+ out.push(JSON.parse(line));
310
+ } catch {
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+ function appendTurn(t) {
316
+ mkdirSync2(memDir(), { recursive: true });
317
+ appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
318
+ }
319
+ function clearMemory() {
320
+ if (existsSync4(memPath())) rmSync(memPath());
321
+ }
322
+ function memStats() {
323
+ const turns = loadTurns();
324
+ const sessions = new Set(turns.map((t) => t.session)).size;
325
+ const withEmbeddings = turns.filter((t) => Array.isArray(t.emb)).length;
326
+ const bytes = existsSync4(memPath()) ? statSync(memPath()).size : 0;
327
+ return { turns: turns.length, sessions, withEmbeddings, bytes };
328
+ }
329
+ function cosine(a, b) {
330
+ let dot = 0;
331
+ let na = 0;
332
+ let nb = 0;
333
+ const n = Math.min(a.length, b.length);
334
+ for (let i = 0; i < n; i++) {
335
+ const x = a[i];
336
+ const y = b[i];
337
+ dot += x * y;
338
+ na += x * x;
339
+ nb += y * y;
340
+ }
341
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
342
+ }
343
+ function tokens(s) {
344
+ return new Set(
345
+ s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2)
346
+ );
347
+ }
348
+ function keywordScore(q, text) {
349
+ if (q.size === 0) return 0;
350
+ const t = tokens(text);
351
+ let hit = 0;
352
+ for (const w of q) if (t.has(w)) hit++;
353
+ return hit / q.size;
354
+ }
355
+ async function recall(query, currentSession, k = 4) {
356
+ const past = loadTurns().filter((t) => t.session !== currentSession);
357
+ if (past.length === 0) return [];
358
+ const qEmb = await embed(query);
359
+ const qTok = tokens(query);
360
+ const scored = [];
361
+ for (const turn of past) {
362
+ let score = 0;
363
+ if (qEmb && Array.isArray(turn.emb)) score = cosine(qEmb, turn.emb);
364
+ else score = keywordScore(qTok, turn.content);
365
+ if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) scored.push({ turn, score });
366
+ }
367
+ scored.sort((a, b) => b.score - a.score);
368
+ return scored.slice(0, k);
369
+ }
370
+ async function backfillEmbeddings(onProgress) {
371
+ const turns = loadTurns();
372
+ const missing = turns.filter((t) => !Array.isArray(t.emb));
373
+ if (missing.length === 0) return { embedded: 0, total: 0 };
374
+ let done = 0;
375
+ let embedded = 0;
376
+ for (const t of missing) {
377
+ const e = await embed(t.content);
378
+ if (e) {
379
+ t.emb = e;
380
+ embedded++;
381
+ }
382
+ done++;
383
+ if (onProgress) onProgress(done, missing.length);
384
+ }
385
+ mkdirSync2(memDir(), { recursive: true });
386
+ writeFileSync4(memPath(), turns.map((t) => JSON.stringify(t)).join("\n") + "\n", "utf8");
387
+ return { embedded, total: missing.length };
388
+ }
389
+
243
390
  // src/commands/init.ts
244
391
  function parseBrains(raw, found) {
245
392
  const s = raw.trim().toLowerCase();
@@ -286,6 +433,17 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
286
433
  const r = installAlias(aliasAns);
287
434
  aliasNote = r.ok ? c.green(` alias "${aliasAns}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message);
288
435
  }
436
+ let wantMemorySetup = false;
437
+ const embedReady = await embeddingsAvailable();
438
+ if (embedReady) {
439
+ console.log(c.dim("\nSemantic memory: ready (local Ollama with " + EMBED_MODEL + " detected)."));
440
+ } else {
441
+ const ollamaHere = isInstalled("ollama");
442
+ const q = ollamaHere ? `Semantic memory needs a local embed model. Pull ${EMBED_MODEL} with Ollama now? [Y/n] ` : "Enable private semantic memory? Installs Ollama plus a small local embed model. Everything stays on your machine. [Y/n] ";
443
+ const a = (await ask("\n" + q) ?? "").trim().toLowerCase();
444
+ wantMemorySetup = a !== "n" && a !== "no";
445
+ if (!wantMemorySetup) console.log(c.dim(' Okay. Memory still works with keyword recall; run "holt init" again anytime.'));
446
+ }
289
447
  close();
290
448
  for (const id of toInstall) {
291
449
  const s = BRAIN_SETUP[id];
@@ -300,6 +458,28 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
300
458
  console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool to return here.`));
301
459
  await runInteractive(s.login[0], s.login.slice(1));
302
460
  }
461
+ if (wantMemorySetup) {
462
+ if (!isInstalled("ollama")) {
463
+ if (process.platform === "darwin" && isInstalled("brew")) {
464
+ console.log("\n" + c.accent("Installing Ollama") + c.dim(" (brew install ollama)"));
465
+ const code = await runInteractive("brew", ["install", "ollama"]);
466
+ if (code === 0) await runInteractive("brew", ["services", "start", "ollama"]);
467
+ else console.log(c.red(' Install failed. Get Ollama from https://ollama.com/download and run "holt init" again.'));
468
+ } else {
469
+ console.log(c.dim('\n Get Ollama from https://ollama.com/download, then run "holt init" again to finish memory setup.'));
470
+ }
471
+ }
472
+ if (isInstalled("ollama")) {
473
+ console.log("\n" + c.accent("Pulling embed model") + c.dim(` (ollama pull ${EMBED_MODEL})`));
474
+ const code = await runInteractive("ollama", ["pull", EMBED_MODEL]);
475
+ if (code !== 0) {
476
+ console.log(c.dim(' Could not pull. Start Ollama (open the app or run "ollama serve"), then run:'));
477
+ console.log(c.dim(` ollama pull ${EMBED_MODEL}`));
478
+ }
479
+ resetEmbedProbe();
480
+ if (await embeddingsAvailable()) console.log(c.green(" Semantic memory is ready. Chats in trusted folders are stored and recalled locally."));
481
+ }
482
+ }
303
483
  const cfg = loadConfig() ?? defaultConfig();
304
484
  for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
305
485
  cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? null;
@@ -310,6 +490,9 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
310
490
  else console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
311
491
  }
312
492
 
493
+ // src/commands/chat.ts
494
+ import { randomUUID as randomUUID2 } from "crypto";
495
+
313
496
  // src/commands/setting.ts
314
497
  function printStatus(cfg) {
315
498
  console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
@@ -387,11 +570,12 @@ async function setting() {
387
570
  function help() {
388
571
  console.log(c.dim([
389
572
  " commands:",
390
- " /brain [name] switch brain (claude, codex, gemini). context is kept.",
391
- " /setting configure brains and your launch command",
392
- " /clear forget the conversation so far",
393
- " /help this list",
394
- " /exit leave"
573
+ " /brain [name] switch brain (claude, codex, gemini). context is kept.",
574
+ " /memory [query] memory stats, or preview what a query would recall",
575
+ " /setting configure brains and your launch command",
576
+ " /clear forget this session so far (saved memory stays)",
577
+ " /help this list",
578
+ " /exit leave"
395
579
  ].join("\n")));
396
580
  }
397
581
  async function chat() {
@@ -413,9 +597,18 @@ async function chat() {
413
597
  return;
414
598
  }
415
599
  let current = cfg.defaultBrain;
600
+ const session = newSessionId();
416
601
  const history = [];
602
+ const embedOk = await embeddingsAvailable();
603
+ const stats = memStats();
417
604
  console.log("\n" + c.accent("Holt") + c.dim(` brain: ${cfg.brains[current].label}`));
418
- console.log(c.dim("Type a message. Commands: /brain /setting /clear /help /exit\n"));
605
+ console.log(c.dim(
606
+ `Memory: ${stats.turns} moments from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
607
+ ));
608
+ if (embedOk && stats.withEmbeddings < stats.turns) {
609
+ console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
610
+ }
611
+ console.log(c.dim("Type a message. Commands: /brain /memory /setting /clear /help /exit\n"));
419
612
  while (true) {
420
613
  const raw = await ask(c.accent("\u203A "));
421
614
  if (raw === null) break;
@@ -424,6 +617,7 @@ async function chat() {
424
617
  if (line.startsWith("/")) {
425
618
  const parts = line.slice(1).split(/\s+/);
426
619
  const cmd = (parts[0] || "").toLowerCase();
620
+ const rest = parts.slice(1).join(" ");
427
621
  const arg = (parts[1] || "").toLowerCase();
428
622
  if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
429
623
  if (cmd === "help" || cmd === "h") {
@@ -432,7 +626,19 @@ async function chat() {
432
626
  }
433
627
  if (cmd === "clear") {
434
628
  history.length = 0;
435
- console.log(c.dim(" context cleared."));
629
+ console.log(c.dim(" session context cleared. Saved memory is untouched."));
630
+ continue;
631
+ }
632
+ if (cmd === "memory" || cmd === "mem") {
633
+ if (rest) {
634
+ const hits = await recall(rest, session, 5);
635
+ if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
636
+ else for (const h of hits) console.log(c.dim(` ${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.slice(0, 110).replace(/\s+/g, " ")}`));
637
+ } else {
638
+ const s = memStats();
639
+ console.log(c.dim(` ${s.turns} moments, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
640
+ console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
641
+ }
436
642
  continue;
437
643
  }
438
644
  if (cmd === "setting" || cmd === "settings") {
@@ -463,12 +669,23 @@ async function chat() {
463
669
  console.log(c.red(` ${brain.label} (${brain.command}) is not on your PATH. Use /brain to switch or /setting.`));
464
670
  continue;
465
671
  }
466
- console.log(c.dim(` ${brain.label} is thinking...`));
467
- const res = await runBrain(brain, renderPrompt(history, line));
672
+ 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...`;
674
+ console.log(c.dim(` ${label}`) + "\n");
675
+ let streamed = false;
676
+ const res = await runBrain(brain, renderPrompt(history, line, remembered), (chunk) => {
677
+ streamed = true;
678
+ process.stdout.write(chunk);
679
+ });
468
680
  if (res.ok) {
681
+ if (!streamed) console.log(res.text);
682
+ if (!res.text.endsWith("\n")) console.log("");
683
+ console.log("");
469
684
  history.push({ role: "user", content: line });
470
685
  history.push({ role: "assistant", content: res.text });
471
- console.log("\n" + res.text + "\n");
686
+ const now = Date.now();
687
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
688
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
472
689
  } else {
473
690
  console.log(c.red("\n " + res.text + "\n"));
474
691
  }
@@ -493,8 +710,91 @@ async function login(which) {
493
710
  await runInteractive(s.login[0], s.login.slice(1));
494
711
  }
495
712
 
713
+ // src/commands/memory.ts
714
+ async function memoryCmd(sub, rest = []) {
715
+ const { ask, close } = createReader();
716
+ if (!await ensureTrusted(ask)) {
717
+ close();
718
+ return;
719
+ }
720
+ const action = (sub || "").toLowerCase();
721
+ if (action === "clear") {
722
+ const s2 = memStats();
723
+ if (s2.turns === 0) {
724
+ console.log(c.dim("\n Memory is already empty.\n"));
725
+ close();
726
+ return;
727
+ }
728
+ const a = (await ask(`
729
+ Delete all ${s2.turns} remembered moments in this folder? [y/N] `) ?? "").trim().toLowerCase();
730
+ if (a === "y" || a === "yes") {
731
+ clearMemory();
732
+ console.log(c.green(" Memory cleared.\n"));
733
+ } else console.log(c.dim(" Kept.\n"));
734
+ close();
735
+ return;
736
+ }
737
+ if (action === "embed") {
738
+ if (!await embeddingsAvailable()) {
739
+ console.log(c.dim(`
740
+ No local Ollama with ${EMBED_MODEL} reachable. Run "holt init" to set it up.
741
+ `));
742
+ close();
743
+ return;
744
+ }
745
+ const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
746
+ if (missing === 0) {
747
+ console.log(c.dim("\n All moments already have embeddings.\n"));
748
+ close();
749
+ return;
750
+ }
751
+ console.log("");
752
+ const r = await backfillEmbeddings((done, total) => {
753
+ process.stdout.write(`\r embedding ${done}/${total}...`);
754
+ });
755
+ console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} moments embedded.`) + "\n");
756
+ close();
757
+ return;
758
+ }
759
+ if (action === "search") {
760
+ const q = rest.join(" ").trim();
761
+ if (!q) {
762
+ console.log(c.dim("\n Usage: holt memory search <query>\n"));
763
+ close();
764
+ return;
765
+ }
766
+ const hits = await recall(q, "__none__", 8);
767
+ console.log("");
768
+ if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
769
+ else for (const h of hits) {
770
+ const when = new Date(h.turn.ts).toISOString().slice(0, 10);
771
+ console.log(` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role}) ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`);
772
+ }
773
+ console.log("");
774
+ close();
775
+ return;
776
+ }
777
+ const s = memStats();
778
+ const embedOk = await embeddingsAvailable();
779
+ const sessions = new Set(loadTurns().map((t) => t.session)).size;
780
+ console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
781
+ console.log(` moments ${s.turns}`);
782
+ console.log(` sessions ${sessions}`);
783
+ console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
784
+ console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
785
+ console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
786
+ if (embedOk && s.withEmbeddings < s.turns) {
787
+ console.log(c.dim(`
788
+ ${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
789
+ }
790
+ console.log(c.dim("\n holt memory search <query> find remembered moments"));
791
+ console.log(c.dim(" holt memory embed embed older moments for semantic recall"));
792
+ console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
793
+ close();
794
+ }
795
+
496
796
  // src/cli.ts
497
- var VERSION = "0.2.0";
797
+ var VERSION = "0.4.0";
498
798
  var BANNER = `
499
799
  \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
500
800
  \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
@@ -509,7 +809,8 @@ Usage: holt <command>
509
809
 
510
810
  Commands:
511
811
  init Trust this folder, choose and install brains, sign in, set defaults
512
- chat Start a session. Switch brains mid-chat with /brain, context is kept
812
+ chat Start a session. It remembers past sessions in this folder
813
+ memory Inspect memory: holt memory [search <query> | clear]
513
814
  setting Configure brains and your launch command (per folder)
514
815
  login <brain> Sign in to a brain: claude, codex, or gemini
515
816
  version Print the Holt version
@@ -548,6 +849,9 @@ async function main() {
548
849
  case "login":
549
850
  await login(process.argv[3]);
550
851
  break;
852
+ case "memory":
853
+ await memoryCmd(process.argv[3], process.argv.slice(4));
854
+ break;
551
855
  default:
552
856
  console.log(`
553
857
  Unknown command: "${cmd}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "An open-source personal agent OS: any LLM, private memory you can see and walk.",
5
5
  "type": "module",
6
6
  "license": "MIT",