@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 +89 -25
- package/config.example.yml +4 -2
- package/dist/cli.js +734 -17
- package/package.json +1 -1
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
|
-
>
|
|
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
|
|
26
|
-
|
|
27
|
-
holt
|
|
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
|
-
|
|
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
|
|
34
|
-
holt
|
|
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
|
-
|
|
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** (
|
|
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**:
|
|
50
|
-
1. **Memory**:
|
|
51
|
-
2. **Any
|
|
52
|
-
3. **Skills**: agentskills.io
|
|
53
|
-
4. **Knowledge graph**:
|
|
54
|
-
5. **Orchestration**: local
|
|
55
|
-
6. **Channels
|
|
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
|
|
package/config.example.yml
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
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
|
|
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>
|
|
723
|
+
Usage: holt <command>
|
|
16
724
|
|
|
17
725
|
Commands:
|
|
18
|
-
init
|
|
19
|
-
chat Start
|
|
20
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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(
|
|
778
|
+
main();
|