@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 +9 -1
- package/dist/cli.js +155 -15
- package/package.json +6 -3
- package/skills/onboard/SKILL.md +3 -3
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
|
-
|
|
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 (
|
|
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
|
-
...
|
|
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
|
|
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
|
|
2917
|
-
'- "
|
|
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
|
|
3806
|
-
if (!hits
|
|
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
|
-
|
|
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
|
|
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
|
|
4561
|
-
const
|
|
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.
|
|
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.
|
|
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":
|
|
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": {
|
package/skills/onboard/SKILL.md
CHANGED
|
@@ -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
|
|
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 "
|
|
124
|
-
holt memory add "Runs
|
|
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
|
```
|