@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.
- package/README.md +29 -4
- package/dist/cli.js +331 -27
- 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
|
|
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
|
|
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**:
|
|
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
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
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
|
-
|
|
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]
|
|
391
|
-
" /
|
|
392
|
-
" /
|
|
393
|
-
" /
|
|
394
|
-
" /
|
|
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(
|
|
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
|
-
|
|
467
|
-
const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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}"`);
|