@holt-os/holt 0.5.2 → 0.7.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 +38 -1
  2. package/dist/cli.js +526 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,6 +63,8 @@ The point of `/brain`: Holt owns the transcript, so you can start a thread on on
63
63
 
64
64
  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.
65
65
 
66
+ When you end a chat, Holt asks the brain to distill 1 to 5 durable facts from the session (decisions, preferences, key names, numbers) and writes them to a human-readable `<folder>/.holt/memory/facts.md` you can read and edit. Those facts are also embedded and ranked slightly higher than raw turns during recall, so the signal rises to the top over time. See them with `holt memory facts`. Turn it off by setting `memory.extractFacts` to `false` in `config.json`.
67
+
66
68
  Two recall modes, picked automatically:
67
69
 
68
70
  - **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.
@@ -72,6 +74,7 @@ Inspect it any time:
72
74
 
73
75
  ```bash
74
76
  holt memory # stats for this folder
77
+ holt memory facts # show the distilled facts (facts.md)
75
78
  holt memory search <query> # find remembered moments
76
79
  holt memory embed # embed older moments for semantic recall
77
80
  holt memory clear # wipe this folder's memory
@@ -124,6 +127,39 @@ holt skill remove <name> delete a skill
124
127
 
125
128
  In chat, run one with `/skill <name> [your input]`. Available skills are also listed to the brain each turn, so it knows what it can be asked to follow.
126
129
 
130
+ ## Use Holt from your other tools (MCP)
131
+
132
+ Holt can run as an [MCP](https://modelcontextprotocol.io) server, so Claude Code, Cursor, and Codex can recall and remember from Holt's memory for the folder without leaving those tools.
133
+
134
+ ```bash
135
+ holt mcp # run the MCP server for the current folder (talks JSON-RPC over stdio)
136
+ holt mcp setup # print the config snippets for each client
137
+ ```
138
+
139
+ The server serves the memory of whatever folder it is launched in. Because MCP is non-interactive (stdin carries the protocol), it cannot prompt for trust, so it **auto-trusts the launch folder** and adds it to `~/.holt/trust.json`. Startup logging goes to stderr; stdout is reserved for the protocol.
140
+
141
+ Tools it exposes:
142
+
143
+ - `recall` (`query`, optional `k`): search this folder's memory for relevant moments and facts.
144
+ - `remember` (`content`): save a durable fact to this folder's memory.
145
+ - `list_skills`: list the skills installed for this folder.
146
+ - `get_skill` (`name`): get a skill's full instructions.
147
+ - `memory_stats`: memory statistics for this folder.
148
+
149
+ **Claude Code**
150
+
151
+ ```bash
152
+ claude mcp add holt -- holt mcp
153
+ ```
154
+
155
+ **Cursor / Codex** (JSON config):
156
+
157
+ ```json
158
+ { "mcpServers": { "holt": { "command": "holt", "args": ["mcp"] } } }
159
+ ```
160
+
161
+ Point the client at the folder whose memory you want it to use; Holt serves whatever folder the server process starts in.
162
+
127
163
  ## Output format
128
164
 
129
165
  Replies print as markdown. `/output html` (or `markdown`) switches the save format and persists it. `/save [name]` writes the last reply to the current folder: `.md`, or a small self-contained dark-theme `.html` page.
@@ -134,6 +170,7 @@ Replies print as markdown. `/output html` (or `markdown`) switches the save form
134
170
  holt init set up (trust, brains, sign-in, defaults) for this folder
135
171
  holt chat start a session that remembers past ones
136
172
  holt graph see your memory as an interactive knowledge graph
173
+ holt mcp run an MCP server so other tools use this folder's memory (holt mcp setup for config)
137
174
  holt skill manage skills: list | show | create | add | remove
138
175
  holt memory inspect memory: holt memory [search <query> | embed | clear]
139
176
  holt setting configure brains, API brains, and launch command
@@ -155,7 +192,7 @@ Small strongly-typed **TypeScript core**, zero runtime dependencies: command dis
155
192
  Built in always-shippable phases toward a full-vision v1:
156
193
 
157
194
  0. **Skeleton and chat**: trust, init with install and sign-in, chat, brain switching with kept context *(shipped)*
158
- 1. **Memory**: per-folder store, semantic recall via local embeddings with keyword fallback, streaming replies *(shipped)*
195
+ 1. **Memory**: per-folder store, semantic recall, streaming replies, and distilled facts per session *(shipped)*
159
196
  2. **Any LLM directly**: API brains with your own key, HTML or Markdown output *(shipped)*
160
197
  3. **Skills**: portable SKILL.md skills, create/add/run *(shipped)*
161
198
  4. **Knowledge graph**: see and navigate your own memory with `holt graph` *(shipped)*
package/dist/cli.js CHANGED
@@ -121,7 +121,7 @@ function defaultConfig() {
121
121
  const d = BRAIN_DEFS[id];
122
122
  brains[id] = { id, label: d.label, command: d.command, args: [...d.args], enabled: false };
123
123
  }
124
- return { version: 3, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown" };
124
+ return { version: 4, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown", memory: { extractFacts: true } };
125
125
  }
126
126
  function loadConfig() {
127
127
  const path = wsConfigPath();
@@ -132,11 +132,14 @@ function loadConfig() {
132
132
  const brains = cfg.brains ?? {};
133
133
  for (const id of BRAIN_IDS) if (!brains[id]) brains[id] = base.brains[id];
134
134
  return {
135
- version: 3,
135
+ version: 4,
136
136
  defaultBrain: cfg.defaultBrain ?? null,
137
137
  brains,
138
138
  apiBrains: Array.isArray(cfg.apiBrains) ? cfg.apiBrains : [],
139
- outputFormat: cfg.outputFormat === "html" ? "html" : "markdown"
139
+ outputFormat: cfg.outputFormat === "html" ? "html" : "markdown",
140
+ memory: {
141
+ extractFacts: cfg.memory && typeof cfg.memory.extractFacts === "boolean" ? cfg.memory.extractFacts : true
142
+ }
140
143
  };
141
144
  } catch {
142
145
  return null;
@@ -228,20 +231,20 @@ function runBrain(brain, prompt, onChunk) {
228
231
  return;
229
232
  }
230
233
  let out = "";
231
- let err = "";
234
+ let err2 = "";
232
235
  child.stdout.on("data", (d) => {
233
236
  const s = d.toString();
234
237
  out += s;
235
238
  if (onChunk) onChunk(s);
236
239
  });
237
240
  child.stderr.on("data", (d) => {
238
- err += d.toString();
241
+ err2 += d.toString();
239
242
  });
240
243
  child.on("error", (e) => resolve3({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
241
244
  child.on("close", (code) => {
242
245
  const text = out.trim();
243
246
  if (code === 0 && text) resolve3({ ok: true, text });
244
- else resolve3({ ok: false, text: err.trim() || text || `"${brain.command}" exited with code ${code}` });
247
+ else resolve3({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
245
248
  });
246
249
  });
247
250
  }
@@ -415,6 +418,9 @@ function memDir() {
415
418
  function memPath() {
416
419
  return join4(memDir(), "turns.jsonl");
417
420
  }
421
+ function factsMdPath() {
422
+ return join4(memDir(), "facts.md");
423
+ }
418
424
  function newSessionId() {
419
425
  return randomUUID().slice(0, 8);
420
426
  }
@@ -470,12 +476,58 @@ function appendTurn(t) {
470
476
  function clearMemory() {
471
477
  if (existsSync4(memPath())) rmSync2(memPath());
472
478
  }
479
+ function normalizeFact(s) {
480
+ return s.toLowerCase().replace(/\s+/g, " ").trim().replace(/[.,;:!?]+$/, "");
481
+ }
482
+ var FACTS_HEADER = "# Holt facts\n\nDistilled memories for this folder. Safe to edit by hand.\n";
483
+ async function saveFact(content, session) {
484
+ const clean = content.trim();
485
+ if (!clean) return false;
486
+ const norm2 = normalizeFact(clean);
487
+ for (const t of loadTurns()) {
488
+ if (t.role === "fact" && normalizeFact(t.content) === norm2) return false;
489
+ }
490
+ if (existsSync4(factsMdPath())) {
491
+ for (const line of readFileSync4(factsMdPath(), "utf8").split("\n")) {
492
+ const l = line.trim();
493
+ if (!l.startsWith("- ")) continue;
494
+ if (normalizeFact(l.slice(2)) === norm2) return false;
495
+ }
496
+ }
497
+ mkdirSync4(memDir(), { recursive: true });
498
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
499
+ let existing = existsSync4(factsMdPath()) ? readFileSync4(factsMdPath(), "utf8") : "";
500
+ if (!existing) existing = FACTS_HEADER;
501
+ const headings = existing.match(/^## (\d{4}-\d{2}-\d{2})/gm);
502
+ const lastHeading = headings && headings.length ? headings[headings.length - 1] : null;
503
+ const needsHeading = lastHeading !== `## ${today}`;
504
+ let addition = "";
505
+ if (needsHeading) {
506
+ if (!existing.endsWith("\n")) addition += "\n";
507
+ addition += `
508
+ ## ${today}
509
+ `;
510
+ }
511
+ addition += `- ${clean}
512
+ `;
513
+ writeFileSync4(factsMdPath(), existing + addition, "utf8");
514
+ appendTurn({
515
+ id: randomUUID().slice(0, 8),
516
+ ts: Date.now(),
517
+ session,
518
+ role: "fact",
519
+ content: clean,
520
+ emb: await embed(clean) ?? void 0
521
+ });
522
+ return true;
523
+ }
473
524
  function memStats() {
474
525
  const turns = loadTurns();
475
526
  const sessions = new Set(turns.map((t) => t.session)).size;
476
527
  const withEmbeddings = turns.filter((t) => Array.isArray(t.emb)).length;
528
+ const facts = turns.filter((t) => t.role === "fact").length;
477
529
  const bytes = existsSync4(memPath()) ? statSync(memPath()).size : 0;
478
- return { turns: turns.length, sessions, withEmbeddings, bytes };
530
+ return { turns: turns.length, facts, sessions, withEmbeddings, bytes };
479
531
  }
480
532
  function cosine(a, b) {
481
533
  let dot = 0;
@@ -503,6 +555,7 @@ function keywordScore(q, text) {
503
555
  for (const w of q) if (t.has(w)) hit++;
504
556
  return hit / q.size;
505
557
  }
558
+ var FACT_BOOST = 1.15;
506
559
  async function recall(query, currentSession, k = 4) {
507
560
  const past = loadTurns().filter((t) => t.session !== currentSession);
508
561
  if (past.length === 0) return [];
@@ -513,7 +566,10 @@ async function recall(query, currentSession, k = 4) {
513
566
  let score = 0;
514
567
  if (qEmb && Array.isArray(turn.emb)) score = cosine(qEmb, turn.emb);
515
568
  else score = keywordScore(qTok, turn.content);
516
- if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) scored.push({ turn, score });
569
+ if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) {
570
+ if (turn.role === "fact") score *= FACT_BOOST;
571
+ scored.push({ turn, score });
572
+ }
517
573
  }
518
574
  scored.sort((a, b) => b.score - a.score);
519
575
  return scored.slice(0, k);
@@ -709,9 +765,9 @@ async function init() {
709
765
  console.log("Looking for agent CLIs on your machine...\n");
710
766
  const found = [];
711
767
  for (const id of BRAIN_IDS) {
712
- const ok = isInstalled(BRAIN_DEFS[id].command);
713
- console.log(` ${ok ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
714
- if (ok) found.push(id);
768
+ const ok2 = isInstalled(BRAIN_DEFS[id].command);
769
+ console.log(` ${ok2 ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
770
+ if (ok2) found.push(id);
715
771
  }
716
772
  console.log("");
717
773
  const chosen = parseBrains(
@@ -975,6 +1031,115 @@ async function runApiBrain(brain, prompt, onChunk) {
975
1031
  return { ok: false, text: `${brain.provider} returned an empty reply.` };
976
1032
  }
977
1033
 
1034
+ // src/facts.ts
1035
+ var MIN_EXCHANGES_FOR_EXTRACTION = 3;
1036
+ var MAX_TRANSCRIPT_TURNS = 20;
1037
+ var MAX_TURN_CHARS = 1e3;
1038
+ function buildExtractionPrompt(history) {
1039
+ const recent = history.slice(-MAX_TRANSCRIPT_TURNS);
1040
+ const transcript = recent.map((t) => `${t.role === "user" ? "USER" : "ASSISTANT"}: ${t.content.slice(0, MAX_TURN_CHARS)}`).join("\n\n");
1041
+ const lines = [
1042
+ "Given this conversation, extract 1 to 5 durable facts worth remembering long-term.",
1043
+ "Output ONLY a JSON array of strings, no prose, no code fences.",
1044
+ "",
1045
+ "Rules:",
1046
+ "- Keep decisions, outcomes, stable preferences, key names, dates, and numbers.",
1047
+ "- Skip pleasantries, one-off questions, and generic advice.",
1048
+ "- Make each fact self-contained so it reads on its own in a future session.",
1049
+ "- If nothing is worth saving, output exactly []",
1050
+ "",
1051
+ "Good examples:",
1052
+ '- "User decided to target the Netherlands as the primary job market, HSM visa route."',
1053
+ '- "HDFC EMI is 17,394 rupees per month with 29 payments remaining as of March 2026."',
1054
+ "",
1055
+ "Bad examples (do NOT output these):",
1056
+ '- "User asked a question about their finances."',
1057
+ '- "The assistant gave some helpful advice."',
1058
+ "",
1059
+ "Conversation:",
1060
+ transcript,
1061
+ "",
1062
+ "JSON array:"
1063
+ ];
1064
+ return lines.join("\n");
1065
+ }
1066
+ function firstBalancedArray(s) {
1067
+ const start = s.indexOf("[");
1068
+ if (start < 0) return null;
1069
+ let depth = 0;
1070
+ let inStr = false;
1071
+ let esc = false;
1072
+ for (let i = start; i < s.length; i++) {
1073
+ const ch = s[i];
1074
+ if (inStr) {
1075
+ if (esc) esc = false;
1076
+ else if (ch === "\\") esc = true;
1077
+ else if (ch === '"') inStr = false;
1078
+ continue;
1079
+ }
1080
+ if (ch === '"') inStr = true;
1081
+ else if (ch === "[") depth++;
1082
+ else if (ch === "]") {
1083
+ depth--;
1084
+ if (depth === 0) {
1085
+ const slice = s.slice(start, i + 1);
1086
+ try {
1087
+ const parsed = JSON.parse(slice);
1088
+ return Array.isArray(parsed) ? parsed : null;
1089
+ } catch {
1090
+ return null;
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ return null;
1096
+ }
1097
+ function norm(s) {
1098
+ return s.toLowerCase().replace(/\s+/g, " ").trim().replace(/[.,;:!?]+$/, "");
1099
+ }
1100
+ function parseFacts(raw) {
1101
+ let s = (raw || "").trim();
1102
+ s = s.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
1103
+ const m = s.match(/\[[\s\S]*\]/);
1104
+ if (!m) return [];
1105
+ let arr = null;
1106
+ try {
1107
+ arr = JSON.parse(m[0]);
1108
+ } catch {
1109
+ arr = firstBalancedArray(s);
1110
+ }
1111
+ if (!Array.isArray(arr)) return [];
1112
+ const seen = /* @__PURE__ */ new Set();
1113
+ const out = [];
1114
+ for (const item of arr) {
1115
+ if (typeof item !== "string") continue;
1116
+ const v = String(item).trim();
1117
+ if (v.length <= 10) continue;
1118
+ const key = norm(v);
1119
+ if (seen.has(key)) continue;
1120
+ seen.add(key);
1121
+ out.push(v);
1122
+ if (out.length >= 5) break;
1123
+ }
1124
+ return out;
1125
+ }
1126
+ async function extractAndSaveFacts(active, cfg, history, session) {
1127
+ try {
1128
+ if (Math.floor(history.length / 2) < MIN_EXCHANGES_FOR_EXTRACTION) return 0;
1129
+ const prompt = buildExtractionPrompt(history);
1130
+ const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt) : await runApiBrain(active.brain, prompt);
1131
+ if (!res.ok) return 0;
1132
+ const facts = parseFacts(res.text);
1133
+ let saved = 0;
1134
+ for (const f of facts) {
1135
+ if (await saveFact(f, session)) saved++;
1136
+ }
1137
+ return saved;
1138
+ } catch {
1139
+ return 0;
1140
+ }
1141
+ }
1142
+
978
1143
  // src/output.ts
979
1144
  import { writeFileSync as writeFileSync5 } from "fs";
980
1145
  import { join as join5 } from "path";
@@ -1301,7 +1466,7 @@ async function chat() {
1301
1466
  const stats = memStats();
1302
1467
  console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
1303
1468
  console.log(c.dim(
1304
- `Memory: ${stats.turns} moments from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
1469
+ `Memory: ${stats.turns} moments (${stats.facts} facts) from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
1305
1470
  ));
1306
1471
  if (embedOk && stats.withEmbeddings < stats.turns) {
1307
1472
  console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
@@ -1343,7 +1508,7 @@ async function chat() {
1343
1508
  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, " ")}`));
1344
1509
  } else {
1345
1510
  const s = memStats();
1346
- console.log(c.dim(` ${s.turns} moments, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
1511
+ console.log(c.dim(` ${s.turns} moments, ${s.facts} facts, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
1347
1512
  console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
1348
1513
  }
1349
1514
  continue;
@@ -1451,6 +1616,14 @@ async function chat() {
1451
1616
  console.log(c.red("\n " + res.text + "\n"));
1452
1617
  }
1453
1618
  }
1619
+ if (cfg.memory?.extractFacts !== false) {
1620
+ try {
1621
+ const arg = active.kind === "cli" ? { kind: "cli", id: active.id } : { kind: "api", brain: active.brain };
1622
+ const n = await extractAndSaveFacts(arg, cfg, history, session);
1623
+ if (n > 0) console.log(c.dim(` distilled ${n} fact${n === 1 ? "" : "s"} from this session.`));
1624
+ } catch {
1625
+ }
1626
+ }
1454
1627
  close();
1455
1628
  console.log(c.dim("\nBye.\n"));
1456
1629
  }
@@ -1472,6 +1645,7 @@ async function login(which) {
1472
1645
  }
1473
1646
 
1474
1647
  // src/commands/memory.ts
1648
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
1475
1649
  async function memoryCmd(sub, rest = []) {
1476
1650
  const { ask, close } = createReader();
1477
1651
  if (!await ensureTrusted(ask)) {
@@ -1505,7 +1679,7 @@ async function memoryCmd(sub, rest = []) {
1505
1679
  }
1506
1680
  const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
1507
1681
  if (missing === 0) {
1508
- console.log(c.dim("\n All moments already have embeddings.\n"));
1682
+ console.log(c.dim("\n All memories already have embeddings.\n"));
1509
1683
  close();
1510
1684
  return;
1511
1685
  }
@@ -1513,7 +1687,18 @@ async function memoryCmd(sub, rest = []) {
1513
1687
  const r = await backfillEmbeddings((done, total) => {
1514
1688
  process.stdout.write(`\r embedding ${done}/${total}...`);
1515
1689
  });
1516
- console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} moments embedded.`) + "\n");
1690
+ console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} memories embedded.`) + "\n");
1691
+ close();
1692
+ return;
1693
+ }
1694
+ if (action === "facts") {
1695
+ if (!existsSync6(factsMdPath())) {
1696
+ console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
1697
+ close();
1698
+ return;
1699
+ }
1700
+ const body = readFileSync6(factsMdPath(), "utf8").trim();
1701
+ console.log("\n" + body + "\n");
1517
1702
  close();
1518
1703
  return;
1519
1704
  }
@@ -1540,6 +1725,7 @@ async function memoryCmd(sub, rest = []) {
1540
1725
  const sessions = new Set(loadTurns().map((t) => t.session)).size;
1541
1726
  console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
1542
1727
  console.log(` moments ${s.turns}`);
1728
+ console.log(` facts ${s.facts} (./.holt/memory/facts.md)`);
1543
1729
  console.log(` sessions ${sessions}`);
1544
1730
  console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
1545
1731
  console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
@@ -1549,7 +1735,8 @@ async function memoryCmd(sub, rest = []) {
1549
1735
  ${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
1550
1736
  }
1551
1737
  console.log(c.dim("\n holt memory search <query> find remembered moments"));
1552
- console.log(c.dim(" holt memory embed embed older moments for semantic recall"));
1738
+ console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
1739
+ console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
1553
1740
  console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
1554
1741
  close();
1555
1742
  }
@@ -1558,8 +1745,8 @@ async function memoryCmd(sub, rest = []) {
1558
1745
  import {
1559
1746
  mkdirSync as mkdirSync5,
1560
1747
  writeFileSync as writeFileSync6,
1561
- readFileSync as readFileSync6,
1562
- existsSync as existsSync6,
1748
+ readFileSync as readFileSync7,
1749
+ existsSync as existsSync7,
1563
1750
  readdirSync as readdirSync2,
1564
1751
  statSync as statSync3,
1565
1752
  rmSync as rmSync3,
@@ -1612,7 +1799,7 @@ function isGitUrl(s) {
1612
1799
  return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
1613
1800
  }
1614
1801
  function findSkillDir(root) {
1615
- if (existsSync6(join7(root, "SKILL.md"))) return root;
1802
+ if (existsSync7(join7(root, "SKILL.md"))) return root;
1616
1803
  let subs = [];
1617
1804
  try {
1618
1805
  subs = readdirSync2(root).filter((f) => {
@@ -1625,7 +1812,7 @@ function findSkillDir(root) {
1625
1812
  } catch {
1626
1813
  return null;
1627
1814
  }
1628
- const withSkill = subs.filter((f) => existsSync6(join7(root, f, "SKILL.md")));
1815
+ const withSkill = subs.filter((f) => existsSync7(join7(root, f, "SKILL.md")));
1629
1816
  if (withSkill.length === 1) return join7(root, withSkill[0]);
1630
1817
  return null;
1631
1818
  }
@@ -1678,7 +1865,7 @@ function cmdCreate(name, global) {
1678
1865
  }
1679
1866
  const scope = global ? "global" : "workspace";
1680
1867
  const dir = join7(skillsRoot(scope), clean);
1681
- if (existsSync6(dir)) {
1868
+ if (existsSync7(dir)) {
1682
1869
  console.log(c.red(`
1683
1870
  A ${scope} skill named "${clean}" already exists.`));
1684
1871
  console.log(c.dim(" " + dir + "\n"));
@@ -1712,7 +1899,7 @@ function cmdAdd(source, global) {
1712
1899
  fetched = tempDir;
1713
1900
  } else {
1714
1901
  fetched = resolve(source);
1715
- if (!existsSync6(fetched)) {
1902
+ if (!existsSync7(fetched)) {
1716
1903
  console.log(c.red(`
1717
1904
  No such path: ${fetched}
1718
1905
  `));
@@ -1726,7 +1913,7 @@ function cmdAdd(source, global) {
1726
1913
  }
1727
1914
  let skillName = "";
1728
1915
  try {
1729
- const { data } = parseFrontmatter(readFileSync6(join7(skillDir, "SKILL.md"), "utf8"));
1916
+ const { data } = parseFrontmatter(readFileSync7(join7(skillDir, "SKILL.md"), "utf8"));
1730
1917
  skillName = sanitizeName(data.name || "");
1731
1918
  } catch {
1732
1919
  skillName = "";
@@ -1737,7 +1924,7 @@ function cmdAdd(source, global) {
1737
1924
  return;
1738
1925
  }
1739
1926
  const dest = join7(skillsRoot(scope), skillName);
1740
- if (existsSync6(dest)) {
1927
+ if (existsSync7(dest)) {
1741
1928
  console.log(c.red(`
1742
1929
  A ${scope} skill named "${skillName}" already exists.`));
1743
1930
  console.log(c.dim(" " + dest + "\n"));
@@ -1746,12 +1933,12 @@ function cmdAdd(source, global) {
1746
1933
  mkdirSync5(skillsRoot(scope), { recursive: true });
1747
1934
  cpSync(skillDir, dest, { recursive: true });
1748
1935
  const nestedGit = join7(dest, ".git");
1749
- if (existsSync6(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
1936
+ if (existsSync7(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
1750
1937
  console.log(c.green(`
1751
1938
  Installed ${scope} skill "${skillName}".`));
1752
1939
  console.log(c.dim(" " + join7(dest, "SKILL.md") + "\n"));
1753
1940
  } finally {
1754
- if (tempDir && existsSync6(tempDir)) {
1941
+ if (tempDir && existsSync7(tempDir)) {
1755
1942
  try {
1756
1943
  rmSync3(tempDir, { recursive: true, force: true });
1757
1944
  } catch {
@@ -1767,7 +1954,7 @@ async function cmdRemove(name, ask) {
1767
1954
  }
1768
1955
  const wsDir = join7(skillsRoot("workspace"), clean);
1769
1956
  const globalDir = join7(skillsRoot("global"), clean);
1770
- const target = existsSync6(wsDir) ? wsDir : existsSync6(globalDir) ? globalDir : "";
1957
+ const target = existsSync7(wsDir) ? wsDir : existsSync7(globalDir) ? globalDir : "";
1771
1958
  if (!target) {
1772
1959
  console.log(c.dim(`
1773
1960
  No skill named "${clean}" found.
@@ -2505,8 +2692,315 @@ async function graph(args = []) {
2505
2692
  }
2506
2693
  }
2507
2694
 
2695
+ // src/mcp/server.ts
2696
+ var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
2697
+ function isObject(v) {
2698
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2699
+ }
2700
+ function ok(id, result) {
2701
+ return { jsonrpc: "2.0", id: id ?? null, result };
2702
+ }
2703
+ function err(id, code, message) {
2704
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
2705
+ }
2706
+ async function handleMcpMessage(msg, opts) {
2707
+ if (!isObject(msg)) {
2708
+ console.error("[mcp] ignoring non-object message");
2709
+ return null;
2710
+ }
2711
+ const message = msg;
2712
+ const method = typeof message.method === "string" ? message.method : void 0;
2713
+ const hasId = "id" in message && message.id !== void 0 && message.id !== null;
2714
+ const id = message.id;
2715
+ if (method === void 0) {
2716
+ if (hasId) return err(id, -32600, "Invalid Request: missing method");
2717
+ console.error("[mcp] ignoring message with no method");
2718
+ return null;
2719
+ }
2720
+ if (!hasId) {
2721
+ if (!method.startsWith("notifications/")) {
2722
+ console.error(`[mcp] ignoring notification-shaped message: ${method}`);
2723
+ }
2724
+ return null;
2725
+ }
2726
+ const params = isObject(message.params) ? message.params : {};
2727
+ try {
2728
+ switch (method) {
2729
+ case "initialize": {
2730
+ const requested = params["protocolVersion"];
2731
+ const protocolVersion = typeof requested === "string" ? requested : DEFAULT_PROTOCOL_VERSION;
2732
+ return ok(id, {
2733
+ protocolVersion,
2734
+ capabilities: { tools: {} },
2735
+ serverInfo: { name: opts.name, version: opts.version }
2736
+ });
2737
+ }
2738
+ case "ping":
2739
+ return ok(id, {});
2740
+ case "tools/list":
2741
+ return ok(id, {
2742
+ tools: opts.tools.map((t) => ({
2743
+ name: t.name,
2744
+ description: t.description,
2745
+ inputSchema: t.inputSchema
2746
+ }))
2747
+ });
2748
+ case "tools/call": {
2749
+ const name = typeof params["name"] === "string" ? params["name"] : "";
2750
+ const rawArgs = params["arguments"];
2751
+ const args = isObject(rawArgs) ? rawArgs : {};
2752
+ const tool = opts.tools.find((t) => t.name === name);
2753
+ if (!tool) {
2754
+ return ok(id, {
2755
+ content: [{ type: "text", text: "Unknown tool: " + name }],
2756
+ isError: true
2757
+ });
2758
+ }
2759
+ try {
2760
+ const text = await tool.handler(args);
2761
+ return ok(id, { content: [{ type: "text", text }] });
2762
+ } catch (e) {
2763
+ const emsg = e instanceof Error ? e.message : String(e);
2764
+ return ok(id, {
2765
+ content: [{ type: "text", text: "Tool error: " + emsg }],
2766
+ isError: true
2767
+ });
2768
+ }
2769
+ }
2770
+ default:
2771
+ return err(id, -32601, "Method not found: " + method);
2772
+ }
2773
+ } catch (e) {
2774
+ const emsg = e instanceof Error ? e.message : String(e);
2775
+ console.error(`[mcp] internal error handling ${method}: ${emsg}`);
2776
+ return err(id, -32603, "Internal error: " + emsg);
2777
+ }
2778
+ }
2779
+ function recoverId(line) {
2780
+ const num = line.match(/"id"\s*:\s*(-?\d+(?:\.\d+)?)/);
2781
+ if (num && num[1] !== void 0) {
2782
+ const n = Number(num[1]);
2783
+ if (Number.isFinite(n)) return n;
2784
+ }
2785
+ const str = line.match(/"id"\s*:\s*"((?:[^"\\]|\\.)*)"/);
2786
+ if (str && str[1] !== void 0) return str[1];
2787
+ return null;
2788
+ }
2789
+ function runMcpServer(opts) {
2790
+ return new Promise((resolve3) => {
2791
+ const stdin = process.stdin;
2792
+ let buffer = "";
2793
+ let chain = Promise.resolve();
2794
+ const send = (response) => {
2795
+ process.stdout.write(JSON.stringify(response) + "\n");
2796
+ };
2797
+ const processLine = (rawLine) => {
2798
+ const line = rawLine.trim();
2799
+ if (line === "") return;
2800
+ let parsed;
2801
+ try {
2802
+ parsed = JSON.parse(line);
2803
+ } catch {
2804
+ const id = recoverId(line);
2805
+ if (id !== null) {
2806
+ send(err(id, -32700, "Parse error"));
2807
+ } else {
2808
+ console.error("[mcp] parse error on line, no id to recover, skipping");
2809
+ }
2810
+ return;
2811
+ }
2812
+ if (Array.isArray(parsed)) {
2813
+ chain = chain.then(async () => {
2814
+ for (const entry of parsed) {
2815
+ const response = await handleMcpMessage(entry, opts);
2816
+ if (response !== null) send(response);
2817
+ }
2818
+ });
2819
+ return;
2820
+ }
2821
+ chain = chain.then(async () => {
2822
+ const response = await handleMcpMessage(parsed, opts);
2823
+ if (response !== null) send(response);
2824
+ });
2825
+ };
2826
+ stdin.setEncoding("utf8");
2827
+ stdin.on("data", (chunk) => {
2828
+ buffer += chunk;
2829
+ let nl = buffer.indexOf("\n");
2830
+ while (nl !== -1) {
2831
+ const line = buffer.slice(0, nl);
2832
+ buffer = buffer.slice(nl + 1);
2833
+ processLine(line);
2834
+ nl = buffer.indexOf("\n");
2835
+ }
2836
+ });
2837
+ stdin.on("end", () => {
2838
+ if (buffer.length > 0) {
2839
+ processLine(buffer);
2840
+ buffer = "";
2841
+ }
2842
+ chain.then(() => resolve3()).catch(() => resolve3());
2843
+ });
2844
+ stdin.on("error", (e) => {
2845
+ console.error(`[mcp] stdin error: ${e.message}`);
2846
+ chain.then(() => resolve3()).catch(() => resolve3());
2847
+ });
2848
+ stdin.resume();
2849
+ });
2850
+ }
2851
+
2852
+ // src/mcp/tools.ts
2853
+ var MCP_SESSION = "__mcp__";
2854
+ function holtTools() {
2855
+ return [
2856
+ {
2857
+ name: "recall",
2858
+ description: "Search Holt's memory for this folder and return the most relevant remembered moments and distilled facts.",
2859
+ inputSchema: {
2860
+ type: "object",
2861
+ properties: {
2862
+ query: { type: "string", description: "What to recall." },
2863
+ k: { type: "number", description: "Max results to return (default 5)." }
2864
+ },
2865
+ required: ["query"]
2866
+ },
2867
+ handler: async (args) => {
2868
+ try {
2869
+ const query = String(args.query || "").trim();
2870
+ if (!query) return "Provide a query to recall.";
2871
+ const k = Number(args.k) || 5;
2872
+ const hits = await recall(query, MCP_SESSION, k);
2873
+ if (hits.length === 0) return "No relevant memory found.";
2874
+ const lines = hits.map(
2875
+ (h) => `${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.replace(/\s+/g, " ").trim()}`
2876
+ );
2877
+ return [`Recalled ${hits.length} relevant memory item(s):`, ...lines].join("\n");
2878
+ } catch (err2) {
2879
+ return `Could not recall right now: ${err2 instanceof Error ? err2.message : String(err2)}`;
2880
+ }
2881
+ }
2882
+ },
2883
+ {
2884
+ name: "remember",
2885
+ description: "Save a durable fact to Holt's memory for this folder.",
2886
+ inputSchema: {
2887
+ type: "object",
2888
+ properties: {
2889
+ content: { type: "string", description: "The fact to remember." }
2890
+ },
2891
+ required: ["content"]
2892
+ },
2893
+ handler: async (args) => {
2894
+ try {
2895
+ const content = String(args.content || "").trim();
2896
+ if (!content) return "Nothing to remember: content was empty.";
2897
+ const ok2 = await saveFact(content, MCP_SESSION);
2898
+ return ok2 ? "Remembered." : "Already known (duplicate).";
2899
+ } catch (err2) {
2900
+ return `Could not remember right now: ${err2 instanceof Error ? err2.message : String(err2)}`;
2901
+ }
2902
+ }
2903
+ },
2904
+ {
2905
+ name: "list_skills",
2906
+ description: "List the skills installed for this folder (workspace and global).",
2907
+ inputSchema: { type: "object", properties: {} },
2908
+ handler: async () => {
2909
+ try {
2910
+ const skills = listSkills();
2911
+ if (skills.length === 0) return "No skills installed.";
2912
+ const lines = skills.map((s) => `${s.name}: ${s.description || "(no description)"}`);
2913
+ return ["Installed skills:", ...lines].join("\n");
2914
+ } catch (err2) {
2915
+ return `Could not list skills right now: ${err2 instanceof Error ? err2.message : String(err2)}`;
2916
+ }
2917
+ }
2918
+ },
2919
+ {
2920
+ name: "get_skill",
2921
+ description: "Get the full instructions (body) of a skill by name.",
2922
+ inputSchema: {
2923
+ type: "object",
2924
+ properties: {
2925
+ name: { type: "string", description: "The skill name." }
2926
+ },
2927
+ required: ["name"]
2928
+ },
2929
+ handler: async (args) => {
2930
+ try {
2931
+ const name = String(args.name || "").trim();
2932
+ if (!name) return "Provide a skill name.";
2933
+ const loaded = loadSkill(name);
2934
+ if (!loaded) return `No skill named "${name}".`;
2935
+ return [`Skill: ${loaded.skill.name}`, "", loaded.body].join("\n");
2936
+ } catch (err2) {
2937
+ return `Could not load that skill right now: ${err2 instanceof Error ? err2.message : String(err2)}`;
2938
+ }
2939
+ }
2940
+ },
2941
+ {
2942
+ name: "memory_stats",
2943
+ description: "Show statistics for Holt's memory in this folder.",
2944
+ inputSchema: { type: "object", properties: {} },
2945
+ handler: async () => {
2946
+ try {
2947
+ const s = memStats();
2948
+ return [
2949
+ "Holt memory (this folder):",
2950
+ `moments ${s.turns}`,
2951
+ `facts ${s.facts}`,
2952
+ `sessions ${s.sessions}`,
2953
+ `embedded ${s.withEmbeddings} of ${s.turns}`,
2954
+ `size ${(s.bytes / 1024).toFixed(1)} KB`
2955
+ ].join("\n");
2956
+ } catch (err2) {
2957
+ return `Could not read memory stats right now: ${err2 instanceof Error ? err2.message : String(err2)}`;
2958
+ }
2959
+ }
2960
+ }
2961
+ ];
2962
+ }
2963
+
2964
+ // src/commands/mcp.ts
2965
+ function printSetup() {
2966
+ const config = JSON.stringify(
2967
+ { mcpServers: { holt: { command: "holt", args: ["mcp"] } } },
2968
+ null,
2969
+ 2
2970
+ );
2971
+ const out = [
2972
+ "Add Holt as an MCP server to your other tools.",
2973
+ "",
2974
+ "The server serves the memory of whatever folder it is launched in, and",
2975
+ "auto-trusts that folder on first run.",
2976
+ "",
2977
+ "Claude Code:",
2978
+ " claude mcp add holt -- holt mcp",
2979
+ "",
2980
+ "Cursor / Codex (JSON config):",
2981
+ config,
2982
+ ""
2983
+ ].join("\n");
2984
+ console.log(out);
2985
+ }
2986
+ async function mcp(sub, rest = []) {
2987
+ void rest;
2988
+ if (sub === "setup") {
2989
+ printSetup();
2990
+ return;
2991
+ }
2992
+ try {
2993
+ if (!isTrusted()) trustDir();
2994
+ console.error("Holt MCP: serving memory for " + workspace());
2995
+ await runMcpServer({ name: "holt", version: "0.7.0", tools: holtTools() });
2996
+ } catch (err2) {
2997
+ console.error("Holt MCP failed: " + (err2 instanceof Error ? err2.message : String(err2)));
2998
+ process.exitCode = 1;
2999
+ }
3000
+ }
3001
+
2508
3002
  // src/cli.ts
2509
- var VERSION = "0.5.2";
3003
+ var VERSION = "0.7.0";
2510
3004
  var BANNER = `
2511
3005
  \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
2512
3006
  \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
@@ -2525,6 +3019,7 @@ Commands:
2525
3019
  memory Inspect memory: holt memory [search <query> | embed | clear]
2526
3020
  graph See your memory as an interactive knowledge graph in the browser
2527
3021
  skill Manage skills: holt skill [list | show | create | add | remove]
3022
+ mcp Serve this folder's memory to Claude Code, Cursor, Codex (holt mcp setup)
2528
3023
  setting Configure brains, API brains, and your launch command (per folder)
2529
3024
  login <brain> Sign in to a brain: claude, codex, or gemini
2530
3025
  version Print the Holt version
@@ -2574,6 +3069,9 @@ async function main() {
2574
3069
  case "graph":
2575
3070
  await graph(process.argv.slice(3));
2576
3071
  break;
3072
+ case "mcp":
3073
+ await mcp(process.argv[3], process.argv.slice(4));
3074
+ break;
2577
3075
  default:
2578
3076
  console.log(`
2579
3077
  Unknown command: "${cmd}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.5.2",
3
+ "version": "0.7.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",