@holt-os/holt 0.2.0 → 0.3.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 +25 -4
  2. package/dist/cli.js +246 -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
 
@@ -46,14 +46,34 @@ Inside `holt chat`:
46
46
  ```
47
47
  /brain list your brains and see which is active
48
48
  /brain gemini switch brain. your conversation context is kept
49
+ /memory memory stats. /memory <query> previews what recall would surface
49
50
  /setting configure brains and your launch command
50
- /clear forget the conversation so far
51
+ /clear forget this session (saved memory stays)
51
52
  /help show commands
52
53
  /exit leave
53
54
  ```
54
55
 
55
56
  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
57
 
58
+ ## Memory
59
+
60
+ 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.
61
+
62
+ Two recall modes, picked automatically:
63
+
64
+ - **Semantic** (best): if a local [Ollama](https://ollama.com) is running with an embedding model (`ollama pull nomic-embed-text`), recall matches by meaning. Asking "who owns my apartment" finds "my landlord is called Pieter". No API keys, nothing leaves your machine.
65
+ - **Keyword** (fallback): with no Ollama, recall matches by word overlap. Still useful, zero setup.
66
+
67
+ Inspect it any time:
68
+
69
+ ```bash
70
+ holt memory # stats for this folder
71
+ holt memory search <query> # find remembered moments
72
+ holt memory clear # wipe this folder's memory
73
+ ```
74
+
75
+ Long conversations stay cheap: only recent turns are replayed verbatim, older context comes back through recall.
76
+
57
77
  ## Brains
58
78
 
59
79
  A brain is an agent CLI installed and logged in on your machine. No API keys to paste.
@@ -70,7 +90,8 @@ A brain is an agent CLI installed and logged in on your machine. No API keys to
70
90
 
71
91
  ```
72
92
  holt init set up (trust, brains, sign-in, defaults) for this folder
73
- holt chat start a session
93
+ holt chat start a session that remembers past ones
94
+ holt memory inspect memory: holt memory [search <query> | clear]
74
95
  holt setting configure brains and launch command
75
96
  holt login <brain> sign in to claude, codex, or gemini
76
97
  holt version print version
@@ -90,7 +111,7 @@ Small strongly-typed **TypeScript core** (command dispatch, brain router, transc
90
111
  Built in always-shippable phases toward a full-vision v1:
91
112
 
92
113
  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
114
+ 1. **Memory**: per-folder store, semantic recall via local embeddings with keyword fallback, streaming replies *(shipped)*
94
115
  2. **Any LLM directly**: raw provider brains and an HTML or Markdown output toggle
95
116
  3. **Skills**: install, search, and publish in the agentskills.io format
96
117
  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();
@@ -310,6 +322,122 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
310
322
  else console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
311
323
  }
312
324
 
325
+ // src/commands/chat.ts
326
+ import { randomUUID as randomUUID2 } from "crypto";
327
+
328
+ // src/memory.ts
329
+ import { appendFileSync, readFileSync as readFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync, statSync } from "fs";
330
+ import { join as join3 } from "path";
331
+ import { randomUUID } from "crypto";
332
+ var OLLAMA_URL = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
333
+ var EMBED_MODEL = process.env.HOLT_EMBED_MODEL || "nomic-embed-text";
334
+ function memDir() {
335
+ return join3(wsHoltDir(), "memory");
336
+ }
337
+ function memPath() {
338
+ return join3(memDir(), "turns.jsonl");
339
+ }
340
+ function newSessionId() {
341
+ return randomUUID().slice(0, 8);
342
+ }
343
+ var embedProbe = null;
344
+ async function embeddingsAvailable() {
345
+ if (embedProbe !== null) return embedProbe;
346
+ try {
347
+ const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1200) });
348
+ if (!res.ok) return embedProbe = false;
349
+ const data = await res.json();
350
+ embedProbe = !!data.models?.some((m) => (m.name || "").startsWith(EMBED_MODEL));
351
+ } catch {
352
+ embedProbe = false;
353
+ }
354
+ return embedProbe;
355
+ }
356
+ async function embed(text) {
357
+ if (!await embeddingsAvailable()) return null;
358
+ try {
359
+ const res = await fetch(`${OLLAMA_URL}/api/embeddings`, {
360
+ method: "POST",
361
+ headers: { "content-type": "application/json" },
362
+ body: JSON.stringify({ model: EMBED_MODEL, prompt: text.slice(0, 4e3) }),
363
+ signal: AbortSignal.timeout(1e4)
364
+ });
365
+ if (!res.ok) return null;
366
+ const data = await res.json();
367
+ if (!Array.isArray(data.embedding)) return null;
368
+ return data.embedding.map((x) => Math.round(x * 1e4) / 1e4);
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+ function loadTurns() {
374
+ if (!existsSync4(memPath())) return [];
375
+ const out = [];
376
+ for (const line of readFileSync4(memPath(), "utf8").split("\n")) {
377
+ if (!line.trim()) continue;
378
+ try {
379
+ out.push(JSON.parse(line));
380
+ } catch {
381
+ }
382
+ }
383
+ return out;
384
+ }
385
+ function appendTurn(t) {
386
+ mkdirSync2(memDir(), { recursive: true });
387
+ appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
388
+ }
389
+ function clearMemory() {
390
+ if (existsSync4(memPath())) rmSync(memPath());
391
+ }
392
+ function memStats() {
393
+ const turns = loadTurns();
394
+ const sessions = new Set(turns.map((t) => t.session)).size;
395
+ const withEmbeddings = turns.filter((t) => Array.isArray(t.emb)).length;
396
+ const bytes = existsSync4(memPath()) ? statSync(memPath()).size : 0;
397
+ return { turns: turns.length, sessions, withEmbeddings, bytes };
398
+ }
399
+ function cosine(a, b) {
400
+ let dot = 0;
401
+ let na = 0;
402
+ let nb = 0;
403
+ const n = Math.min(a.length, b.length);
404
+ for (let i = 0; i < n; i++) {
405
+ const x = a[i];
406
+ const y = b[i];
407
+ dot += x * y;
408
+ na += x * x;
409
+ nb += y * y;
410
+ }
411
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
412
+ }
413
+ function tokens(s) {
414
+ return new Set(
415
+ s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2)
416
+ );
417
+ }
418
+ function keywordScore(q, text) {
419
+ if (q.size === 0) return 0;
420
+ const t = tokens(text);
421
+ let hit = 0;
422
+ for (const w of q) if (t.has(w)) hit++;
423
+ return hit / q.size;
424
+ }
425
+ async function recall(query, currentSession, k = 4) {
426
+ const past = loadTurns().filter((t) => t.session !== currentSession);
427
+ if (past.length === 0) return [];
428
+ const qEmb = await embed(query);
429
+ const qTok = tokens(query);
430
+ const scored = [];
431
+ for (const turn of past) {
432
+ let score = 0;
433
+ if (qEmb && Array.isArray(turn.emb)) score = cosine(qEmb, turn.emb);
434
+ else score = keywordScore(qTok, turn.content);
435
+ if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) scored.push({ turn, score });
436
+ }
437
+ scored.sort((a, b) => b.score - a.score);
438
+ return scored.slice(0, k);
439
+ }
440
+
313
441
  // src/commands/setting.ts
314
442
  function printStatus(cfg) {
315
443
  console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
@@ -387,11 +515,12 @@ async function setting() {
387
515
  function help() {
388
516
  console.log(c.dim([
389
517
  " 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"
518
+ " /brain [name] switch brain (claude, codex, gemini). context is kept.",
519
+ " /memory [query] memory stats, or preview what a query would recall",
520
+ " /setting configure brains and your launch command",
521
+ " /clear forget this session so far (saved memory stays)",
522
+ " /help this list",
523
+ " /exit leave"
395
524
  ].join("\n")));
396
525
  }
397
526
  async function chat() {
@@ -413,9 +542,15 @@ async function chat() {
413
542
  return;
414
543
  }
415
544
  let current = cfg.defaultBrain;
545
+ const session = newSessionId();
416
546
  const history = [];
547
+ const embedOk = await embeddingsAvailable();
548
+ const stats = memStats();
417
549
  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"));
550
+ console.log(c.dim(
551
+ `Memory: ${stats.turns} moments from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
552
+ ));
553
+ console.log(c.dim("Type a message. Commands: /brain /memory /setting /clear /help /exit\n"));
419
554
  while (true) {
420
555
  const raw = await ask(c.accent("\u203A "));
421
556
  if (raw === null) break;
@@ -424,6 +559,7 @@ async function chat() {
424
559
  if (line.startsWith("/")) {
425
560
  const parts = line.slice(1).split(/\s+/);
426
561
  const cmd = (parts[0] || "").toLowerCase();
562
+ const rest = parts.slice(1).join(" ");
427
563
  const arg = (parts[1] || "").toLowerCase();
428
564
  if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
429
565
  if (cmd === "help" || cmd === "h") {
@@ -432,7 +568,19 @@ async function chat() {
432
568
  }
433
569
  if (cmd === "clear") {
434
570
  history.length = 0;
435
- console.log(c.dim(" context cleared."));
571
+ console.log(c.dim(" session context cleared. Saved memory is untouched."));
572
+ continue;
573
+ }
574
+ if (cmd === "memory" || cmd === "mem") {
575
+ if (rest) {
576
+ const hits = await recall(rest, session, 5);
577
+ if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
578
+ 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, " ")}`));
579
+ } else {
580
+ const s = memStats();
581
+ console.log(c.dim(` ${s.turns} moments, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
582
+ console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
583
+ }
436
584
  continue;
437
585
  }
438
586
  if (cmd === "setting" || cmd === "settings") {
@@ -463,12 +611,23 @@ async function chat() {
463
611
  console.log(c.red(` ${brain.label} (${brain.command}) is not on your PATH. Use /brain to switch or /setting.`));
464
612
  continue;
465
613
  }
466
- console.log(c.dim(` ${brain.label} is thinking...`));
467
- const res = await runBrain(brain, renderPrompt(history, line));
614
+ const remembered = await recall(line, session, 4);
615
+ const label = remembered.length ? `${brain.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${brain.label} is thinking...`;
616
+ console.log(c.dim(` ${label}`) + "\n");
617
+ let streamed = false;
618
+ const res = await runBrain(brain, renderPrompt(history, line, remembered), (chunk) => {
619
+ streamed = true;
620
+ process.stdout.write(chunk);
621
+ });
468
622
  if (res.ok) {
623
+ if (!streamed) console.log(res.text);
624
+ if (!res.text.endsWith("\n")) console.log("");
625
+ console.log("");
469
626
  history.push({ role: "user", content: line });
470
627
  history.push({ role: "assistant", content: res.text });
471
- console.log("\n" + res.text + "\n");
628
+ const now = Date.now();
629
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
630
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
472
631
  } else {
473
632
  console.log(c.red("\n " + res.text + "\n"));
474
633
  }
@@ -493,8 +652,64 @@ async function login(which) {
493
652
  await runInteractive(s.login[0], s.login.slice(1));
494
653
  }
495
654
 
655
+ // src/commands/memory.ts
656
+ async function memoryCmd(sub, rest = []) {
657
+ const { ask, close } = createReader();
658
+ if (!await ensureTrusted(ask)) {
659
+ close();
660
+ return;
661
+ }
662
+ const action = (sub || "").toLowerCase();
663
+ if (action === "clear") {
664
+ const s2 = memStats();
665
+ if (s2.turns === 0) {
666
+ console.log(c.dim("\n Memory is already empty.\n"));
667
+ close();
668
+ return;
669
+ }
670
+ const a = (await ask(`
671
+ Delete all ${s2.turns} remembered moments in this folder? [y/N] `) ?? "").trim().toLowerCase();
672
+ if (a === "y" || a === "yes") {
673
+ clearMemory();
674
+ console.log(c.green(" Memory cleared.\n"));
675
+ } else console.log(c.dim(" Kept.\n"));
676
+ close();
677
+ return;
678
+ }
679
+ if (action === "search") {
680
+ const q = rest.join(" ").trim();
681
+ if (!q) {
682
+ console.log(c.dim("\n Usage: holt memory search <query>\n"));
683
+ close();
684
+ return;
685
+ }
686
+ const hits = await recall(q, "__none__", 8);
687
+ console.log("");
688
+ if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
689
+ else for (const h of hits) {
690
+ const when = new Date(h.turn.ts).toISOString().slice(0, 10);
691
+ console.log(` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role}) ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`);
692
+ }
693
+ console.log("");
694
+ close();
695
+ return;
696
+ }
697
+ const s = memStats();
698
+ const embedOk = await embeddingsAvailable();
699
+ const sessions = new Set(loadTurns().map((t) => t.session)).size;
700
+ console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
701
+ console.log(` moments ${s.turns}`);
702
+ console.log(` sessions ${sessions}`);
703
+ console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
704
+ console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
705
+ console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
706
+ console.log(c.dim("\n holt memory search <query> find remembered moments"));
707
+ console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
708
+ close();
709
+ }
710
+
496
711
  // src/cli.ts
497
- var VERSION = "0.2.0";
712
+ var VERSION = "0.3.0";
498
713
  var BANNER = `
499
714
  \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
715
  \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 +724,8 @@ Usage: holt <command>
509
724
 
510
725
  Commands:
511
726
  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
727
+ chat Start a session. It remembers past sessions in this folder
728
+ memory Inspect memory: holt memory [search <query> | clear]
513
729
  setting Configure brains and your launch command (per folder)
514
730
  login <brain> Sign in to a brain: claude, codex, or gemini
515
731
  version Print the Holt version
@@ -548,6 +764,9 @@ async function main() {
548
764
  case "login":
549
765
  await login(process.argv[3]);
550
766
  break;
767
+ case "memory":
768
+ await memoryCmd(process.argv[3], process.argv.slice(4));
769
+ break;
551
770
  default:
552
771
  console.log(`
553
772
  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.3.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",