@holt-os/holt 0.5.1 → 0.6.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 +5 -2
- package/dist/cli.js +342 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ During `holt init` you:
|
|
|
37
37
|
1. **Trust the folder.**
|
|
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
|
-
4. **Pick a default** brain and, optionally, a **launch command
|
|
40
|
+
4. **Pick a default** brain and, optionally, a **launch command**: a short word like `ai` that starts `holt chat`. Holt installs it as a tiny launcher next to its own binary, so it works immediately in the same terminal, no sourcing or restart needed.
|
|
41
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.
|
|
42
42
|
|
|
43
43
|
## Using it
|
|
@@ -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
|
|
@@ -155,7 +158,7 @@ Small strongly-typed **TypeScript core**, zero runtime dependencies: command dis
|
|
|
155
158
|
Built in always-shippable phases toward a full-vision v1:
|
|
156
159
|
|
|
157
160
|
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
|
|
161
|
+
1. **Memory**: per-folder store, semantic recall, streaming replies, and distilled facts per session *(shipped)*
|
|
159
162
|
2. **Any LLM directly**: API brains with your own key, HTML or Markdown output *(shipped)*
|
|
160
163
|
3. **Skills**: portable SKILL.md skills, create/add/run *(shipped)*
|
|
161
164
|
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:
|
|
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:
|
|
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;
|
|
@@ -248,17 +251,58 @@ function runBrain(brain, prompt, onChunk) {
|
|
|
248
251
|
|
|
249
252
|
// src/alias.ts
|
|
250
253
|
import { homedir as homedir2 } from "os";
|
|
251
|
-
import { join as join3 } from "path";
|
|
252
|
-
import {
|
|
254
|
+
import { join as join3, dirname, delimiter } from "path";
|
|
255
|
+
import {
|
|
256
|
+
readFileSync as readFileSync3,
|
|
257
|
+
writeFileSync as writeFileSync3,
|
|
258
|
+
existsSync as existsSync3,
|
|
259
|
+
mkdirSync as mkdirSync3,
|
|
260
|
+
rmSync,
|
|
261
|
+
chmodSync,
|
|
262
|
+
realpathSync
|
|
263
|
+
} from "fs";
|
|
253
264
|
var START = "# >>> holt launch alias >>>";
|
|
254
265
|
var END = "# <<< holt launch alias <<<";
|
|
266
|
+
var SHIM_MARKER = "# holt launcher";
|
|
267
|
+
var GLOBAL_DIR2 = join3(homedir2(), ".holt");
|
|
268
|
+
var LAUNCHER_STATE = join3(GLOBAL_DIR2, "launcher.json");
|
|
255
269
|
function rcFile() {
|
|
256
270
|
const shell = process.env.SHELL || "";
|
|
257
271
|
if (shell.includes("zsh")) return join3(homedir2(), ".zshrc");
|
|
258
272
|
if (shell.includes("bash")) return join3(homedir2(), ".bashrc");
|
|
259
273
|
return join3(homedir2(), ".profile");
|
|
260
274
|
}
|
|
261
|
-
function
|
|
275
|
+
function readState() {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(readFileSync3(LAUNCHER_STATE, "utf8"));
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function writeState(state) {
|
|
283
|
+
mkdirSync3(GLOBAL_DIR2, { recursive: true });
|
|
284
|
+
if (state) writeFileSync3(LAUNCHER_STATE, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
285
|
+
else if (existsSync3(LAUNCHER_STATE)) rmSync(LAUNCHER_STATE);
|
|
286
|
+
}
|
|
287
|
+
function pathDirs() {
|
|
288
|
+
return (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
289
|
+
}
|
|
290
|
+
function launcherBinDir() {
|
|
291
|
+
const dirs = pathDirs();
|
|
292
|
+
const candidates = [];
|
|
293
|
+
const invoked = process.argv[1];
|
|
294
|
+
if (invoked) candidates.push(dirname(invoked));
|
|
295
|
+
candidates.push(dirname(process.execPath));
|
|
296
|
+
for (const dir of candidates) {
|
|
297
|
+
if (!dirs.includes(dir)) continue;
|
|
298
|
+
try {
|
|
299
|
+
if (dirs.includes(dir) || dirs.includes(realpathSync(dir))) return dir;
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
function installRcAlias(name) {
|
|
262
306
|
const file = rcFile();
|
|
263
307
|
const block = `${START}
|
|
264
308
|
alias ${name}="holt chat"
|
|
@@ -269,30 +313,80 @@ ${END}`;
|
|
|
269
313
|
if (re.test(content)) content = content.replace(re, block);
|
|
270
314
|
else content = content.replace(/\n*$/, "\n") + block + "\n";
|
|
271
315
|
writeFileSync3(file, content, "utf8");
|
|
272
|
-
|
|
316
|
+
writeState({ name, kind: "rc", file });
|
|
317
|
+
return { ok: true, kind: "rc", file, immediate: false };
|
|
273
318
|
} catch (e) {
|
|
274
|
-
return { ok: false, file, message: `Could not write ${file}: ${e.message}` };
|
|
319
|
+
return { ok: false, kind: "none", file, immediate: false, message: `Could not write ${file}: ${e.message}` };
|
|
275
320
|
}
|
|
276
321
|
}
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
322
|
+
function installAlias(name) {
|
|
323
|
+
removeAlias();
|
|
324
|
+
if (process.platform !== "win32") {
|
|
325
|
+
const binDir = launcherBinDir();
|
|
326
|
+
if (binDir) {
|
|
327
|
+
const file = join3(binDir, name);
|
|
328
|
+
if (existsSync3(file)) {
|
|
329
|
+
try {
|
|
330
|
+
if (!readFileSync3(file, "utf8").includes(SHIM_MARKER)) {
|
|
331
|
+
return { ok: false, kind: "none", file, immediate: false, message: `"${name}" already exists at ${file}. Pick another word.` };
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
return { ok: false, kind: "none", file, immediate: false, message: `"${name}" already exists at ${file}. Pick another word.` };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
writeFileSync3(file, `#!/bin/sh
|
|
339
|
+
${SHIM_MARKER}
|
|
340
|
+
exec holt chat "$@"
|
|
341
|
+
`, "utf8");
|
|
342
|
+
chmodSync(file, 493);
|
|
343
|
+
writeState({ name, kind: "bin", file });
|
|
344
|
+
return { ok: true, kind: "bin", file, immediate: true };
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
}
|
|
284
348
|
}
|
|
349
|
+
return installRcAlias(name);
|
|
285
350
|
}
|
|
286
351
|
function removeAlias() {
|
|
352
|
+
const state = readState();
|
|
353
|
+
if (state?.kind === "bin" && existsSync3(state.file)) {
|
|
354
|
+
try {
|
|
355
|
+
if (readFileSync3(state.file, "utf8").includes(SHIM_MARKER)) rmSync(state.file);
|
|
356
|
+
} catch {
|
|
357
|
+
}
|
|
358
|
+
}
|
|
287
359
|
const file = rcFile();
|
|
288
360
|
try {
|
|
289
361
|
if (existsSync3(file)) {
|
|
290
362
|
const content = readFileSync3(file, "utf8").replace(new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`), "\n");
|
|
291
363
|
writeFileSync3(file, content, "utf8");
|
|
292
364
|
}
|
|
293
|
-
|
|
365
|
+
writeState(null);
|
|
366
|
+
return { ok: true, kind: "none", file, immediate: true };
|
|
294
367
|
} catch (e) {
|
|
295
|
-
|
|
368
|
+
writeState(null);
|
|
369
|
+
return { ok: false, kind: "none", file, immediate: true, message: e.message };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function currentAlias() {
|
|
373
|
+
const state = readState();
|
|
374
|
+
if (state) {
|
|
375
|
+
if (state.kind === "bin") {
|
|
376
|
+
try {
|
|
377
|
+
if (existsSync3(state.file) && readFileSync3(state.file, "utf8").includes(SHIM_MARKER)) return state.name;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
return state.name;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const m = readFileSync3(rcFile(), "utf8").match(/alias\s+([^\s=]+)="holt chat"/);
|
|
387
|
+
return m ? m[1] : null;
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
296
390
|
}
|
|
297
391
|
}
|
|
298
392
|
|
|
@@ -313,7 +407,7 @@ function runInteractive(cmd, args) {
|
|
|
313
407
|
}
|
|
314
408
|
|
|
315
409
|
// src/memory.ts
|
|
316
|
-
import { appendFileSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as
|
|
410
|
+
import { appendFileSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync4, rmSync as rmSync2, statSync } from "fs";
|
|
317
411
|
import { join as join4 } from "path";
|
|
318
412
|
import { randomUUID } from "crypto";
|
|
319
413
|
var OLLAMA_URL = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
|
|
@@ -324,6 +418,9 @@ function memDir() {
|
|
|
324
418
|
function memPath() {
|
|
325
419
|
return join4(memDir(), "turns.jsonl");
|
|
326
420
|
}
|
|
421
|
+
function factsMdPath() {
|
|
422
|
+
return join4(memDir(), "facts.md");
|
|
423
|
+
}
|
|
327
424
|
function newSessionId() {
|
|
328
425
|
return randomUUID().slice(0, 8);
|
|
329
426
|
}
|
|
@@ -373,18 +470,64 @@ function loadTurns() {
|
|
|
373
470
|
return out;
|
|
374
471
|
}
|
|
375
472
|
function appendTurn(t) {
|
|
376
|
-
|
|
473
|
+
mkdirSync4(memDir(), { recursive: true });
|
|
377
474
|
appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
|
|
378
475
|
}
|
|
379
476
|
function clearMemory() {
|
|
380
|
-
if (existsSync4(memPath()))
|
|
477
|
+
if (existsSync4(memPath())) rmSync2(memPath());
|
|
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;
|
|
381
523
|
}
|
|
382
524
|
function memStats() {
|
|
383
525
|
const turns = loadTurns();
|
|
384
526
|
const sessions = new Set(turns.map((t) => t.session)).size;
|
|
385
527
|
const withEmbeddings = turns.filter((t) => Array.isArray(t.emb)).length;
|
|
528
|
+
const facts = turns.filter((t) => t.role === "fact").length;
|
|
386
529
|
const bytes = existsSync4(memPath()) ? statSync(memPath()).size : 0;
|
|
387
|
-
return { turns: turns.length, sessions, withEmbeddings, bytes };
|
|
530
|
+
return { turns: turns.length, facts, sessions, withEmbeddings, bytes };
|
|
388
531
|
}
|
|
389
532
|
function cosine(a, b) {
|
|
390
533
|
let dot = 0;
|
|
@@ -412,6 +555,7 @@ function keywordScore(q, text) {
|
|
|
412
555
|
for (const w of q) if (t.has(w)) hit++;
|
|
413
556
|
return hit / q.size;
|
|
414
557
|
}
|
|
558
|
+
var FACT_BOOST = 1.15;
|
|
415
559
|
async function recall(query, currentSession, k = 4) {
|
|
416
560
|
const past = loadTurns().filter((t) => t.session !== currentSession);
|
|
417
561
|
if (past.length === 0) return [];
|
|
@@ -422,7 +566,10 @@ async function recall(query, currentSession, k = 4) {
|
|
|
422
566
|
let score = 0;
|
|
423
567
|
if (qEmb && Array.isArray(turn.emb)) score = cosine(qEmb, turn.emb);
|
|
424
568
|
else score = keywordScore(qTok, turn.content);
|
|
425
|
-
if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15))
|
|
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
|
+
}
|
|
426
573
|
}
|
|
427
574
|
scored.sort((a, b) => b.score - a.score);
|
|
428
575
|
return scored.slice(0, k);
|
|
@@ -442,7 +589,7 @@ async function backfillEmbeddings(onProgress) {
|
|
|
442
589
|
done++;
|
|
443
590
|
if (onProgress) onProgress(done, missing.length);
|
|
444
591
|
}
|
|
445
|
-
|
|
592
|
+
mkdirSync4(memDir(), { recursive: true });
|
|
446
593
|
writeFileSync4(memPath(), turns.map((t) => JSON.stringify(t)).join("\n") + "\n", "utf8");
|
|
447
594
|
return { embedded, total: missing.length };
|
|
448
595
|
}
|
|
@@ -572,9 +719,10 @@ async function runSettings(ask) {
|
|
|
572
719
|
} else if (choice === "a") {
|
|
573
720
|
const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
|
|
574
721
|
if (name && name !== "holt") {
|
|
575
|
-
if (isInstalled(name)) console.log(c.dim(` note: "${name}" already exists; the alias will shadow it in new shells.`));
|
|
576
722
|
const r = installAlias(name);
|
|
577
|
-
|
|
723
|
+
if (r.ok && r.immediate) console.log(c.green(` "${name}" is ready to use right now.`));
|
|
724
|
+
else if (r.ok) console.log(c.green(` alias "${name}" added to ${r.file}. Run: source ${r.file} (new terminals will not need it).`));
|
|
725
|
+
else console.log(c.red(" " + r.message));
|
|
578
726
|
} else {
|
|
579
727
|
removeAlias();
|
|
580
728
|
console.log(c.dim(" reset to holt."));
|
|
@@ -646,12 +794,16 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
|
|
|
646
794
|
const defaultBrain = chosen.includes(dans) ? dans : defPick;
|
|
647
795
|
const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
|
|
648
796
|
let aliasNote = "";
|
|
649
|
-
let
|
|
797
|
+
let aliasNeedsSource = "";
|
|
798
|
+
let aliasWorked = false;
|
|
650
799
|
if (aliasAns && aliasAns !== "holt") {
|
|
651
|
-
if (isInstalled(aliasAns)) console.log(c.dim(` note: "${aliasAns}" already exists; the alias will shadow it in new shells.`));
|
|
652
800
|
const r = installAlias(aliasAns);
|
|
653
|
-
|
|
654
|
-
|
|
801
|
+
aliasWorked = r.ok;
|
|
802
|
+
if (r.ok && r.immediate) aliasNote = c.green(` "${aliasAns}" is ready to use right now (launcher at ${r.file}).`);
|
|
803
|
+
else if (r.ok) {
|
|
804
|
+
aliasNote = c.green(` alias "${aliasAns}" -> holt chat added to ${r.file}`);
|
|
805
|
+
aliasNeedsSource = r.file;
|
|
806
|
+
} else aliasNote = c.red(" " + r.message);
|
|
655
807
|
}
|
|
656
808
|
let wantMemorySetup = false;
|
|
657
809
|
const embedReady = await embeddingsAvailable();
|
|
@@ -708,9 +860,11 @@ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
|
|
|
708
860
|
console.log("\n" + c.green("Saved to ./.holt/config.json"));
|
|
709
861
|
if (aliasNote) console.log(aliasNote);
|
|
710
862
|
if (cfg.defaultBrain) {
|
|
711
|
-
if (
|
|
863
|
+
if (aliasWorked && !aliasNeedsSource) {
|
|
864
|
+
console.log("Start chatting: " + c.accent(aliasAns) + "\n");
|
|
865
|
+
} else if (aliasNeedsSource) {
|
|
712
866
|
console.log("\nStart chatting:");
|
|
713
|
-
console.log(" " + c.accent(`source ${
|
|
867
|
+
console.log(" " + c.accent(`source ${aliasNeedsSource}`) + c.dim(" (once; new terminals will not need it)"));
|
|
714
868
|
console.log(" " + c.accent(aliasAns) + "\n");
|
|
715
869
|
console.log(c.dim(" Or right now, without sourcing: holt chat\n"));
|
|
716
870
|
} else {
|
|
@@ -877,6 +1031,115 @@ async function runApiBrain(brain, prompt, onChunk) {
|
|
|
877
1031
|
return { ok: false, text: `${brain.provider} returned an empty reply.` };
|
|
878
1032
|
}
|
|
879
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
|
+
|
|
880
1143
|
// src/output.ts
|
|
881
1144
|
import { writeFileSync as writeFileSync5 } from "fs";
|
|
882
1145
|
import { join as join5 } from "path";
|
|
@@ -1203,7 +1466,7 @@ async function chat() {
|
|
|
1203
1466
|
const stats = memStats();
|
|
1204
1467
|
console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
|
|
1205
1468
|
console.log(c.dim(
|
|
1206
|
-
`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"}).`
|
|
1207
1470
|
));
|
|
1208
1471
|
if (embedOk && stats.withEmbeddings < stats.turns) {
|
|
1209
1472
|
console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
|
|
@@ -1245,7 +1508,7 @@ async function chat() {
|
|
|
1245
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, " ")}`));
|
|
1246
1509
|
} else {
|
|
1247
1510
|
const s = memStats();
|
|
1248
|
-
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/`));
|
|
1249
1512
|
console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
|
|
1250
1513
|
}
|
|
1251
1514
|
continue;
|
|
@@ -1353,6 +1616,14 @@ async function chat() {
|
|
|
1353
1616
|
console.log(c.red("\n " + res.text + "\n"));
|
|
1354
1617
|
}
|
|
1355
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
|
+
}
|
|
1356
1627
|
close();
|
|
1357
1628
|
console.log(c.dim("\nBye.\n"));
|
|
1358
1629
|
}
|
|
@@ -1374,6 +1645,7 @@ async function login(which) {
|
|
|
1374
1645
|
}
|
|
1375
1646
|
|
|
1376
1647
|
// src/commands/memory.ts
|
|
1648
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
|
|
1377
1649
|
async function memoryCmd(sub, rest = []) {
|
|
1378
1650
|
const { ask, close } = createReader();
|
|
1379
1651
|
if (!await ensureTrusted(ask)) {
|
|
@@ -1407,7 +1679,7 @@ async function memoryCmd(sub, rest = []) {
|
|
|
1407
1679
|
}
|
|
1408
1680
|
const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
|
|
1409
1681
|
if (missing === 0) {
|
|
1410
|
-
console.log(c.dim("\n All
|
|
1682
|
+
console.log(c.dim("\n All memories already have embeddings.\n"));
|
|
1411
1683
|
close();
|
|
1412
1684
|
return;
|
|
1413
1685
|
}
|
|
@@ -1415,7 +1687,18 @@ async function memoryCmd(sub, rest = []) {
|
|
|
1415
1687
|
const r = await backfillEmbeddings((done, total) => {
|
|
1416
1688
|
process.stdout.write(`\r embedding ${done}/${total}...`);
|
|
1417
1689
|
});
|
|
1418
|
-
console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total}
|
|
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");
|
|
1419
1702
|
close();
|
|
1420
1703
|
return;
|
|
1421
1704
|
}
|
|
@@ -1442,6 +1725,7 @@ async function memoryCmd(sub, rest = []) {
|
|
|
1442
1725
|
const sessions = new Set(loadTurns().map((t) => t.session)).size;
|
|
1443
1726
|
console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
|
|
1444
1727
|
console.log(` moments ${s.turns}`);
|
|
1728
|
+
console.log(` facts ${s.facts} (./.holt/memory/facts.md)`);
|
|
1445
1729
|
console.log(` sessions ${sessions}`);
|
|
1446
1730
|
console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
|
|
1447
1731
|
console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
|
|
@@ -1451,20 +1735,21 @@ async function memoryCmd(sub, rest = []) {
|
|
|
1451
1735
|
${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
|
|
1452
1736
|
}
|
|
1453
1737
|
console.log(c.dim("\n holt memory search <query> find remembered moments"));
|
|
1454
|
-
console.log(c.dim(" holt memory
|
|
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"));
|
|
1455
1740
|
console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
|
|
1456
1741
|
close();
|
|
1457
1742
|
}
|
|
1458
1743
|
|
|
1459
1744
|
// src/commands/skill.ts
|
|
1460
1745
|
import {
|
|
1461
|
-
mkdirSync as
|
|
1746
|
+
mkdirSync as mkdirSync5,
|
|
1462
1747
|
writeFileSync as writeFileSync6,
|
|
1463
|
-
readFileSync as
|
|
1464
|
-
existsSync as
|
|
1748
|
+
readFileSync as readFileSync7,
|
|
1749
|
+
existsSync as existsSync7,
|
|
1465
1750
|
readdirSync as readdirSync2,
|
|
1466
1751
|
statSync as statSync3,
|
|
1467
|
-
rmSync as
|
|
1752
|
+
rmSync as rmSync3,
|
|
1468
1753
|
cpSync,
|
|
1469
1754
|
mkdtempSync
|
|
1470
1755
|
} from "fs";
|
|
@@ -1514,7 +1799,7 @@ function isGitUrl(s) {
|
|
|
1514
1799
|
return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
|
|
1515
1800
|
}
|
|
1516
1801
|
function findSkillDir(root) {
|
|
1517
|
-
if (
|
|
1802
|
+
if (existsSync7(join7(root, "SKILL.md"))) return root;
|
|
1518
1803
|
let subs = [];
|
|
1519
1804
|
try {
|
|
1520
1805
|
subs = readdirSync2(root).filter((f) => {
|
|
@@ -1527,7 +1812,7 @@ function findSkillDir(root) {
|
|
|
1527
1812
|
} catch {
|
|
1528
1813
|
return null;
|
|
1529
1814
|
}
|
|
1530
|
-
const withSkill = subs.filter((f) =>
|
|
1815
|
+
const withSkill = subs.filter((f) => existsSync7(join7(root, f, "SKILL.md")));
|
|
1531
1816
|
if (withSkill.length === 1) return join7(root, withSkill[0]);
|
|
1532
1817
|
return null;
|
|
1533
1818
|
}
|
|
@@ -1580,13 +1865,13 @@ function cmdCreate(name, global) {
|
|
|
1580
1865
|
}
|
|
1581
1866
|
const scope = global ? "global" : "workspace";
|
|
1582
1867
|
const dir = join7(skillsRoot(scope), clean);
|
|
1583
|
-
if (
|
|
1868
|
+
if (existsSync7(dir)) {
|
|
1584
1869
|
console.log(c.red(`
|
|
1585
1870
|
A ${scope} skill named "${clean}" already exists.`));
|
|
1586
1871
|
console.log(c.dim(" " + dir + "\n"));
|
|
1587
1872
|
return;
|
|
1588
1873
|
}
|
|
1589
|
-
|
|
1874
|
+
mkdirSync5(dir, { recursive: true });
|
|
1590
1875
|
writeFileSync6(join7(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
|
|
1591
1876
|
console.log(c.green(`
|
|
1592
1877
|
Created ${scope} skill "${clean}".`));
|
|
@@ -1614,7 +1899,7 @@ function cmdAdd(source, global) {
|
|
|
1614
1899
|
fetched = tempDir;
|
|
1615
1900
|
} else {
|
|
1616
1901
|
fetched = resolve(source);
|
|
1617
|
-
if (!
|
|
1902
|
+
if (!existsSync7(fetched)) {
|
|
1618
1903
|
console.log(c.red(`
|
|
1619
1904
|
No such path: ${fetched}
|
|
1620
1905
|
`));
|
|
@@ -1628,7 +1913,7 @@ function cmdAdd(source, global) {
|
|
|
1628
1913
|
}
|
|
1629
1914
|
let skillName = "";
|
|
1630
1915
|
try {
|
|
1631
|
-
const { data } = parseFrontmatter(
|
|
1916
|
+
const { data } = parseFrontmatter(readFileSync7(join7(skillDir, "SKILL.md"), "utf8"));
|
|
1632
1917
|
skillName = sanitizeName(data.name || "");
|
|
1633
1918
|
} catch {
|
|
1634
1919
|
skillName = "";
|
|
@@ -1639,23 +1924,23 @@ function cmdAdd(source, global) {
|
|
|
1639
1924
|
return;
|
|
1640
1925
|
}
|
|
1641
1926
|
const dest = join7(skillsRoot(scope), skillName);
|
|
1642
|
-
if (
|
|
1927
|
+
if (existsSync7(dest)) {
|
|
1643
1928
|
console.log(c.red(`
|
|
1644
1929
|
A ${scope} skill named "${skillName}" already exists.`));
|
|
1645
1930
|
console.log(c.dim(" " + dest + "\n"));
|
|
1646
1931
|
return;
|
|
1647
1932
|
}
|
|
1648
|
-
|
|
1933
|
+
mkdirSync5(skillsRoot(scope), { recursive: true });
|
|
1649
1934
|
cpSync(skillDir, dest, { recursive: true });
|
|
1650
1935
|
const nestedGit = join7(dest, ".git");
|
|
1651
|
-
if (
|
|
1936
|
+
if (existsSync7(nestedGit)) rmSync3(nestedGit, { recursive: true, force: true });
|
|
1652
1937
|
console.log(c.green(`
|
|
1653
1938
|
Installed ${scope} skill "${skillName}".`));
|
|
1654
1939
|
console.log(c.dim(" " + join7(dest, "SKILL.md") + "\n"));
|
|
1655
1940
|
} finally {
|
|
1656
|
-
if (tempDir &&
|
|
1941
|
+
if (tempDir && existsSync7(tempDir)) {
|
|
1657
1942
|
try {
|
|
1658
|
-
|
|
1943
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
1659
1944
|
} catch {
|
|
1660
1945
|
}
|
|
1661
1946
|
}
|
|
@@ -1669,7 +1954,7 @@ async function cmdRemove(name, ask) {
|
|
|
1669
1954
|
}
|
|
1670
1955
|
const wsDir = join7(skillsRoot("workspace"), clean);
|
|
1671
1956
|
const globalDir = join7(skillsRoot("global"), clean);
|
|
1672
|
-
const target =
|
|
1957
|
+
const target = existsSync7(wsDir) ? wsDir : existsSync7(globalDir) ? globalDir : "";
|
|
1673
1958
|
if (!target) {
|
|
1674
1959
|
console.log(c.dim(`
|
|
1675
1960
|
No skill named "${clean}" found.
|
|
@@ -1680,7 +1965,7 @@ async function cmdRemove(name, ask) {
|
|
|
1680
1965
|
const a = (await ask(`
|
|
1681
1966
|
Delete ${scope} skill "${clean}"? [y/N] `) ?? "").trim().toLowerCase();
|
|
1682
1967
|
if (a === "y" || a === "yes") {
|
|
1683
|
-
|
|
1968
|
+
rmSync3(target, { recursive: true, force: true });
|
|
1684
1969
|
console.log(c.green(" Removed.\n"));
|
|
1685
1970
|
} else {
|
|
1686
1971
|
console.log(c.dim(" Kept.\n"));
|
|
@@ -1727,8 +2012,8 @@ async function skillCmd(sub, rest = []) {
|
|
|
1727
2012
|
}
|
|
1728
2013
|
|
|
1729
2014
|
// src/commands/graph.ts
|
|
1730
|
-
import { writeFileSync as writeFileSync7, mkdirSync as
|
|
1731
|
-
import { join as join8, dirname, resolve as resolve2 } from "path";
|
|
2015
|
+
import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync6 } from "fs";
|
|
2016
|
+
import { join as join8, dirname as dirname2, resolve as resolve2 } from "path";
|
|
1732
2017
|
import { spawn as spawn3 } from "child_process";
|
|
1733
2018
|
|
|
1734
2019
|
// src/graphview.ts
|
|
@@ -2393,7 +2678,7 @@ async function graph(args = []) {
|
|
|
2393
2678
|
edges: g.edges.length
|
|
2394
2679
|
});
|
|
2395
2680
|
const outPath = opts.out ? resolve2(opts.out) : join8(wsHoltDir(), "graph.html");
|
|
2396
|
-
|
|
2681
|
+
mkdirSync6(dirname2(outPath), { recursive: true });
|
|
2397
2682
|
writeFileSync7(outPath, html, "utf8");
|
|
2398
2683
|
console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
|
|
2399
2684
|
console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
|
|
@@ -2408,7 +2693,7 @@ async function graph(args = []) {
|
|
|
2408
2693
|
}
|
|
2409
2694
|
|
|
2410
2695
|
// src/cli.ts
|
|
2411
|
-
var VERSION = "0.
|
|
2696
|
+
var VERSION = "0.6.0";
|
|
2412
2697
|
var BANNER = `
|
|
2413
2698
|
\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
|
|
2414
2699
|
\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
|