@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.
Files changed (3) hide show
  1. package/README.md +5 -2
  2. package/dist/cli.js +342 -57
  3. 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** (a short word like `ai` that runs `holt chat`).
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 via local embeddings with keyword fallback, streaming replies *(shipped)*
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: 3, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown" };
124
+ return { version: 4, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown", memory: { extractFacts: true } };
125
125
  }
126
126
  function loadConfig() {
127
127
  const path = wsConfigPath();
@@ -132,11 +132,14 @@ function loadConfig() {
132
132
  const brains = cfg.brains ?? {};
133
133
  for (const id of BRAIN_IDS) if (!brains[id]) brains[id] = base.brains[id];
134
134
  return {
135
- version: 3,
135
+ version: 4,
136
136
  defaultBrain: cfg.defaultBrain ?? null,
137
137
  brains,
138
138
  apiBrains: Array.isArray(cfg.apiBrains) ? cfg.apiBrains : [],
139
- outputFormat: cfg.outputFormat === "html" ? "html" : "markdown"
139
+ outputFormat: cfg.outputFormat === "html" ? "html" : "markdown",
140
+ memory: {
141
+ extractFacts: cfg.memory && typeof cfg.memory.extractFacts === "boolean" ? cfg.memory.extractFacts : true
142
+ }
140
143
  };
141
144
  } catch {
142
145
  return null;
@@ -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 { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
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 installAlias(name) {
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
- return { ok: true, file };
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 currentAlias() {
278
- const file = rcFile();
279
- try {
280
- const m = readFileSync3(file, "utf8").match(/alias\s+([^\s=]+)="holt chat"/);
281
- return m ? m[1] : null;
282
- } catch {
283
- return null;
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
- return { ok: true, file };
365
+ writeState(null);
366
+ return { ok: true, kind: "none", file, immediate: true };
294
367
  } catch (e) {
295
- return { ok: false, file, message: e.message };
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 mkdirSync3, rmSync, statSync } from "fs";
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
- mkdirSync3(memDir(), { recursive: true });
473
+ mkdirSync4(memDir(), { recursive: true });
377
474
  appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
378
475
  }
379
476
  function clearMemory() {
380
- if (existsSync4(memPath())) rmSync(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)) scored.push({ turn, score });
569
+ if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) {
570
+ if (turn.role === "fact") score *= FACT_BOOST;
571
+ scored.push({ turn, score });
572
+ }
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
- mkdirSync3(memDir(), { recursive: true });
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
- console.log(r.ok ? c.green(` alias "${name}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message));
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 aliasRcFile = "";
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
- if (r.ok) aliasRcFile = r.file;
654
- aliasNote = r.ok ? c.green(` alias "${aliasAns}" -> holt chat added to ${r.file}`) : c.red(" " + r.message);
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 (aliasRcFile) {
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 ${aliasRcFile}`) + c.dim(" (once; new terminals will not need it)"));
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 moments already have embeddings.\n"));
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} moments embedded.`) + "\n");
1690
+ console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} memories embedded.`) + "\n");
1691
+ close();
1692
+ return;
1693
+ }
1694
+ if (action === "facts") {
1695
+ if (!existsSync6(factsMdPath())) {
1696
+ console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
1697
+ close();
1698
+ return;
1699
+ }
1700
+ const body = readFileSync6(factsMdPath(), "utf8").trim();
1701
+ console.log("\n" + body + "\n");
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 embed embed older moments for semantic recall"));
1738
+ console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
1739
+ console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
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 mkdirSync4,
1746
+ mkdirSync as mkdirSync5,
1462
1747
  writeFileSync as writeFileSync6,
1463
- readFileSync as readFileSync6,
1464
- existsSync as existsSync6,
1748
+ readFileSync as readFileSync7,
1749
+ existsSync as existsSync7,
1465
1750
  readdirSync as readdirSync2,
1466
1751
  statSync as statSync3,
1467
- rmSync as rmSync2,
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 (existsSync6(join7(root, "SKILL.md"))) return root;
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) => existsSync6(join7(root, f, "SKILL.md")));
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 (existsSync6(dir)) {
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
- mkdirSync4(dir, { recursive: true });
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 (!existsSync6(fetched)) {
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(readFileSync6(join7(skillDir, "SKILL.md"), "utf8"));
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 (existsSync6(dest)) {
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
- mkdirSync4(skillsRoot(scope), { recursive: true });
1933
+ mkdirSync5(skillsRoot(scope), { recursive: true });
1649
1934
  cpSync(skillDir, dest, { recursive: true });
1650
1935
  const nestedGit = join7(dest, ".git");
1651
- if (existsSync6(nestedGit)) rmSync2(nestedGit, { recursive: true, force: true });
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 && existsSync6(tempDir)) {
1941
+ if (tempDir && existsSync7(tempDir)) {
1657
1942
  try {
1658
- rmSync2(tempDir, { recursive: true, force: true });
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 = existsSync6(wsDir) ? wsDir : existsSync6(globalDir) ? globalDir : "";
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
- rmSync2(target, { recursive: true, force: true });
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 mkdirSync5 } from "fs";
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
- mkdirSync5(dirname(outPath), { recursive: true });
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.5.1";
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "An open-source personal agent OS: any LLM, private memory you can see and walk.",
5
5
  "type": "module",
6
6
  "license": "MIT",