@holt-os/holt 0.0.2 → 0.3.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 CHANGED
@@ -6,53 +6,117 @@ 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 development (Phase 0).** The architecture and roadmap are locked and the CLI skeleton is here, but Holt is not yet functional end-to-end. Star or watch to follow along. Contributions welcome.
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
 
13
- ## Why Holt
14
-
15
- - 🧠 **Memory you can see.** Persistent RAG memory plus a *navigable knowledge graph* of everything you feed it. No black-box profile you have to trust.
16
- - 🔌 **Any LLM.** Claude, OpenAI, Gemini, or a local model. Swap your brain with one line of config. No vendor lock-in.
17
- - 💸 **Local executes, cloud reviews.** Run a local model for the work and let a premium cloud model review only the risky, irreversible steps. Cheap and private by design.
18
- - 🧩 **MCP plugin pantry.** Skills, channels, providers, embeddings: everything is a plugin speaking the [Model Context Protocol](https://modelcontextprotocol.io). Extend it in any language.
19
- - 📚 **Standard skills.** Compatible with the [agentskills.io](https://agentskills.io) skill format. Pull from the community catalog or publish your own.
20
- - 🖥️ **CLI-first.** Works in your terminal the moment you clone. Telegram and other channels are opt-in.
21
-
22
13
  ## Quickstart
23
14
 
24
15
  ```bash
25
- npm install -g @holt-os/holt # or: pnpm add -g @holt-os/holt
26
- holt init # pick your brain + memory (local or cloud)
27
- holt chat # start talking
16
+ npm install -g @holt-os/holt
17
+ cd ~/where-you-want-to-work
18
+ holt init # trust this folder, choose and install brains, sign in, set defaults
19
+ holt chat # start talking (or use your custom command, e.g. `ai`)
20
+ ```
21
+
22
+ ## First run
23
+
24
+ Holt runs in the folder you launch it from, like a per-project tool. The first time you use a folder it asks:
25
+
26
+ ```
27
+ Trust this folder?
28
+ /Users/you/where-you-want-to-work
29
+ Holt will read and write here.
30
+ Trust and continue? [y/N]
31
+ ```
32
+
33
+ Trusted folders are remembered in `~/.holt/trust.json`. Everything Holt writes for that folder (its config, and later its memory) stays in `<folder>/.holt/`.
34
+
35
+ During `holt init` you:
36
+
37
+ 1. **Trust the folder.**
38
+ 2. **Choose brains** (claude, codex, gemini). Holt installs any you pick that are missing.
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`).
41
+
42
+ ## Using it
43
+
44
+ Inside `holt chat`:
45
+
46
+ ```
47
+ /brain list your brains and see which is active
48
+ /brain gemini switch brain. your conversation context is kept
49
+ /memory memory stats. /memory <query> previews what recall would surface
50
+ /setting configure brains and your launch command
51
+ /clear forget this session (saved memory stays)
52
+ /help show commands
53
+ /exit leave
28
54
  ```
29
55
 
30
- Add skills:
56
+ 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.
57
+
58
+ ## Memory
59
+
60
+ 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.
61
+
62
+ Two recall modes, picked automatically:
63
+
64
+ - **Semantic** (best): if a local [Ollama](https://ollama.com) is running with an embedding model (`ollama pull nomic-embed-text`), recall matches by meaning. Asking "who owns my apartment" finds "my landlord is called Pieter". No API keys, nothing leaves your machine.
65
+ - **Keyword** (fallback): with no Ollama, recall matches by word overlap. Still useful, zero setup.
66
+
67
+ Inspect it any time:
31
68
 
32
69
  ```bash
33
- holt skill search finance
34
- holt skill add deep-research
70
+ holt memory # stats for this folder
71
+ holt memory search <query> # find remembered moments
72
+ holt memory clear # wipe this folder's memory
73
+ ```
74
+
75
+ Long conversations stay cheap: only recent turns are replayed verbatim, older context comes back through recall.
76
+
77
+ ## Brains
78
+
79
+ A brain is an agent CLI installed and logged in on your machine. No API keys to paste.
80
+
81
+ | Brain | Command | Install |
82
+ |-------|---------|---------|
83
+ | Claude Code | `claude` | `npm i -g @anthropic-ai/claude-code` |
84
+ | Codex | `codex` | `npm i -g @openai/codex` |
85
+ | Gemini CLI | `gemini` | `npm i -g @google/gemini-cli` |
86
+
87
+ `holt init` runs these for you when you pick a brain that is missing. You can also sign in any time with `holt login <brain>`. Raw API providers are planned for a later phase.
88
+
89
+ ## Commands
90
+
91
+ ```
92
+ holt init set up (trust, brains, sign-in, defaults) for this folder
93
+ holt chat start a session that remembers past ones
94
+ holt memory inspect memory: holt memory [search <query> | clear]
95
+ holt setting configure brains and launch command
96
+ holt login <brain> sign in to claude, codex, or gemini
97
+ holt version print version
98
+ holt help show help
35
99
  ```
36
100
 
37
101
  ## Configuration
38
102
 
39
- Copy `config.example.yml` to `config.yml` and edit. See the file for the full schema: brain/provider, memory and embeddings, output format (HTML or Markdown), orchestration, and channels.
103
+ `holt init` writes `<folder>/.holt/config.json` (default brain and enabled brains for that folder). Trusted folders live in `~/.holt/trust.json`. Edit settings with `holt setting`.
40
104
 
41
105
  ## Architecture
42
106
 
43
- Small strongly-typed **TypeScript core** (agent loop, brain router, memory orchestration, risky-action review gate, plugin dispatcher). *Everything else* is an MCP plugin: providers, embeddings, skills, channels. See [`ARCHITECTURE.md`](./ARCHITECTURE.md).
107
+ Small strongly-typed **TypeScript core** (command dispatch, brain router, transcript, and a plugin dispatcher coming with skills). Brains and, soon, skills and channels are adapters. See [`ARCHITECTURE.md`](./ARCHITECTURE.md).
44
108
 
45
109
  ## Roadmap
46
110
 
47
111
  Built in always-shippable phases toward a full-vision v1:
48
112
 
49
- 0. **Skeleton**: core loop + CLI *(in progress)*
50
- 1. **Memory**: sqlite-vec RAG + embeddings
51
- 2. **Any-LLM**: provider plugins + output toggle
52
- 3. **Skills**: agentskills.io catalog + installer
53
- 4. **Knowledge graph**: navigable memory view
54
- 5. **Orchestration**: local-executes / cloud-reviews
55
- 6. **Channels + polish**: Telegram, docs, one-command install
113
+ 0. **Skeleton and chat**: trust, init with install and sign-in, chat, brain switching with kept context *(shipped)*
114
+ 1. **Memory**: per-folder store, semantic recall via local embeddings with keyword fallback, streaming replies *(shipped)*
115
+ 2. **Any LLM directly**: raw provider brains and an HTML or Markdown output toggle
116
+ 3. **Skills**: install, search, and publish in the agentskills.io format
117
+ 4. **Knowledge graph**: a view where you can see and navigate your own memory
118
+ 5. **Orchestration**: a local model works, a cloud model reviews the risky steps
119
+ 6. **Channels and polish**: Telegram, docs, one-command setup
56
120
 
57
121
  ## Contributing
58
122
 
@@ -1,5 +1,7 @@
1
- # Holt configuration. Copy to config.yml and edit.
2
- # config.yml is git-ignored; never commit secrets or personal memory.
1
+ # Planned full configuration schema (later phases). Preview only.
2
+ # Phase 0 does NOT read this file: run `holt init`, which writes
3
+ # ~/.holt/config.json (your brains, default brain, and launch command).
4
+ # Never commit real secrets or personal memory.
3
5
 
4
6
  # ── Brain ────────────────────────────────────────────────────────────────
5
7
  # The LLM that runs the agent. Provider-agnostic: swap freely.
package/dist/cli.js CHANGED
@@ -1,7 +1,715 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/config.ts
4
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
5
+
6
+ // src/workspace.ts
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
10
+
11
+ // src/ui.ts
12
+ import readline from "readline";
13
+ var on = process.stdout.isTTY && !process.env.NO_COLOR;
14
+ var wrap = (code) => (s) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
15
+ var c = {
16
+ dim: wrap("2"),
17
+ bold: wrap("1"),
18
+ accent: wrap("38;5;214"),
19
+ // amber
20
+ green: wrap("32"),
21
+ red: wrap("31"),
22
+ cyan: wrap("36")
23
+ };
24
+ function createReader() {
25
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
26
+ const buffer = [];
27
+ const waiters = [];
28
+ let closed = false;
29
+ rl.on("line", (line) => {
30
+ const w = waiters.shift();
31
+ if (w) w(line);
32
+ else buffer.push(line);
33
+ });
34
+ rl.on("close", () => {
35
+ closed = true;
36
+ while (waiters.length) waiters.shift()(null);
37
+ });
38
+ const ask = (q) => new Promise((resolve) => {
39
+ if (q) process.stdout.write(q);
40
+ if (buffer.length) resolve(buffer.shift());
41
+ else if (closed) resolve(null);
42
+ else waiters.push(resolve);
43
+ });
44
+ return { ask, close: () => rl.close() };
45
+ }
46
+
47
+ // src/workspace.ts
48
+ var GLOBAL_DIR = join(homedir(), ".holt");
49
+ var TRUST_PATH = join(GLOBAL_DIR, "trust.json");
50
+ function workspace() {
51
+ return process.cwd();
52
+ }
53
+ function wsHoltDir(dir = workspace()) {
54
+ return join(dir, ".holt");
55
+ }
56
+ function wsConfigPath(dir = workspace()) {
57
+ return join(wsHoltDir(dir), "config.json");
58
+ }
59
+ function readTrust() {
60
+ try {
61
+ return JSON.parse(readFileSync(TRUST_PATH, "utf8"));
62
+ } catch {
63
+ return { trusted: [] };
64
+ }
65
+ }
66
+ function isTrusted(dir = workspace()) {
67
+ return readTrust().trusted.includes(dir);
68
+ }
69
+ function trustDir(dir = workspace()) {
70
+ const t = readTrust();
71
+ if (!t.trusted.includes(dir)) t.trusted.push(dir);
72
+ mkdirSync(GLOBAL_DIR, { recursive: true });
73
+ writeFileSync(TRUST_PATH, JSON.stringify(t, null, 2) + "\n", "utf8");
74
+ }
75
+ async function ensureTrusted(ask) {
76
+ const ws = workspace();
77
+ if (isTrusted(ws)) return true;
78
+ console.log("\n" + c.accent("Trust this folder?"));
79
+ console.log(" " + ws);
80
+ console.log(c.dim(" Holt will read and write here: its config, memory, and any files you ask a brain to touch."));
81
+ const ans = (await ask(" Trust and continue? [y/N] ") ?? "").trim().toLowerCase();
82
+ if (ans === "y" || ans === "yes") {
83
+ trustDir(ws);
84
+ console.log(c.green(" Trusted.") + c.dim(" (remembered for next time)") + "\n");
85
+ return true;
86
+ }
87
+ console.log(c.dim(" Cancelled. Holt only runs in folders you trust.\n"));
88
+ return false;
89
+ }
90
+ function ensureWsDir() {
91
+ mkdirSync(wsHoltDir(), { recursive: true });
92
+ }
93
+
94
+ // src/config.ts
95
+ var BRAIN_DEFS = {
96
+ claude: { label: "Claude Code", command: "claude", args: ["-p"] },
97
+ codex: { label: "Codex (OpenAI)", command: "codex", args: ["exec"] },
98
+ gemini: { label: "Gemini CLI", command: "gemini", args: ["-p"] }
99
+ };
100
+ var BRAIN_SETUP = {
101
+ claude: { install: ["npm", "install", "-g", "@anthropic-ai/claude-code"], login: ["claude"] },
102
+ codex: { install: ["npm", "install", "-g", "@openai/codex"], login: ["codex", "login"] },
103
+ gemini: { install: ["npm", "install", "-g", "@google/gemini-cli"], login: ["gemini"] }
104
+ };
105
+ var BRAIN_IDS = ["claude", "codex", "gemini"];
106
+ function defaultConfig() {
107
+ const brains = {};
108
+ for (const id of BRAIN_IDS) {
109
+ const d = BRAIN_DEFS[id];
110
+ brains[id] = { id, label: d.label, command: d.command, args: [...d.args], enabled: false };
111
+ }
112
+ return { version: 2, defaultBrain: null, brains };
113
+ }
114
+ function loadConfig() {
115
+ const path = wsConfigPath();
116
+ if (!existsSync2(path)) return null;
117
+ try {
118
+ const cfg = JSON.parse(readFileSync2(path, "utf8"));
119
+ const base = defaultConfig();
120
+ for (const id of BRAIN_IDS) if (!cfg.brains?.[id]) cfg.brains[id] = base.brains[id];
121
+ return cfg;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ function saveConfig(cfg) {
127
+ ensureWsDir();
128
+ writeFileSync2(wsConfigPath(), JSON.stringify(cfg, null, 2) + "\n", "utf8");
129
+ }
130
+
131
+ // src/brains.ts
132
+ import { spawn, spawnSync } from "child_process";
133
+ var MAX_REPLAY_TURNS = 12;
134
+ function isInstalled(command) {
135
+ const finder = process.platform === "win32" ? "where" : "which";
136
+ const res = spawnSync(finder, [command], { stdio: "ignore" });
137
+ return res.status === 0;
138
+ }
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) {
162
+ return new Promise((resolve) => {
163
+ let child;
164
+ try {
165
+ child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
166
+ } catch (e) {
167
+ resolve({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
168
+ return;
169
+ }
170
+ let out = "";
171
+ let err = "";
172
+ child.stdout.on("data", (d) => {
173
+ const s = d.toString();
174
+ out += s;
175
+ if (onChunk) onChunk(s);
176
+ });
177
+ child.stderr.on("data", (d) => {
178
+ err += d.toString();
179
+ });
180
+ child.on("error", (e) => resolve({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
181
+ child.on("close", (code) => {
182
+ const text = out.trim();
183
+ if (code === 0 && text) resolve({ ok: true, text });
184
+ else resolve({ ok: false, text: err.trim() || text || `"${brain.command}" exited with code ${code}` });
185
+ });
186
+ });
187
+ }
188
+
189
+ // src/alias.ts
190
+ import { homedir as homedir2 } from "os";
191
+ import { join as join2 } from "path";
192
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
193
+ var START = "# >>> holt launch alias >>>";
194
+ var END = "# <<< holt launch alias <<<";
195
+ function rcFile() {
196
+ const shell = process.env.SHELL || "";
197
+ if (shell.includes("zsh")) return join2(homedir2(), ".zshrc");
198
+ if (shell.includes("bash")) return join2(homedir2(), ".bashrc");
199
+ return join2(homedir2(), ".profile");
200
+ }
201
+ function installAlias(name) {
202
+ const file = rcFile();
203
+ const block = `${START}
204
+ alias ${name}="holt chat"
205
+ ${END}`;
206
+ try {
207
+ let content = existsSync3(file) ? readFileSync3(file, "utf8") : "";
208
+ const re = new RegExp(`${START}[\\s\\S]*?${END}`);
209
+ if (re.test(content)) content = content.replace(re, block);
210
+ else content = content.replace(/\n*$/, "\n") + block + "\n";
211
+ writeFileSync3(file, content, "utf8");
212
+ return { ok: true, file };
213
+ } catch (e) {
214
+ return { ok: false, file, message: `Could not write ${file}: ${e.message}` };
215
+ }
216
+ }
217
+ function currentAlias() {
218
+ const file = rcFile();
219
+ try {
220
+ const m = readFileSync3(file, "utf8").match(/alias\s+([^\s=]+)="holt chat"/);
221
+ return m ? m[1] : null;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+ function removeAlias() {
227
+ const file = rcFile();
228
+ try {
229
+ if (existsSync3(file)) {
230
+ const content = readFileSync3(file, "utf8").replace(new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`), "\n");
231
+ writeFileSync3(file, content, "utf8");
232
+ }
233
+ return { ok: true, file };
234
+ } catch (e) {
235
+ return { ok: false, file, message: e.message };
236
+ }
237
+ }
238
+
239
+ // src/install.ts
240
+ import { spawn as spawn2 } from "child_process";
241
+ function runInteractive(cmd, args) {
242
+ return new Promise((resolve) => {
243
+ let child;
244
+ try {
245
+ child = spawn2(cmd, args, { stdio: "inherit" });
246
+ } catch {
247
+ resolve(-1);
248
+ return;
249
+ }
250
+ child.on("error", () => resolve(-1));
251
+ child.on("close", (code) => resolve(code ?? -1));
252
+ });
253
+ }
254
+
255
+ // src/commands/init.ts
256
+ function parseBrains(raw, found) {
257
+ const s = raw.trim().toLowerCase();
258
+ if (s === "") return found.length ? found : [...BRAIN_IDS];
259
+ if (s === "all") return [...BRAIN_IDS];
260
+ const picked = s.split(/[\s,]+/).filter((x) => BRAIN_IDS.includes(x));
261
+ if (picked.length) return [...new Set(picked)];
262
+ return found.length ? found : ["claude"];
263
+ }
264
+ async function init() {
265
+ const { ask, close } = createReader();
266
+ if (!await ensureTrusted(ask)) {
267
+ close();
268
+ return;
269
+ }
270
+ console.log(c.accent("Holt setup") + c.dim(` (${workspace()})`) + "\n");
271
+ console.log("Looking for agent CLIs on your machine...\n");
272
+ const found = [];
273
+ for (const id of BRAIN_IDS) {
274
+ const ok = isInstalled(BRAIN_DEFS[id].command);
275
+ console.log(` ${ok ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
276
+ if (ok) found.push(id);
277
+ }
278
+ console.log("");
279
+ const chosen = parseBrains(
280
+ await ask('Which brains do you want? claude, codex, gemini (comma-separated, or "all"): ') ?? "",
281
+ found
282
+ );
283
+ console.log(c.dim(` using: ${chosen.join(", ")}`));
284
+ const toInstall = chosen.filter((id) => !isInstalled(BRAIN_DEFS[id].command));
285
+ const loginWanted = /* @__PURE__ */ new Set();
286
+ for (const id of toInstall) {
287
+ const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
288
+ if (a !== "n" && a !== "no") loginWanted.add(id);
289
+ }
290
+ const defPick = chosen.includes("claude") ? "claude" : chosen[0];
291
+ const dans = (await ask(`
292
+ Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
293
+ const defaultBrain = chosen.includes(dans) ? dans : defPick;
294
+ const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
295
+ let aliasNote = "";
296
+ if (aliasAns && aliasAns !== "holt") {
297
+ if (isInstalled(aliasAns)) console.log(c.dim(` note: "${aliasAns}" already exists; the alias will shadow it in new shells.`));
298
+ const r = installAlias(aliasAns);
299
+ aliasNote = r.ok ? c.green(` alias "${aliasAns}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message);
300
+ }
301
+ close();
302
+ for (const id of toInstall) {
303
+ const s = BRAIN_SETUP[id];
304
+ console.log("\n" + c.accent(`Installing ${BRAIN_DEFS[id].label}`) + c.dim(` (${s.install.join(" ")})`));
305
+ const code = await runInteractive(s.install[0], s.install.slice(1));
306
+ console.log(code === 0 ? c.green(` ${BRAIN_DEFS[id].label} installed.`) : c.red(` Install failed (exit ${code}). Run manually: ${s.install.join(" ")}`));
307
+ }
308
+ for (const id of toInstall) {
309
+ if (!loginWanted.has(id) || !isInstalled(BRAIN_DEFS[id].command)) continue;
310
+ const s = BRAIN_SETUP[id];
311
+ console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
312
+ console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool to return here.`));
313
+ await runInteractive(s.login[0], s.login.slice(1));
314
+ }
315
+ const cfg = loadConfig() ?? defaultConfig();
316
+ for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
317
+ cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? null;
318
+ saveConfig(cfg);
319
+ console.log("\n" + c.green("Saved to ./.holt/config.json"));
320
+ if (aliasNote) console.log(aliasNote);
321
+ if (cfg.defaultBrain) console.log("Start chatting: " + c.accent(aliasAns && aliasAns !== "holt" ? aliasAns : "holt chat") + "\n");
322
+ else console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
323
+ }
324
+
325
+ // src/commands/chat.ts
326
+ import { randomUUID as randomUUID2 } from "crypto";
327
+
328
+ // src/memory.ts
329
+ import { appendFileSync, readFileSync as readFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync2, rmSync, statSync } from "fs";
330
+ import { join as join3 } from "path";
331
+ import { randomUUID } from "crypto";
332
+ var OLLAMA_URL = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
333
+ var EMBED_MODEL = process.env.HOLT_EMBED_MODEL || "nomic-embed-text";
334
+ function memDir() {
335
+ return join3(wsHoltDir(), "memory");
336
+ }
337
+ function memPath() {
338
+ return join3(memDir(), "turns.jsonl");
339
+ }
340
+ function newSessionId() {
341
+ return randomUUID().slice(0, 8);
342
+ }
343
+ var embedProbe = null;
344
+ async function embeddingsAvailable() {
345
+ if (embedProbe !== null) return embedProbe;
346
+ try {
347
+ const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1200) });
348
+ if (!res.ok) return embedProbe = false;
349
+ const data = await res.json();
350
+ embedProbe = !!data.models?.some((m) => (m.name || "").startsWith(EMBED_MODEL));
351
+ } catch {
352
+ embedProbe = false;
353
+ }
354
+ return embedProbe;
355
+ }
356
+ async function embed(text) {
357
+ if (!await embeddingsAvailable()) return null;
358
+ try {
359
+ const res = await fetch(`${OLLAMA_URL}/api/embeddings`, {
360
+ method: "POST",
361
+ headers: { "content-type": "application/json" },
362
+ body: JSON.stringify({ model: EMBED_MODEL, prompt: text.slice(0, 4e3) }),
363
+ signal: AbortSignal.timeout(1e4)
364
+ });
365
+ if (!res.ok) return null;
366
+ const data = await res.json();
367
+ if (!Array.isArray(data.embedding)) return null;
368
+ return data.embedding.map((x) => Math.round(x * 1e4) / 1e4);
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+ function loadTurns() {
374
+ if (!existsSync4(memPath())) return [];
375
+ const out = [];
376
+ for (const line of readFileSync4(memPath(), "utf8").split("\n")) {
377
+ if (!line.trim()) continue;
378
+ try {
379
+ out.push(JSON.parse(line));
380
+ } catch {
381
+ }
382
+ }
383
+ return out;
384
+ }
385
+ function appendTurn(t) {
386
+ mkdirSync2(memDir(), { recursive: true });
387
+ appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
388
+ }
389
+ function clearMemory() {
390
+ if (existsSync4(memPath())) rmSync(memPath());
391
+ }
392
+ function memStats() {
393
+ const turns = loadTurns();
394
+ const sessions = new Set(turns.map((t) => t.session)).size;
395
+ const withEmbeddings = turns.filter((t) => Array.isArray(t.emb)).length;
396
+ const bytes = existsSync4(memPath()) ? statSync(memPath()).size : 0;
397
+ return { turns: turns.length, sessions, withEmbeddings, bytes };
398
+ }
399
+ function cosine(a, b) {
400
+ let dot = 0;
401
+ let na = 0;
402
+ let nb = 0;
403
+ const n = Math.min(a.length, b.length);
404
+ for (let i = 0; i < n; i++) {
405
+ const x = a[i];
406
+ const y = b[i];
407
+ dot += x * y;
408
+ na += x * x;
409
+ nb += y * y;
410
+ }
411
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
412
+ }
413
+ function tokens(s) {
414
+ return new Set(
415
+ s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2)
416
+ );
417
+ }
418
+ function keywordScore(q, text) {
419
+ if (q.size === 0) return 0;
420
+ const t = tokens(text);
421
+ let hit = 0;
422
+ for (const w of q) if (t.has(w)) hit++;
423
+ return hit / q.size;
424
+ }
425
+ async function recall(query, currentSession, k = 4) {
426
+ const past = loadTurns().filter((t) => t.session !== currentSession);
427
+ if (past.length === 0) return [];
428
+ const qEmb = await embed(query);
429
+ const qTok = tokens(query);
430
+ const scored = [];
431
+ for (const turn of past) {
432
+ let score = 0;
433
+ if (qEmb && Array.isArray(turn.emb)) score = cosine(qEmb, turn.emb);
434
+ else score = keywordScore(qTok, turn.content);
435
+ if (score > (qEmb && Array.isArray(turn.emb) ? 0.35 : 0.15)) scored.push({ turn, score });
436
+ }
437
+ scored.sort((a, b) => b.score - a.score);
438
+ return scored.slice(0, k);
439
+ }
440
+
441
+ // src/commands/setting.ts
442
+ function printStatus(cfg) {
443
+ console.log("\n" + c.accent("Holt settings") + c.dim(" (this folder)"));
444
+ for (const id of BRAIN_IDS) {
445
+ const b = cfg.brains[id];
446
+ const tags = [
447
+ b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
448
+ cfg.defaultBrain === id ? c.accent("default") : ""
449
+ ].filter(Boolean).join(" ");
450
+ console.log(` ${id.padEnd(7)} ${b.label.padEnd(16)} ${tags}`);
451
+ }
452
+ console.log(c.dim(` launch command: ${currentAlias() || "holt (default)"}`));
453
+ console.log("\n " + c.dim("[d] default brain [t] toggle brain [a] launch command [enter] done"));
454
+ }
455
+ async function runSettings(ask) {
456
+ let cfg = loadConfig() ?? defaultConfig();
457
+ while (true) {
458
+ printStatus(cfg);
459
+ const raw = await ask(" > ");
460
+ const choice = (raw ?? "").trim().toLowerCase();
461
+ if (raw === null || choice === "" || choice === "q") break;
462
+ if (choice === "d") {
463
+ const enabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
464
+ if (enabled.length === 0) {
465
+ console.log(c.dim(' No enabled brains. Toggle one on first with "t".'));
466
+ continue;
467
+ }
468
+ const pick = (await ask(` default brain [${enabled.join("/")}]: `) ?? "").trim();
469
+ if (enabled.includes(pick)) {
470
+ cfg.defaultBrain = pick;
471
+ console.log(c.green(` default set to ${cfg.brains[pick].label}`));
472
+ } else console.log(c.dim(" unchanged."));
473
+ } else if (choice === "t") {
474
+ const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
475
+ if (BRAIN_IDS.includes(pick)) {
476
+ if (!cfg.brains[pick].enabled && !isInstalled(cfg.brains[pick].command)) {
477
+ console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
478
+ } else {
479
+ cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
480
+ if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? null;
481
+ if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
482
+ console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
483
+ }
484
+ } else console.log(c.dim(" unchanged."));
485
+ } else if (choice === "a") {
486
+ const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
487
+ if (name && name !== "holt") {
488
+ if (isInstalled(name)) console.log(c.dim(` note: "${name}" already exists; the alias will shadow it in new shells.`));
489
+ const r = installAlias(name);
490
+ console.log(r.ok ? c.green(` alias "${name}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message));
491
+ } else {
492
+ removeAlias();
493
+ console.log(c.dim(" reset to holt."));
494
+ }
495
+ } else {
496
+ console.log(c.dim(" pick d, t, a, or press enter to finish."));
497
+ }
498
+ saveConfig(cfg);
499
+ }
500
+ saveConfig(cfg);
501
+ return cfg;
502
+ }
503
+ async function setting() {
504
+ const { ask, close } = createReader();
505
+ if (!await ensureTrusted(ask)) {
506
+ close();
507
+ return;
508
+ }
509
+ await runSettings(ask);
510
+ close();
511
+ console.log("");
512
+ }
513
+
514
+ // src/commands/chat.ts
515
+ function help() {
516
+ console.log(c.dim([
517
+ " commands:",
518
+ " /brain [name] switch brain (claude, codex, gemini). context is kept.",
519
+ " /memory [query] memory stats, or preview what a query would recall",
520
+ " /setting configure brains and your launch command",
521
+ " /clear forget this session so far (saved memory stays)",
522
+ " /help this list",
523
+ " /exit leave"
524
+ ].join("\n")));
525
+ }
526
+ async function chat() {
527
+ const { ask, close } = createReader();
528
+ if (!await ensureTrusted(ask)) {
529
+ close();
530
+ return;
531
+ }
532
+ let cfg = loadConfig();
533
+ if (!cfg || !cfg.defaultBrain) {
534
+ const a = (await ask(c.dim("No Holt setup in this folder. Set it up now? [Y/n] ")) ?? "").trim().toLowerCase();
535
+ close();
536
+ if (a === "n" || a === "no") {
537
+ console.log(c.dim(' Run "holt init" here when ready.\n'));
538
+ return;
539
+ }
540
+ await init();
541
+ console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
542
+ return;
543
+ }
544
+ let current = cfg.defaultBrain;
545
+ const session = newSessionId();
546
+ const history = [];
547
+ const embedOk = await embeddingsAvailable();
548
+ const stats = memStats();
549
+ console.log("\n" + c.accent("Holt") + c.dim(` brain: ${cfg.brains[current].label}`));
550
+ console.log(c.dim(
551
+ `Memory: ${stats.turns} moments from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
552
+ ));
553
+ console.log(c.dim("Type a message. Commands: /brain /memory /setting /clear /help /exit\n"));
554
+ while (true) {
555
+ const raw = await ask(c.accent("\u203A "));
556
+ if (raw === null) break;
557
+ const line = raw.trim();
558
+ if (!line) continue;
559
+ if (line.startsWith("/")) {
560
+ const parts = line.slice(1).split(/\s+/);
561
+ const cmd = (parts[0] || "").toLowerCase();
562
+ const rest = parts.slice(1).join(" ");
563
+ const arg = (parts[1] || "").toLowerCase();
564
+ if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
565
+ if (cmd === "help" || cmd === "h") {
566
+ help();
567
+ continue;
568
+ }
569
+ if (cmd === "clear") {
570
+ history.length = 0;
571
+ console.log(c.dim(" session context cleared. Saved memory is untouched."));
572
+ continue;
573
+ }
574
+ if (cmd === "memory" || cmd === "mem") {
575
+ if (rest) {
576
+ const hits = await recall(rest, session, 5);
577
+ if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
578
+ 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, " ")}`));
579
+ } else {
580
+ const s = memStats();
581
+ console.log(c.dim(` ${s.turns} moments, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
582
+ console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
583
+ }
584
+ continue;
585
+ }
586
+ if (cmd === "setting" || cmd === "settings") {
587
+ cfg = await runSettings(ask);
588
+ if (!cfg.brains[current].enabled && cfg.defaultBrain) current = cfg.defaultBrain;
589
+ console.log(c.dim(` brain: ${cfg.brains[current].label}`));
590
+ continue;
591
+ }
592
+ if (cmd === "brain") {
593
+ const enabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
594
+ if (arg && enabled.includes(arg)) {
595
+ current = arg;
596
+ const turns = Math.floor(history.length / 2);
597
+ console.log(c.green(` switched to ${cfg.brains[current].label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
598
+ } else if (arg) {
599
+ console.log(c.dim(` "${arg}" is not available. Installed: ${enabled.join(", ") || "none"}`));
600
+ } else {
601
+ console.log(c.dim(" brains: " + enabled.map((id) => id === current ? c.accent(id + " (current)") : id).join(" ")));
602
+ console.log(c.dim(" usage: /brain <name>"));
603
+ }
604
+ continue;
605
+ }
606
+ console.log(c.dim(` unknown command: /${cmd} (try /help)`));
607
+ continue;
608
+ }
609
+ const brain = cfg.brains[current];
610
+ if (!isInstalled(brain.command)) {
611
+ console.log(c.red(` ${brain.label} (${brain.command}) is not on your PATH. Use /brain to switch or /setting.`));
612
+ continue;
613
+ }
614
+ const remembered = await recall(line, session, 4);
615
+ const label = remembered.length ? `${brain.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${brain.label} is thinking...`;
616
+ console.log(c.dim(` ${label}`) + "\n");
617
+ let streamed = false;
618
+ const res = await runBrain(brain, renderPrompt(history, line, remembered), (chunk) => {
619
+ streamed = true;
620
+ process.stdout.write(chunk);
621
+ });
622
+ if (res.ok) {
623
+ if (!streamed) console.log(res.text);
624
+ if (!res.text.endsWith("\n")) console.log("");
625
+ console.log("");
626
+ history.push({ role: "user", content: line });
627
+ history.push({ role: "assistant", content: res.text });
628
+ const now = Date.now();
629
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
630
+ appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
631
+ } else {
632
+ console.log(c.red("\n " + res.text + "\n"));
633
+ }
634
+ }
635
+ close();
636
+ console.log(c.dim("\nBye.\n"));
637
+ }
638
+
639
+ // src/commands/login.ts
640
+ async function login(which) {
641
+ const id = (which || "").toLowerCase();
642
+ if (!BRAIN_IDS.includes(id)) {
643
+ console.log(c.dim(`
644
+ Usage: holt login <${BRAIN_IDS.join("|")}>
645
+ `));
646
+ return;
647
+ }
648
+ const s = BRAIN_SETUP[id];
649
+ console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
650
+ console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool.
651
+ `));
652
+ await runInteractive(s.login[0], s.login.slice(1));
653
+ }
654
+
655
+ // src/commands/memory.ts
656
+ async function memoryCmd(sub, rest = []) {
657
+ const { ask, close } = createReader();
658
+ if (!await ensureTrusted(ask)) {
659
+ close();
660
+ return;
661
+ }
662
+ const action = (sub || "").toLowerCase();
663
+ if (action === "clear") {
664
+ const s2 = memStats();
665
+ if (s2.turns === 0) {
666
+ console.log(c.dim("\n Memory is already empty.\n"));
667
+ close();
668
+ return;
669
+ }
670
+ const a = (await ask(`
671
+ Delete all ${s2.turns} remembered moments in this folder? [y/N] `) ?? "").trim().toLowerCase();
672
+ if (a === "y" || a === "yes") {
673
+ clearMemory();
674
+ console.log(c.green(" Memory cleared.\n"));
675
+ } else console.log(c.dim(" Kept.\n"));
676
+ close();
677
+ return;
678
+ }
679
+ if (action === "search") {
680
+ const q = rest.join(" ").trim();
681
+ if (!q) {
682
+ console.log(c.dim("\n Usage: holt memory search <query>\n"));
683
+ close();
684
+ return;
685
+ }
686
+ const hits = await recall(q, "__none__", 8);
687
+ console.log("");
688
+ if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
689
+ else for (const h of hits) {
690
+ const when = new Date(h.turn.ts).toISOString().slice(0, 10);
691
+ console.log(` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role}) ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`);
692
+ }
693
+ console.log("");
694
+ close();
695
+ return;
696
+ }
697
+ const s = memStats();
698
+ const embedOk = await embeddingsAvailable();
699
+ const sessions = new Set(loadTurns().map((t) => t.session)).size;
700
+ console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
701
+ console.log(` moments ${s.turns}`);
702
+ console.log(` sessions ${sessions}`);
703
+ console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
704
+ console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
705
+ console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
706
+ console.log(c.dim("\n holt memory search <query> find remembered moments"));
707
+ console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
708
+ close();
709
+ }
710
+
3
711
  // src/cli.ts
4
- var VERSION = "0.0.1";
712
+ var VERSION = "0.3.0";
5
713
  var BANNER = `
6
714
  \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
7
715
  \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
@@ -12,27 +720,25 @@ var BANNER = `
12
720
  Everything you know, kept and connected.
13
721
  `;
14
722
  var HELP = `${BANNER}
15
- Usage: holt <command> [options]
723
+ Usage: holt <command>
16
724
 
17
725
  Commands:
18
- init Set up your brain (LLM) and memory (local or cloud)
19
- chat Start an interactive session
20
- skill <cmd> Manage skills: search | add | remove | list | publish
726
+ init Trust this folder, choose and install brains, sign in, set defaults
727
+ chat Start a session. It remembers past sessions in this folder
728
+ memory Inspect memory: holt memory [search <query> | clear]
729
+ setting Configure brains and your launch command (per folder)
730
+ login <brain> Sign in to a brain: claude, codex, or gemini
21
731
  version Print the Holt version
22
732
  help Show this help
23
733
 
734
+ Holt runs in the folder you launch it from and asks to trust it first.
735
+ Brains are the agent CLIs on your machine: claude (Claude Code), codex, gemini.
736
+
24
737
  Docs: https://productsdecoded.com/holt
25
738
  Repo: https://github.com/holt-os/holt
26
-
27
- Holt is in early development (Phase 0). Most commands are not wired up yet.
28
739
  `;
29
- function notReady(cmd) {
30
- console.log(`
31
- "${cmd}" is not implemented yet. Holt is in Phase 0 (skeleton).`);
32
- console.log(" Follow progress: https://github.com/holt-os/holt\n");
33
- }
34
- function main(argv) {
35
- const [cmd, ...rest] = argv;
740
+ async function main() {
741
+ const cmd = process.argv[2];
36
742
  switch (cmd) {
37
743
  case void 0:
38
744
  case "help":
@@ -46,9 +752,20 @@ function main(argv) {
46
752
  console.log(`holt ${VERSION}`);
47
753
  break;
48
754
  case "init":
755
+ await init();
756
+ break;
49
757
  case "chat":
50
- case "skill":
51
- notReady([cmd, ...rest].join(" "));
758
+ await chat();
759
+ break;
760
+ case "setting":
761
+ case "settings":
762
+ await setting();
763
+ break;
764
+ case "login":
765
+ await login(process.argv[3]);
766
+ break;
767
+ case "memory":
768
+ await memoryCmd(process.argv[3], process.argv.slice(4));
52
769
  break;
53
770
  default:
54
771
  console.log(`
@@ -58,4 +775,4 @@ function main(argv) {
58
775
  process.exitCode = 1;
59
776
  }
60
777
  }
61
- main(process.argv.slice(2));
778
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holt-os/holt",
3
- "version": "0.0.2",
3
+ "version": "0.3.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",