@holt-os/holt 0.18.1 → 0.19.2

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/README.md CHANGED
@@ -179,6 +179,12 @@ Turns saved before semantic memory was enabled are upgraded in one pass with `ho
179
179
 
180
180
  Long conversations stay cheap: only recent turns are replayed verbatim, older context comes back through recall.
181
181
 
182
+ ### How recall works, and how well
183
+
184
+ Each message is matched against your past sessions by meaning (local embeddings) and by exact words (BM25), so names, numbers and rare terms are not lost. Holt takes the best 32 matches, adds the message next to each one in short form, groups them by session, dates every session and orders them oldest first. Your own statements get a slight edge, because that is where facts about you live. The whole block stays inside a fixed budget of about 6,000 tokens. Tune it with `HOLT_RECALL_K`, `HOLT_RECALL_BUDGET`, `HOLT_RECALL_NEIGHBORS`, `HOLT_RECALL_NEIGHBOR_CAP` and `HOLT_RECALL_USER_BOOST`.
185
+
186
+ On the public long-term memory benchmarks, Holt 0.19 scores 91.2% on LongMemEval (graded by GPT-4o), 88.2% on LoCoMo and 79.6% on BEAM at one million tokens. Every question, answer and grade, plus the code to rerun it, is in [holt-os/holt-benchmarks](https://github.com/holt-os/holt-benchmarks).
187
+
182
188
  ### Optional global memory (`holt memory global`)
183
189
 
184
190
  By default every folder's memory is **isolated**: recall never crosses folders. If you want a folder to draw on what you learned elsewhere, opt it into a shared store:
@@ -641,4 +647,6 @@ Holt is built to be extended without touching the core. See [`CONTRIBUTING.md`](
641
647
 
642
648
  ## License
643
649
 
644
- [MIT](./LICENSE) © Debashis Nayak
650
+ [MIT](./LICENSE) © [Debashis Nayak](https://productsdecoded.com/about)
651
+
652
+ Built by [Debashis Nayak](https://productsdecoded.com/about).
package/dist/cli.js CHANGED
@@ -361,17 +361,21 @@ function isInstalled(command) {
361
361
  function renderPrompt(history, message, memory = []) {
362
362
  const recent = history.slice(-MAX_REPLAY_TURNS);
363
363
  const parts = [];
364
- if (recent.length || memory.length) {
364
+ const memText = typeof memory === "string" ? memory.trim() : "";
365
+ const memRows = typeof memory === "string" ? [] : memory;
366
+ if (recent.length || memText || memRows.length) {
365
367
  parts.push(
366
368
  "You are the user's assistant, running through Holt, a tool that gives you durable memory of this user across sessions. Notes recalled from their past sessions may appear below; use them naturally and answer as their assistant.",
367
369
  "You are continuing an ongoing conversation. Use the context below and reply only as the assistant to the final User message."
368
370
  );
369
371
  }
370
- if (memory.length) {
372
+ if (memText) {
373
+ parts.push("", memText);
374
+ } else if (memRows.length) {
371
375
  parts.push(
372
376
  "",
373
377
  "Relevant notes from this user's earlier sessions:",
374
- ...memory.map((m) => `- (${m.turn.role}) ${m.turn.content.slice(0, 500)}`)
378
+ ...memRows.map((m) => `- (${m.turn.role}) ${m.turn.content.slice(0, 500)}`)
375
379
  );
376
380
  }
377
381
  if (recent.length) {
@@ -920,6 +924,142 @@ async function backfillEmbeddings(onProgress) {
920
924
  writeFileSync4(memPath(), turns.map((t) => JSON.stringify(t)).join("\n") + "\n", "utf8");
921
925
  return { embedded, total: missing.length };
922
926
  }
927
+ var envNum = (name, dflt) => {
928
+ const v = Number(process.env[name]);
929
+ return Number.isFinite(v) && v > 0 ? v : dflt;
930
+ };
931
+ function bm25Scores(query, docs) {
932
+ const toks = docs.map((d) => (d || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
933
+ const N = toks.length || 1;
934
+ const avg = toks.reduce((a, t) => a + t.length, 0) / N || 1;
935
+ const df = /* @__PURE__ */ new Map();
936
+ for (const t of toks) for (const w of new Set(t)) if (query.has(w)) df.set(w, (df.get(w) || 0) + 1);
937
+ const k1 = 1.2;
938
+ const b = 0.75;
939
+ return toks.map((t) => {
940
+ if (!t.length) return 0;
941
+ const tf = /* @__PURE__ */ new Map();
942
+ for (const w of t) if (query.has(w)) tf.set(w, (tf.get(w) || 0) + 1);
943
+ let s = 0;
944
+ for (const [w, f] of tf) {
945
+ const n = df.get(w) || 0;
946
+ const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
947
+ s += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * t.length / avg));
948
+ }
949
+ return s;
950
+ });
951
+ }
952
+ async function recallContext(query, currentSession, opts = {}) {
953
+ const k = opts.k ?? envNum("HOLT_RECALL_K", 32);
954
+ const nb = opts.neighbors ?? envNum("HOLT_RECALL_NEIGHBORS", 1);
955
+ const budget = opts.budget ?? envNum("HOLT_RECALL_BUDGET", 24e3);
956
+ const rowCap = opts.rowCap ?? envNum("HOLT_RECALL_ROWCAP", 2e3);
957
+ const neighborCap = opts.neighborCap ?? envNum("HOLT_RECALL_NEIGHBOR_CAP", 250);
958
+ const userBoost = opts.userBoost ?? envNum("HOLT_RECALL_USER_BOOST", 1.15);
959
+ const all = loadTurns();
960
+ try {
961
+ const ws = workspace();
962
+ if (isGlobalEnabled(ws)) {
963
+ for (const g of loadGlobalFacts()) {
964
+ if (g.workspace === ws) continue;
965
+ all.push({ id: g.id, ts: g.ts, session: `global:${g.workspace}`, role: g.role, content: g.content, emb: g.emb });
966
+ }
967
+ }
968
+ } catch {
969
+ }
970
+ const at = (i) => all[i];
971
+ const pastIdx = [];
972
+ all.forEach((t, i) => {
973
+ if (t.session !== currentSession) pastIdx.push(i);
974
+ });
975
+ if (!pastIdx.length) return { blocks: [], hits: 0, chars: 0 };
976
+ const qEmb = await embed(query);
977
+ const qTok = tokens(query);
978
+ const bm = bm25Scores(qTok, pastIdx.map((i) => at(i).content));
979
+ const bmMax = Math.max(0, ...bm) || 1;
980
+ const scored = [];
981
+ pastIdx.forEach((i, j) => {
982
+ const t = at(i);
983
+ const cos = qEmb && Array.isArray(t.emb) ? cosine(qEmb, t.emb) : 0;
984
+ const kw = (bm[j] ?? 0) / bmMax;
985
+ let s = qEmb ? 0.75 * cos + 0.25 * kw : kw;
986
+ if (s <= (qEmb ? 0.3 : 0.1)) return;
987
+ if (t.role === "fact") s *= FACT_BOOST;
988
+ else if (t.role === "user") s *= userBoost;
989
+ scored.push({ i, s });
990
+ });
991
+ scored.sort((a, b) => b.s - a.s);
992
+ const top = scored.slice(0, k);
993
+ if (!top.length) return { blocks: [], hits: 0, chars: 0 };
994
+ const pick = /* @__PURE__ */ new Map();
995
+ for (const { i } of top) {
996
+ pick.set(i, true);
997
+ for (let d = 1; d <= nb; d++) {
998
+ for (const j of [i - d, i + d]) {
999
+ const t = all[j];
1000
+ if (t && t.session === at(i).session && t.session !== currentSession && !pick.has(j)) pick.set(j, false);
1001
+ }
1002
+ }
1003
+ }
1004
+ const rank = new Map(top.map(({ i }, r) => [i, r]));
1005
+ const order = [...pick.keys()].sort((a, b) => {
1006
+ const ra = rank.get(a) ?? Infinity;
1007
+ const rb = rank.get(b) ?? Infinity;
1008
+ if (ra !== rb) return ra - rb;
1009
+ return a - b;
1010
+ });
1011
+ const kept = /* @__PURE__ */ new Set();
1012
+ let chars = 0;
1013
+ const capOf = (i) => pick.get(i) ? rowCap : neighborCap;
1014
+ for (const i of order) {
1015
+ const len = Math.min(at(i).content.length, capOf(i));
1016
+ if (chars + len > budget && kept.size) continue;
1017
+ kept.add(i);
1018
+ chars += len;
1019
+ }
1020
+ const bySession = /* @__PURE__ */ new Map();
1021
+ for (const i of [...kept].sort((a, b) => a - b)) {
1022
+ const s = at(i).session;
1023
+ if (!bySession.has(s)) bySession.set(s, []);
1024
+ bySession.get(s).push(i);
1025
+ }
1026
+ const blocks = [...bySession.entries()].map(([session, idx]) => ({
1027
+ session,
1028
+ ts: Math.min(...idx.map((i) => at(i).ts)),
1029
+ rows: idx.map((i) => ({
1030
+ role: at(i).role,
1031
+ content: at(i).content.length > capOf(i) ? at(i).content.slice(0, capOf(i)) + " \u2026" : at(i).content,
1032
+ ts: at(i).ts,
1033
+ hit: !!pick.get(i)
1034
+ }))
1035
+ }));
1036
+ blocks.sort((a, b) => a.ts - b.ts);
1037
+ return { blocks, hits: top.length, chars };
1038
+ }
1039
+ var isoDay = (ts) => {
1040
+ try {
1041
+ return new Date(ts).toISOString().slice(0, 10);
1042
+ } catch {
1043
+ return "unknown date";
1044
+ }
1045
+ };
1046
+ function formatMemoryContext(ctx, now = /* @__PURE__ */ new Date()) {
1047
+ if (!ctx.blocks.length) return "";
1048
+ const today = typeof now === "string" ? now : isoDay(now.getTime());
1049
+ const out = [
1050
+ `Today is ${today}. Notes from this user's earlier sessions follow, oldest first, each with its date.`,
1051
+ "If notes disagree, the most recent one is current. Work out dates and durations from the session dates.",
1052
+ "To count or total something, first list every matching item across all sessions, then answer. If the notes do not cover it, say so."
1053
+ ];
1054
+ for (const b of ctx.blocks) {
1055
+ out.push("", `### Session on ${isoDay(b.ts)}`);
1056
+ for (const r of b.rows) {
1057
+ const who = r.role === "user" ? "User" : r.role === "assistant" ? "Assistant" : "Fact";
1058
+ out.push(`${who}: ${r.content.trim()}`);
1059
+ }
1060
+ }
1061
+ return out.join("\n");
1062
+ }
923
1063
 
924
1064
  // src/telegram.ts
925
1065
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5, mkdirSync as mkdirSync5 } from "fs";
@@ -1351,7 +1491,7 @@ async function runTask(task, opts = {}) {
1351
1491
  };
1352
1492
  }
1353
1493
  const runSession = "run-" + newSessionId();
1354
- const recalled = opts.recall !== false ? await recall(task, runSession, 4) : [];
1494
+ const recalled = opts.recall !== false ? formatMemoryContext(await recallContext(task, runSession)) : "";
1355
1495
  const block = skillsPromptBlock();
1356
1496
  const base = renderPrompt([], task, recalled);
1357
1497
  const prompt = block ? block + "\n\n" + base : base;
@@ -2913,8 +3053,8 @@ function buildExtractionPrompt(history) {
2913
3053
  "- If nothing is worth saving, output exactly []",
2914
3054
  "",
2915
3055
  "Good examples:",
2916
- '- "User decided to target the Netherlands as the primary job market, HSM visa route."',
2917
- '- "HDFC EMI is 17,394 rupees per month with 29 payments remaining as of March 2026."',
3056
+ '- "User decided to move the team wiki from Notion to Obsidian by the end of Q3."',
3057
+ '- "Car loan payment is 420 euros per month with 18 payments remaining as of March 2026."',
2918
3058
  "",
2919
3059
  "Bad examples (do NOT output these):",
2920
3060
  '- "User asked a question about their finances."',
@@ -3802,20 +3942,19 @@ async function inject() {
3802
3942
  return;
3803
3943
  }
3804
3944
  const session = data.session_id || newSessionId();
3805
- const hits = await recall(prompt, session, 4);
3806
- if (!hits.length) {
3945
+ const ctx = await recallContext(prompt, session, { budget: Number(process.env.HOLT_RECALL_BUDGET) || 8e3 });
3946
+ if (!ctx.hits) {
3807
3947
  dlog("inject -> recalled 0 notes");
3808
3948
  return;
3809
3949
  }
3810
- const lines = hits.map((h) => `- ${h.turn.content.replace(/\s+/g, " ").trim().slice(0, 500)}`);
3811
3950
  process.stdout.write(
3812
3951
  [
3813
- "Background on this user, recalled from earlier sessions (use it directly, do not mention looking it up):",
3814
- ...lines,
3952
+ "Background on this user, recalled from earlier sessions (use it directly, do not mention looking it up).",
3953
+ formatMemoryContext(ctx),
3815
3954
  ""
3816
3955
  ].join("\n")
3817
3956
  );
3818
- dlog(`inject -> injected ${hits.length} note(s)`);
3957
+ dlog(`inject -> injected ${ctx.hits} note(s), ${ctx.chars} chars`);
3819
3958
  } catch (e) {
3820
3959
  process.stderr.write("holt hook inject: " + (e instanceof Error ? e.message : String(e)) + "\n");
3821
3960
  dlog("inject -> error: " + (e instanceof Error ? e.message : String(e)));
@@ -4557,8 +4696,9 @@ async function chat() {
4557
4696
  }
4558
4697
  }
4559
4698
  }
4560
- const remembered = await recall(line, session, 4);
4561
- const label = remembered.length ? `Holt is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `Holt is thinking...`;
4699
+ const recalledCtx = await recallContext(line, session);
4700
+ const remembered = formatMemoryContext(recalledCtx);
4701
+ const label = recalledCtx.hits ? `Holt is thinking (recalled ${recalledCtx.hits} moment${recalledCtx.hits === 1 ? "" : "s"})...` : `Holt is thinking...`;
4562
4702
  console.log(c.dim(` ${label}`) + "\n");
4563
4703
  const skillsBlock = skillsPromptBlock();
4564
4704
  const base = renderPrompt(history, line, remembered);
@@ -7547,7 +7687,7 @@ function holtTools() {
7547
7687
  }
7548
7688
 
7549
7689
  // src/version.ts
7550
- var VERSION = "0.18.1";
7690
+ var VERSION = "0.19.2";
7551
7691
 
7552
7692
  // src/commands/mcp.ts
7553
7693
  function printSetup() {
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.18.1",
3
+ "version": "0.19.2",
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",
7
- "author": "Debashis Nayak",
7
+ "author": {
8
+ "name": "Debashis Nayak",
9
+ "url": "https://productsdecoded.com/about"
10
+ },
8
11
  "homepage": "https://productsdecoded.com/holt",
9
12
  "repository": {
10
13
  "type": "git",
@@ -44,7 +47,7 @@
44
47
  "build": "tsup src/cli.ts --format esm --clean",
45
48
  "start": "node dist/cli.js",
46
49
  "typecheck": "tsc --noEmit",
47
- "test": "tsx scripts/specs.test.ts && tsx scripts/skillcompiler.test.ts && tsx scripts/scheduler.test.ts && tsx scripts/statusline.test.ts",
50
+ "test": "tsx scripts/memory.test.ts && tsx scripts/specs.test.ts && tsx scripts/skillcompiler.test.ts && tsx scripts/scheduler.test.ts && tsx scripts/statusline.test.ts",
48
51
  "release": "bash scripts/release.sh"
49
52
  },
50
53
  "devDependencies": {
@@ -83,7 +83,7 @@ holt memory add "<one durable fact>"
83
83
  One fact per call. Several facts can be piped in at once, one per line.
84
84
 
85
85
  Write facts that will still be true in a month and that a future session could
86
- not guess. "Runs product for a fintech in Bhubaneswar, nine years in" is a fact.
86
+ not guess. "Runs design for a logistics startup in Lisbon, six years in" is a fact.
87
87
  "Wants to be more productive" is noise. Include names, numbers, and dates. Write
88
88
  the fact so it stands alone, because recall will surface it without this
89
89
  conversation around it.
@@ -120,6 +120,6 @@ things genuinely in flight this week?"
120
120
  Then, quietly, between questions:
121
121
 
122
122
  ```
123
- holt memory add "Senior product manager, nine years, currently between roles and job-hunting in the Netherlands"
124
- holt memory add "Runs Sasyam Agro with an uncle in Bargarh; demo plots are on the uncle's land"
123
+ holt memory add "Product designer, six years, leading the checkout redesign at a logistics startup"
124
+ holt memory add "Runs a weekend pottery studio with a cousin; classes are on Saturday mornings"
125
125
  ```