@holt-os/holt 0.3.0 → 0.5.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 +55 -11
- package/dist/cli.js +1909 -221
- package/package.json +3 -3
- package/config.example.yml +0 -40
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/config.ts
|
|
4
|
-
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
4
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
5
|
+
import { join as join2 } from "path";
|
|
5
6
|
|
|
6
7
|
// src/workspace.ts
|
|
7
8
|
import { homedir } from "os";
|
|
@@ -35,11 +36,11 @@ function createReader() {
|
|
|
35
36
|
closed = true;
|
|
36
37
|
while (waiters.length) waiters.shift()(null);
|
|
37
38
|
});
|
|
38
|
-
const ask = (q) => new Promise((
|
|
39
|
+
const ask = (q) => new Promise((resolve3) => {
|
|
39
40
|
if (q) process.stdout.write(q);
|
|
40
|
-
if (buffer.length)
|
|
41
|
-
else if (closed)
|
|
42
|
-
else waiters.push(
|
|
41
|
+
if (buffer.length) resolve3(buffer.shift());
|
|
42
|
+
else if (closed) resolve3(null);
|
|
43
|
+
else waiters.push(resolve3);
|
|
43
44
|
});
|
|
44
45
|
return { ask, close: () => rl.close() };
|
|
45
46
|
}
|
|
@@ -103,13 +104,24 @@ var BRAIN_SETUP = {
|
|
|
103
104
|
gemini: { install: ["npm", "install", "-g", "@google/gemini-cli"], login: ["gemini"] }
|
|
104
105
|
};
|
|
105
106
|
var BRAIN_IDS = ["claude", "codex", "gemini"];
|
|
107
|
+
var PROVIDERS = ["anthropic", "openai", "gemini"];
|
|
108
|
+
var PROVIDER_MODEL_SUGGESTION = {
|
|
109
|
+
anthropic: "claude-sonnet-4-6",
|
|
110
|
+
openai: "gpt-5",
|
|
111
|
+
gemini: "gemini-2.5-flash"
|
|
112
|
+
};
|
|
113
|
+
var PROVIDER_ENV = {
|
|
114
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
115
|
+
openai: "OPENAI_API_KEY",
|
|
116
|
+
gemini: "GEMINI_API_KEY"
|
|
117
|
+
};
|
|
106
118
|
function defaultConfig() {
|
|
107
119
|
const brains = {};
|
|
108
120
|
for (const id of BRAIN_IDS) {
|
|
109
121
|
const d = BRAIN_DEFS[id];
|
|
110
122
|
brains[id] = { id, label: d.label, command: d.command, args: [...d.args], enabled: false };
|
|
111
123
|
}
|
|
112
|
-
return { version:
|
|
124
|
+
return { version: 3, defaultBrain: null, brains, apiBrains: [], outputFormat: "markdown" };
|
|
113
125
|
}
|
|
114
126
|
function loadConfig() {
|
|
115
127
|
const path = wsConfigPath();
|
|
@@ -117,8 +129,15 @@ function loadConfig() {
|
|
|
117
129
|
try {
|
|
118
130
|
const cfg = JSON.parse(readFileSync2(path, "utf8"));
|
|
119
131
|
const base = defaultConfig();
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
const brains = cfg.brains ?? {};
|
|
133
|
+
for (const id of BRAIN_IDS) if (!brains[id]) brains[id] = base.brains[id];
|
|
134
|
+
return {
|
|
135
|
+
version: 3,
|
|
136
|
+
defaultBrain: cfg.defaultBrain ?? null,
|
|
137
|
+
brains,
|
|
138
|
+
apiBrains: Array.isArray(cfg.apiBrains) ? cfg.apiBrains : [],
|
|
139
|
+
outputFormat: cfg.outputFormat === "html" ? "html" : "markdown"
|
|
140
|
+
};
|
|
122
141
|
} catch {
|
|
123
142
|
return null;
|
|
124
143
|
}
|
|
@@ -127,6 +146,47 @@ function saveConfig(cfg) {
|
|
|
127
146
|
ensureWsDir();
|
|
128
147
|
writeFileSync2(wsConfigPath(), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
129
148
|
}
|
|
149
|
+
function isReservedBrainId(id) {
|
|
150
|
+
return BRAIN_IDS.includes(id.toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
function findApiBrain(cfg, id) {
|
|
153
|
+
return cfg.apiBrains.find((b) => b.id === id);
|
|
154
|
+
}
|
|
155
|
+
function credentialsPath() {
|
|
156
|
+
return join2(GLOBAL_DIR, "credentials.json");
|
|
157
|
+
}
|
|
158
|
+
function readCredentials() {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(readFileSync2(credentialsPath(), "utf8"));
|
|
161
|
+
} catch {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function saveCredential(provider, key) {
|
|
166
|
+
mkdirSync2(GLOBAL_DIR, { recursive: true });
|
|
167
|
+
const creds = readCredentials();
|
|
168
|
+
creds[provider] = key;
|
|
169
|
+
writeFileSync2(credentialsPath(), JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 384 });
|
|
170
|
+
}
|
|
171
|
+
function resolveApiKey(brain) {
|
|
172
|
+
if (brain.keyEnv) {
|
|
173
|
+
const v = process.env[brain.keyEnv];
|
|
174
|
+
if (v && v.trim()) return v.trim();
|
|
175
|
+
}
|
|
176
|
+
const creds = readCredentials();
|
|
177
|
+
const stored = creds[brain.provider];
|
|
178
|
+
if (stored && stored.trim()) return stored.trim();
|
|
179
|
+
const std = process.env[PROVIDER_ENV[brain.provider]];
|
|
180
|
+
if (std && std.trim()) return std.trim();
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function keyHint(brain) {
|
|
184
|
+
const parts = [];
|
|
185
|
+
if (brain.keyEnv) parts.push(`set env var ${brain.keyEnv}`);
|
|
186
|
+
parts.push(`set ${PROVIDER_ENV[brain.provider]}`);
|
|
187
|
+
parts.push("or store a key via /setting > connect API brain");
|
|
188
|
+
return parts.join(", ");
|
|
189
|
+
}
|
|
130
190
|
|
|
131
191
|
// src/brains.ts
|
|
132
192
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -159,12 +219,12 @@ function renderPrompt(history, message, memory = []) {
|
|
|
159
219
|
return parts.join("\n");
|
|
160
220
|
}
|
|
161
221
|
function runBrain(brain, prompt, onChunk) {
|
|
162
|
-
return new Promise((
|
|
222
|
+
return new Promise((resolve3) => {
|
|
163
223
|
let child;
|
|
164
224
|
try {
|
|
165
225
|
child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
|
|
166
226
|
} catch (e) {
|
|
167
|
-
|
|
227
|
+
resolve3({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
|
|
168
228
|
return;
|
|
169
229
|
}
|
|
170
230
|
let out = "";
|
|
@@ -177,26 +237,26 @@ function runBrain(brain, prompt, onChunk) {
|
|
|
177
237
|
child.stderr.on("data", (d) => {
|
|
178
238
|
err += d.toString();
|
|
179
239
|
});
|
|
180
|
-
child.on("error", (e) =>
|
|
240
|
+
child.on("error", (e) => resolve3({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
|
|
181
241
|
child.on("close", (code) => {
|
|
182
242
|
const text = out.trim();
|
|
183
|
-
if (code === 0 && text)
|
|
184
|
-
else
|
|
243
|
+
if (code === 0 && text) resolve3({ ok: true, text });
|
|
244
|
+
else resolve3({ ok: false, text: err.trim() || text || `"${brain.command}" exited with code ${code}` });
|
|
185
245
|
});
|
|
186
246
|
});
|
|
187
247
|
}
|
|
188
248
|
|
|
189
249
|
// src/alias.ts
|
|
190
250
|
import { homedir as homedir2 } from "os";
|
|
191
|
-
import { join as
|
|
251
|
+
import { join as join3 } from "path";
|
|
192
252
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
|
|
193
253
|
var START = "# >>> holt launch alias >>>";
|
|
194
254
|
var END = "# <<< holt launch alias <<<";
|
|
195
255
|
function rcFile() {
|
|
196
256
|
const shell = process.env.SHELL || "";
|
|
197
|
-
if (shell.includes("zsh")) return
|
|
198
|
-
if (shell.includes("bash")) return
|
|
199
|
-
return
|
|
257
|
+
if (shell.includes("zsh")) return join3(homedir2(), ".zshrc");
|
|
258
|
+
if (shell.includes("bash")) return join3(homedir2(), ".bashrc");
|
|
259
|
+
return join3(homedir2(), ".profile");
|
|
200
260
|
}
|
|
201
261
|
function installAlias(name) {
|
|
202
262
|
const file = rcFile();
|
|
@@ -239,108 +299,38 @@ function removeAlias() {
|
|
|
239
299
|
// src/install.ts
|
|
240
300
|
import { spawn as spawn2 } from "child_process";
|
|
241
301
|
function runInteractive(cmd, args) {
|
|
242
|
-
return new Promise((
|
|
302
|
+
return new Promise((resolve3) => {
|
|
243
303
|
let child;
|
|
244
304
|
try {
|
|
245
305
|
child = spawn2(cmd, args, { stdio: "inherit" });
|
|
246
306
|
} catch {
|
|
247
|
-
|
|
307
|
+
resolve3(-1);
|
|
248
308
|
return;
|
|
249
309
|
}
|
|
250
|
-
child.on("error", () =>
|
|
251
|
-
child.on("close", (code) =>
|
|
310
|
+
child.on("error", () => resolve3(-1));
|
|
311
|
+
child.on("close", (code) => resolve3(code ?? -1));
|
|
252
312
|
});
|
|
253
313
|
}
|
|
254
314
|
|
|
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
315
|
// src/memory.ts
|
|
329
|
-
import { appendFileSync, readFileSync as readFileSync4, existsSync as existsSync4, mkdirSync as
|
|
330
|
-
import { join as
|
|
316
|
+
import { appendFileSync, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync3, rmSync, statSync } from "fs";
|
|
317
|
+
import { join as join4 } from "path";
|
|
331
318
|
import { randomUUID } from "crypto";
|
|
332
319
|
var OLLAMA_URL = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
|
|
333
320
|
var EMBED_MODEL = process.env.HOLT_EMBED_MODEL || "nomic-embed-text";
|
|
334
321
|
function memDir() {
|
|
335
|
-
return
|
|
322
|
+
return join4(wsHoltDir(), "memory");
|
|
336
323
|
}
|
|
337
324
|
function memPath() {
|
|
338
|
-
return
|
|
325
|
+
return join4(memDir(), "turns.jsonl");
|
|
339
326
|
}
|
|
340
327
|
function newSessionId() {
|
|
341
328
|
return randomUUID().slice(0, 8);
|
|
342
329
|
}
|
|
343
330
|
var embedProbe = null;
|
|
331
|
+
function resetEmbedProbe() {
|
|
332
|
+
embedProbe = null;
|
|
333
|
+
}
|
|
344
334
|
async function embeddingsAvailable() {
|
|
345
335
|
if (embedProbe !== null) return embedProbe;
|
|
346
336
|
try {
|
|
@@ -383,7 +373,7 @@ function loadTurns() {
|
|
|
383
373
|
return out;
|
|
384
374
|
}
|
|
385
375
|
function appendTurn(t) {
|
|
386
|
-
|
|
376
|
+
mkdirSync3(memDir(), { recursive: true });
|
|
387
377
|
appendFileSync(memPath(), JSON.stringify(t) + "\n", "utf8");
|
|
388
378
|
}
|
|
389
379
|
function clearMemory() {
|
|
@@ -437,6 +427,25 @@ async function recall(query, currentSession, k = 4) {
|
|
|
437
427
|
scored.sort((a, b) => b.score - a.score);
|
|
438
428
|
return scored.slice(0, k);
|
|
439
429
|
}
|
|
430
|
+
async function backfillEmbeddings(onProgress) {
|
|
431
|
+
const turns = loadTurns();
|
|
432
|
+
const missing = turns.filter((t) => !Array.isArray(t.emb));
|
|
433
|
+
if (missing.length === 0) return { embedded: 0, total: 0 };
|
|
434
|
+
let done = 0;
|
|
435
|
+
let embedded = 0;
|
|
436
|
+
for (const t of missing) {
|
|
437
|
+
const e = await embed(t.content);
|
|
438
|
+
if (e) {
|
|
439
|
+
t.emb = e;
|
|
440
|
+
embedded++;
|
|
441
|
+
}
|
|
442
|
+
done++;
|
|
443
|
+
if (onProgress) onProgress(done, missing.length);
|
|
444
|
+
}
|
|
445
|
+
mkdirSync3(memDir(), { recursive: true });
|
|
446
|
+
writeFileSync4(memPath(), turns.map((t) => JSON.stringify(t)).join("\n") + "\n", "utf8");
|
|
447
|
+
return { embedded, total: missing.length };
|
|
448
|
+
}
|
|
440
449
|
|
|
441
450
|
// src/commands/setting.ts
|
|
442
451
|
function printStatus(cfg) {
|
|
@@ -447,10 +456,63 @@ function printStatus(cfg) {
|
|
|
447
456
|
b.enabled ? c.green("enabled") : isInstalled(b.command) ? c.dim("installed, off") : c.dim("not installed"),
|
|
448
457
|
cfg.defaultBrain === id ? c.accent("default") : ""
|
|
449
458
|
].filter(Boolean).join(" ");
|
|
450
|
-
console.log(` ${id.padEnd(
|
|
459
|
+
console.log(` ${id.padEnd(10)} ${b.label.padEnd(16)} ${tags}`);
|
|
460
|
+
}
|
|
461
|
+
if (cfg.apiBrains.length) {
|
|
462
|
+
console.log(c.dim(" api brains:"));
|
|
463
|
+
for (const a of cfg.apiBrains) {
|
|
464
|
+
const key = resolveApiKey(a) ? c.green("key ok") : c.red("no key");
|
|
465
|
+
const def = cfg.defaultBrain === a.id ? c.accent("default") : "";
|
|
466
|
+
console.log(` ${a.id.padEnd(10)} ${`${a.provider}/${a.model}`.padEnd(30)} ${key} ${def}`);
|
|
467
|
+
}
|
|
451
468
|
}
|
|
469
|
+
console.log(c.dim(` output format: ${cfg.outputFormat}`));
|
|
452
470
|
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"));
|
|
471
|
+
console.log("\n " + c.dim("[d] default brain [t] toggle brain [c] connect API brain [x] remove API brain [a] launch command [enter] done"));
|
|
472
|
+
}
|
|
473
|
+
async function connectApiBrain(ask, cfg) {
|
|
474
|
+
const provRaw = (await ask(` provider [${PROVIDERS.join("/")}]: `) ?? "").trim().toLowerCase();
|
|
475
|
+
if (!PROVIDERS.includes(provRaw)) {
|
|
476
|
+
console.log(c.dim(" cancelled (unknown provider)."));
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const provider = provRaw;
|
|
480
|
+
const suggestion = PROVIDER_MODEL_SUGGESTION[provider];
|
|
481
|
+
const modelRaw = (await ask(` model (enter for ${suggestion}): `) ?? "").trim();
|
|
482
|
+
const model = modelRaw || suggestion;
|
|
483
|
+
const idRaw = (await ask(" short name for this brain (e.g. sonnet): ") ?? "").trim();
|
|
484
|
+
if (!idRaw) {
|
|
485
|
+
console.log(c.dim(" cancelled (no name)."));
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
if (isReservedBrainId(idRaw)) {
|
|
489
|
+
console.log(c.red(` "${idRaw}" is reserved for a CLI brain. Pick another name.`));
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
if (findApiBrain(cfg, idRaw)) {
|
|
493
|
+
console.log(c.red(` an API brain named "${idRaw}" already exists.`));
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
console.log(c.dim(" key: paste a raw key (stored locally, mode 600), or type the name of an env var that holds it."));
|
|
497
|
+
const keyRaw = (await ask(" key or env var name: ") ?? "").trim();
|
|
498
|
+
let keyEnv;
|
|
499
|
+
if (!keyRaw) {
|
|
500
|
+
console.log(c.dim(" no key given now. You can set the standard env var later."));
|
|
501
|
+
} else if (/^[A-Z][A-Z0-9_]*$/.test(keyRaw)) {
|
|
502
|
+
keyEnv = keyRaw;
|
|
503
|
+
console.log(c.dim(` will read the key from env var ${keyEnv}.`));
|
|
504
|
+
} else {
|
|
505
|
+
saveCredential(provider, keyRaw);
|
|
506
|
+
console.log(c.green(` key stored in ~/.holt/credentials.json (mode 600).`));
|
|
507
|
+
}
|
|
508
|
+
const brain = { id: idRaw, provider, model, ...keyEnv ? { keyEnv } : {} };
|
|
509
|
+
cfg.apiBrains.push(brain);
|
|
510
|
+
const resolvable = resolveApiKey(brain);
|
|
511
|
+
console.log(c.green(` connected "${idRaw}" -> ${provider}/${model}.`) + (resolvable ? "" : c.dim(" (no key resolves yet)")));
|
|
512
|
+
return brain;
|
|
513
|
+
}
|
|
514
|
+
function selectableIds(cfg) {
|
|
515
|
+
return [...BRAIN_IDS.filter((id) => cfg.brains[id].enabled), ...cfg.apiBrains.map((a) => a.id)];
|
|
454
516
|
}
|
|
455
517
|
async function runSettings(ask) {
|
|
456
518
|
let cfg = loadConfig() ?? defaultConfig();
|
|
@@ -460,15 +522,16 @@ async function runSettings(ask) {
|
|
|
460
522
|
const choice = (raw ?? "").trim().toLowerCase();
|
|
461
523
|
if (raw === null || choice === "" || choice === "q") break;
|
|
462
524
|
if (choice === "d") {
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
465
|
-
console.log(c.dim(' No
|
|
525
|
+
const options = selectableIds(cfg);
|
|
526
|
+
if (options.length === 0) {
|
|
527
|
+
console.log(c.dim(' No brains ready. Toggle a CLI brain on ("t") or connect an API brain ("c").'));
|
|
466
528
|
continue;
|
|
467
529
|
}
|
|
468
|
-
const pick = (await ask(` default brain [${
|
|
469
|
-
if (
|
|
530
|
+
const pick = (await ask(` default brain [${options.join("/")}]: `) ?? "").trim();
|
|
531
|
+
if (options.includes(pick)) {
|
|
470
532
|
cfg.defaultBrain = pick;
|
|
471
|
-
|
|
533
|
+
const label = BRAIN_IDS.includes(pick) ? cfg.brains[pick].label : pick;
|
|
534
|
+
console.log(c.green(` default set to ${label}`));
|
|
472
535
|
} else console.log(c.dim(" unchanged."));
|
|
473
536
|
} else if (choice === "t") {
|
|
474
537
|
const pick = (await ask(` toggle which brain [${BRAIN_IDS.join("/")}]: `) ?? "").trim();
|
|
@@ -477,11 +540,26 @@ async function runSettings(ask) {
|
|
|
477
540
|
console.log(c.dim(` ${cfg.brains[pick].label} is not installed. Run "holt init" to install it.`));
|
|
478
541
|
} else {
|
|
479
542
|
cfg.brains[pick].enabled = !cfg.brains[pick].enabled;
|
|
480
|
-
if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain =
|
|
543
|
+
if (!cfg.brains[pick].enabled && cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
|
|
481
544
|
if (cfg.brains[pick].enabled && !cfg.defaultBrain) cfg.defaultBrain = pick;
|
|
482
545
|
console.log(c.dim(` ${cfg.brains[pick].label} is now ${cfg.brains[pick].enabled ? "on" : "off"}.`));
|
|
483
546
|
}
|
|
484
547
|
} else console.log(c.dim(" unchanged."));
|
|
548
|
+
} else if (choice === "c") {
|
|
549
|
+
await connectApiBrain(ask, cfg);
|
|
550
|
+
if (!cfg.defaultBrain && cfg.apiBrains.length) cfg.defaultBrain = cfg.apiBrains[cfg.apiBrains.length - 1]?.id ?? null;
|
|
551
|
+
} else if (choice === "x") {
|
|
552
|
+
if (cfg.apiBrains.length === 0) {
|
|
553
|
+
console.log(c.dim(" no API brains to remove."));
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
const pick = (await ask(` remove which API brain [${cfg.apiBrains.map((a) => a.id).join("/")}]: `) ?? "").trim();
|
|
557
|
+
const idx = cfg.apiBrains.findIndex((a) => a.id === pick);
|
|
558
|
+
if (idx >= 0) {
|
|
559
|
+
cfg.apiBrains.splice(idx, 1);
|
|
560
|
+
if (cfg.defaultBrain === pick) cfg.defaultBrain = selectableIds(cfg)[0] ?? null;
|
|
561
|
+
console.log(c.dim(` removed "${pick}". (its stored key, if any, stays in ~/.holt/credentials.json)`));
|
|
562
|
+
} else console.log(c.dim(" unchanged."));
|
|
485
563
|
} else if (choice === "a") {
|
|
486
564
|
const name = (await ask(" launch command (blank to reset to holt): ") ?? "").trim();
|
|
487
565
|
if (name && name !== "holt") {
|
|
@@ -493,7 +571,7 @@ async function runSettings(ask) {
|
|
|
493
571
|
console.log(c.dim(" reset to holt."));
|
|
494
572
|
}
|
|
495
573
|
} else {
|
|
496
|
-
console.log(c.dim(" pick d, t, a, or press enter to finish."));
|
|
574
|
+
console.log(c.dim(" pick d, t, c, x, a, or press enter to finish."));
|
|
497
575
|
}
|
|
498
576
|
saveConfig(cfg);
|
|
499
577
|
}
|
|
@@ -511,124 +589,746 @@ async function setting() {
|
|
|
511
589
|
console.log("");
|
|
512
590
|
}
|
|
513
591
|
|
|
514
|
-
// src/commands/
|
|
515
|
-
function
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
" /help this list",
|
|
523
|
-
" /exit leave"
|
|
524
|
-
].join("\n")));
|
|
592
|
+
// src/commands/init.ts
|
|
593
|
+
function parseBrains(raw, found) {
|
|
594
|
+
const s = raw.trim().toLowerCase();
|
|
595
|
+
if (s === "") return found.length ? found : [...BRAIN_IDS];
|
|
596
|
+
if (s === "all") return [...BRAIN_IDS];
|
|
597
|
+
const picked = s.split(/[\s,]+/).filter((x) => BRAIN_IDS.includes(x));
|
|
598
|
+
if (picked.length) return [...new Set(picked)];
|
|
599
|
+
return found.length ? found : ["claude"];
|
|
525
600
|
}
|
|
526
|
-
async function
|
|
601
|
+
async function init() {
|
|
527
602
|
const { ask, close } = createReader();
|
|
528
603
|
if (!await ensureTrusted(ask)) {
|
|
529
604
|
close();
|
|
530
605
|
return;
|
|
531
606
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
await init();
|
|
541
|
-
console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
|
|
542
|
-
return;
|
|
607
|
+
console.log(c.accent("Holt setup") + c.dim(` (${workspace()})`) + "\n");
|
|
608
|
+
console.log("Looking for agent CLIs on your machine...\n");
|
|
609
|
+
const found = [];
|
|
610
|
+
for (const id of BRAIN_IDS) {
|
|
611
|
+
const ok = isInstalled(BRAIN_DEFS[id].command);
|
|
612
|
+
console.log(` ${ok ? c.green("found ") : c.dim("missing")} ${BRAIN_DEFS[id].label} (${BRAIN_DEFS[id].command})`);
|
|
613
|
+
if (ok) found.push(id);
|
|
543
614
|
}
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
console.log(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
)
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
615
|
+
console.log("");
|
|
616
|
+
const chosen = parseBrains(
|
|
617
|
+
await ask('Which brains do you want? claude, codex, gemini (comma-separated, or "all"): ') ?? "",
|
|
618
|
+
found
|
|
619
|
+
);
|
|
620
|
+
console.log(c.dim(` using: ${chosen.join(", ")}`));
|
|
621
|
+
const toInstall = chosen.filter((id) => !isInstalled(BRAIN_DEFS[id].command));
|
|
622
|
+
const loginWanted = /* @__PURE__ */ new Set();
|
|
623
|
+
for (const id of toInstall) {
|
|
624
|
+
const a = (await ask(` ${BRAIN_DEFS[id].label} is not installed. Sign in after install? [Y/n] `) ?? "").trim().toLowerCase();
|
|
625
|
+
if (a !== "n" && a !== "no") loginWanted.add(id);
|
|
626
|
+
}
|
|
627
|
+
const connectedApiBrains = [];
|
|
628
|
+
const apiAns = (await ask("\nAlso connect a direct API brain (raw key, no CLI needed)? [y/N] ") ?? "").trim().toLowerCase();
|
|
629
|
+
if (apiAns === "y" || apiAns === "yes") {
|
|
630
|
+
const holder = defaultConfig();
|
|
631
|
+
const brain = await connectApiBrain(ask, holder);
|
|
632
|
+
if (brain) connectedApiBrains.push(brain);
|
|
633
|
+
}
|
|
634
|
+
const defPick = chosen.includes("claude") ? "claude" : chosen[0];
|
|
635
|
+
const dans = (await ask(`
|
|
636
|
+
Default brain? [${chosen.join("/")}] (${defPick}): `) ?? "").trim();
|
|
637
|
+
const defaultBrain = chosen.includes(dans) ? dans : defPick;
|
|
638
|
+
const aliasAns = (await ask('Launch command? Type a custom word like "ai", or press enter to keep "holt": ') ?? "").trim();
|
|
639
|
+
let aliasNote = "";
|
|
640
|
+
if (aliasAns && aliasAns !== "holt") {
|
|
641
|
+
if (isInstalled(aliasAns)) console.log(c.dim(` note: "${aliasAns}" already exists; the alias will shadow it in new shells.`));
|
|
642
|
+
const r = installAlias(aliasAns);
|
|
643
|
+
aliasNote = r.ok ? c.green(` alias "${aliasAns}" -> holt chat added to ${r.file} (run: source ${r.file})`) : c.red(" " + r.message);
|
|
644
|
+
}
|
|
645
|
+
let wantMemorySetup = false;
|
|
646
|
+
const embedReady = await embeddingsAvailable();
|
|
647
|
+
if (embedReady) {
|
|
648
|
+
console.log(c.dim("\nSemantic memory: ready (local Ollama with " + EMBED_MODEL + " detected)."));
|
|
649
|
+
} else {
|
|
650
|
+
const ollamaHere = isInstalled("ollama");
|
|
651
|
+
const q = ollamaHere ? `Semantic memory needs a local embed model. Pull ${EMBED_MODEL} with Ollama now? [Y/n] ` : "Enable private semantic memory? Installs Ollama plus a small local embed model. Everything stays on your machine. [Y/n] ";
|
|
652
|
+
const a = (await ask("\n" + q) ?? "").trim().toLowerCase();
|
|
653
|
+
wantMemorySetup = a !== "n" && a !== "no";
|
|
654
|
+
if (!wantMemorySetup) console.log(c.dim(' Okay. Memory still works with keyword recall; run "holt init" again anytime.'));
|
|
655
|
+
}
|
|
656
|
+
close();
|
|
657
|
+
for (const id of toInstall) {
|
|
658
|
+
const s = BRAIN_SETUP[id];
|
|
659
|
+
console.log("\n" + c.accent(`Installing ${BRAIN_DEFS[id].label}`) + c.dim(` (${s.install.join(" ")})`));
|
|
660
|
+
const code = await runInteractive(s.install[0], s.install.slice(1));
|
|
661
|
+
console.log(code === 0 ? c.green(` ${BRAIN_DEFS[id].label} installed.`) : c.red(` Install failed (exit ${code}). Run manually: ${s.install.join(" ")}`));
|
|
662
|
+
}
|
|
663
|
+
for (const id of toInstall) {
|
|
664
|
+
if (!loginWanted.has(id) || !isInstalled(BRAIN_DEFS[id].command)) continue;
|
|
665
|
+
const s = BRAIN_SETUP[id];
|
|
666
|
+
console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
|
|
667
|
+
console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool to return here.`));
|
|
668
|
+
await runInteractive(s.login[0], s.login.slice(1));
|
|
669
|
+
}
|
|
670
|
+
if (wantMemorySetup) {
|
|
671
|
+
if (!isInstalled("ollama")) {
|
|
672
|
+
if (process.platform === "darwin" && isInstalled("brew")) {
|
|
673
|
+
console.log("\n" + c.accent("Installing Ollama") + c.dim(" (brew install ollama)"));
|
|
674
|
+
const code = await runInteractive("brew", ["install", "ollama"]);
|
|
675
|
+
if (code === 0) await runInteractive("brew", ["services", "start", "ollama"]);
|
|
676
|
+
else console.log(c.red(' Install failed. Get Ollama from https://ollama.com/download and run "holt init" again.'));
|
|
677
|
+
} else {
|
|
678
|
+
console.log(c.dim('\n Get Ollama from https://ollama.com/download, then run "holt init" again to finish memory setup.'));
|
|
605
679
|
}
|
|
606
|
-
console.log(c.dim(` unknown command: /${cmd} (try /help)`));
|
|
607
|
-
continue;
|
|
608
680
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
681
|
+
if (isInstalled("ollama")) {
|
|
682
|
+
console.log("\n" + c.accent("Pulling embed model") + c.dim(` (ollama pull ${EMBED_MODEL})`));
|
|
683
|
+
const code = await runInteractive("ollama", ["pull", EMBED_MODEL]);
|
|
684
|
+
if (code !== 0) {
|
|
685
|
+
console.log(c.dim(' Could not pull. Start Ollama (open the app or run "ollama serve"), then run:'));
|
|
686
|
+
console.log(c.dim(` ollama pull ${EMBED_MODEL}`));
|
|
687
|
+
}
|
|
688
|
+
resetEmbedProbe();
|
|
689
|
+
if (await embeddingsAvailable()) console.log(c.green(" Semantic memory is ready. Chats in trusted folders are stored and recalled locally."));
|
|
613
690
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
691
|
+
}
|
|
692
|
+
const cfg = loadConfig() ?? defaultConfig();
|
|
693
|
+
for (const id of BRAIN_IDS) cfg.brains[id].enabled = chosen.includes(id) && isInstalled(BRAIN_DEFS[id].command);
|
|
694
|
+
for (const b of connectedApiBrains) if (!cfg.apiBrains.some((a) => a.id === b.id)) cfg.apiBrains.push(b);
|
|
695
|
+
cfg.defaultBrain = cfg.brains[defaultBrain].enabled ? defaultBrain : BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
|
|
696
|
+
saveConfig(cfg);
|
|
697
|
+
console.log("\n" + c.green("Saved to ./.holt/config.json"));
|
|
698
|
+
if (aliasNote) console.log(aliasNote);
|
|
699
|
+
if (cfg.defaultBrain) console.log("Start chatting: " + c.accent(aliasAns && aliasAns !== "holt" ? aliasAns : "holt chat") + "\n");
|
|
700
|
+
else console.log(c.dim('No brain is ready yet. Install one, then run "holt init" again.\n'));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/commands/chat.ts
|
|
704
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
705
|
+
|
|
706
|
+
// src/apibrain.ts
|
|
707
|
+
function parseSSELines(buffer) {
|
|
708
|
+
const normalized = buffer.replace(/\r\n/g, "\n");
|
|
709
|
+
const parts = normalized.split("\n\n");
|
|
710
|
+
const rest = parts.pop() ?? "";
|
|
711
|
+
const events = parts.filter((p) => p.trim() !== "");
|
|
712
|
+
return { events, rest };
|
|
713
|
+
}
|
|
714
|
+
function dataLines(event) {
|
|
715
|
+
const out = [];
|
|
716
|
+
for (const line of event.split("\n")) {
|
|
717
|
+
const t = line.replace(/\r$/, "");
|
|
718
|
+
if (t.startsWith("data:")) out.push(t.slice(5).replace(/^ /, ""));
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
function extractDelta(provider, json) {
|
|
723
|
+
const j = json;
|
|
724
|
+
if (provider === "anthropic") {
|
|
725
|
+
if (j["type"] === "content_block_delta") {
|
|
726
|
+
const delta = j["delta"];
|
|
727
|
+
if (delta && delta.type === "text_delta" && typeof delta.text === "string") return delta.text;
|
|
728
|
+
}
|
|
729
|
+
return "";
|
|
730
|
+
}
|
|
731
|
+
if (provider === "openai") {
|
|
732
|
+
const choices = j["choices"];
|
|
733
|
+
const content = choices?.[0]?.delta?.content;
|
|
734
|
+
return typeof content === "string" ? content : "";
|
|
735
|
+
}
|
|
736
|
+
const candidates = j["candidates"];
|
|
737
|
+
const parts = candidates?.[0]?.content?.parts;
|
|
738
|
+
if (!Array.isArray(parts)) return "";
|
|
739
|
+
return parts.map((p) => typeof p.text === "string" ? p.text : "").join("");
|
|
740
|
+
}
|
|
741
|
+
function buildRequest(brain, key, prompt) {
|
|
742
|
+
if (brain.provider === "anthropic") {
|
|
743
|
+
return {
|
|
744
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
745
|
+
headers: {
|
|
746
|
+
"content-type": "application/json",
|
|
747
|
+
"x-api-key": key,
|
|
748
|
+
"anthropic-version": "2023-06-01"
|
|
749
|
+
},
|
|
750
|
+
body: JSON.stringify({
|
|
751
|
+
model: brain.model,
|
|
752
|
+
max_tokens: 4096,
|
|
753
|
+
stream: true,
|
|
754
|
+
messages: [{ role: "user", content: prompt }]
|
|
755
|
+
})
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
if (brain.provider === "openai") {
|
|
759
|
+
return {
|
|
760
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
761
|
+
headers: {
|
|
762
|
+
"content-type": "application/json",
|
|
763
|
+
authorization: `Bearer ${key}`
|
|
764
|
+
},
|
|
765
|
+
body: JSON.stringify({
|
|
766
|
+
model: brain.model,
|
|
767
|
+
stream: true,
|
|
768
|
+
messages: [{ role: "user", content: prompt }]
|
|
769
|
+
})
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
url: `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(brain.model)}:streamGenerateContent?alt=sse`,
|
|
774
|
+
headers: {
|
|
775
|
+
"content-type": "application/json",
|
|
776
|
+
"x-goog-api-key": key
|
|
777
|
+
},
|
|
778
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async function runApiBrain(brain, prompt, onChunk) {
|
|
782
|
+
const key = resolveApiKey(brain);
|
|
783
|
+
if (!key) {
|
|
784
|
+
return {
|
|
785
|
+
ok: false,
|
|
786
|
+
text: `No API key for "${brain.id}" (${brain.provider}). Fix: ${keyHint(brain)}. Standard env var: ${PROVIDER_ENV[brain.provider]}.`
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const spec = buildRequest(brain, key, prompt);
|
|
790
|
+
let res;
|
|
791
|
+
try {
|
|
792
|
+
res = await fetch(spec.url, { method: "POST", headers: spec.headers, body: spec.body });
|
|
793
|
+
} catch (e) {
|
|
794
|
+
return { ok: false, text: `Could not reach ${brain.provider}: ${e.message}` };
|
|
795
|
+
}
|
|
796
|
+
if (!res.ok) {
|
|
797
|
+
let body = "";
|
|
798
|
+
try {
|
|
799
|
+
body = await res.text();
|
|
800
|
+
} catch {
|
|
801
|
+
}
|
|
802
|
+
return { ok: false, text: `${brain.provider} error ${res.status}: ${body.slice(0, 200)}` };
|
|
803
|
+
}
|
|
804
|
+
if (!res.body) {
|
|
805
|
+
let body = "";
|
|
806
|
+
try {
|
|
807
|
+
body = await res.text();
|
|
808
|
+
} catch {
|
|
809
|
+
}
|
|
810
|
+
return { ok: false, text: `${brain.provider} returned no stream. Body: ${body.slice(0, 200)}` };
|
|
811
|
+
}
|
|
812
|
+
const decoder = new TextDecoder();
|
|
813
|
+
let carry = "";
|
|
814
|
+
let text = "";
|
|
815
|
+
try {
|
|
816
|
+
for await (const chunk of res.body) {
|
|
817
|
+
carry += decoder.decode(chunk, { stream: true });
|
|
818
|
+
const { events, rest } = parseSSELines(carry);
|
|
819
|
+
carry = rest;
|
|
820
|
+
for (const ev of events) {
|
|
821
|
+
for (const payload of dataLines(ev)) {
|
|
822
|
+
if (payload === "[DONE]") continue;
|
|
823
|
+
let json;
|
|
824
|
+
try {
|
|
825
|
+
json = JSON.parse(payload);
|
|
826
|
+
} catch {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const piece = extractDelta(brain.provider, json);
|
|
830
|
+
if (piece) {
|
|
831
|
+
text += piece;
|
|
832
|
+
if (onChunk) onChunk(piece);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
} catch (e) {
|
|
838
|
+
if (text) return { ok: true, text };
|
|
839
|
+
return { ok: false, text: `Stream from ${brain.provider} failed: ${e.message}` };
|
|
840
|
+
}
|
|
841
|
+
if (carry.trim()) {
|
|
842
|
+
for (const payload of dataLines(carry)) {
|
|
843
|
+
if (payload === "[DONE]") continue;
|
|
844
|
+
try {
|
|
845
|
+
const piece = extractDelta(brain.provider, JSON.parse(payload));
|
|
846
|
+
if (piece) {
|
|
847
|
+
text += piece;
|
|
848
|
+
if (onChunk) onChunk(piece);
|
|
849
|
+
}
|
|
850
|
+
} catch {
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const trimmed = text.trim();
|
|
855
|
+
if (trimmed) return { ok: true, text: trimmed };
|
|
856
|
+
return { ok: false, text: `${brain.provider} returned an empty reply.` };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/output.ts
|
|
860
|
+
import { writeFileSync as writeFileSync5 } from "fs";
|
|
861
|
+
import { join as join5 } from "path";
|
|
862
|
+
function escapeHtml(s) {
|
|
863
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
864
|
+
}
|
|
865
|
+
function renderInline(text) {
|
|
866
|
+
const codes = [];
|
|
867
|
+
let s = text.replace(/`([^`]+)`/g, (_m, code) => {
|
|
868
|
+
codes.push(`<code>${escapeHtml(code)}</code>`);
|
|
869
|
+
return `\0${codes.length - 1}\0`;
|
|
870
|
+
});
|
|
871
|
+
s = escapeHtml(s);
|
|
872
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, url) => {
|
|
873
|
+
const safe = url.replace(/"/g, "%22");
|
|
874
|
+
return `<a href="${safe}">${label}</a>`;
|
|
875
|
+
});
|
|
876
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
877
|
+
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
878
|
+
s = s.replace(/(\d+)/g, (_m, i) => codes[Number(i)] ?? "");
|
|
879
|
+
return s;
|
|
880
|
+
}
|
|
881
|
+
function markdownToHtmlBody(md) {
|
|
882
|
+
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
|
883
|
+
const out = [];
|
|
884
|
+
let i = 0;
|
|
885
|
+
let listOpen = false;
|
|
886
|
+
let paragraph = [];
|
|
887
|
+
const flushParagraph = () => {
|
|
888
|
+
if (paragraph.length) {
|
|
889
|
+
out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
|
|
890
|
+
paragraph = [];
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
const closeList = () => {
|
|
894
|
+
if (listOpen) {
|
|
895
|
+
out.push("</ul>");
|
|
896
|
+
listOpen = false;
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
while (i < lines.length) {
|
|
900
|
+
const line = lines[i] ?? "";
|
|
901
|
+
const fence = line.match(/^```(.*)$/);
|
|
902
|
+
if (fence) {
|
|
903
|
+
flushParagraph();
|
|
904
|
+
closeList();
|
|
905
|
+
const code = [];
|
|
906
|
+
i++;
|
|
907
|
+
while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
|
|
908
|
+
code.push(lines[i] ?? "");
|
|
909
|
+
i++;
|
|
910
|
+
}
|
|
911
|
+
i++;
|
|
912
|
+
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
916
|
+
if (h) {
|
|
917
|
+
flushParagraph();
|
|
918
|
+
closeList();
|
|
919
|
+
const level = (h[1] ?? "#").length;
|
|
920
|
+
out.push(`<h${level}>${renderInline((h[2] ?? "").trim())}</h${level}>`);
|
|
921
|
+
i++;
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
925
|
+
if (li) {
|
|
926
|
+
flushParagraph();
|
|
927
|
+
if (!listOpen) {
|
|
928
|
+
out.push("<ul>");
|
|
929
|
+
listOpen = true;
|
|
930
|
+
}
|
|
931
|
+
out.push(`<li>${renderInline((li[1] ?? "").trim())}</li>`);
|
|
932
|
+
i++;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (line.trim() === "") {
|
|
936
|
+
flushParagraph();
|
|
937
|
+
closeList();
|
|
938
|
+
i++;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
closeList();
|
|
942
|
+
paragraph.push(line.trim());
|
|
943
|
+
i++;
|
|
944
|
+
}
|
|
945
|
+
flushParagraph();
|
|
946
|
+
closeList();
|
|
947
|
+
return out.join("\n");
|
|
948
|
+
}
|
|
949
|
+
function wrapHtmlPage(body, title = "Holt reply") {
|
|
950
|
+
return `<!doctype html>
|
|
951
|
+
<html lang="en">
|
|
952
|
+
<head>
|
|
953
|
+
<meta charset="utf-8">
|
|
954
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
955
|
+
<title>${escapeHtml(title)}</title>
|
|
956
|
+
<style>
|
|
957
|
+
:root { --bg:#0f1115; --fg:#e7e9ee; --accent:#f0b91e; }
|
|
958
|
+
* { box-sizing: border-box; }
|
|
959
|
+
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
960
|
+
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
|
|
961
|
+
main { max-width: 760px; margin: 0 auto; }
|
|
962
|
+
h1,h2,h3 { color:var(--accent); line-height:1.25; margin:1.4em 0 .5em; }
|
|
963
|
+
h1 { font-size:1.6rem; } h2 { font-size:1.3rem; } h3 { font-size:1.1rem; }
|
|
964
|
+
a { color:var(--accent); }
|
|
965
|
+
code { background:#1b1e26; padding:.1em .35em; border-radius:4px; font-size:.9em; }
|
|
966
|
+
pre { background:#1b1e26; padding:1rem; border-radius:8px; overflow:auto; }
|
|
967
|
+
pre code { background:none; padding:0; }
|
|
968
|
+
ul { padding-left:1.3rem; }
|
|
969
|
+
p { margin:.7em 0; }
|
|
970
|
+
</style>
|
|
971
|
+
</head>
|
|
972
|
+
<body>
|
|
973
|
+
<main>
|
|
974
|
+
${body}
|
|
975
|
+
</main>
|
|
976
|
+
</body>
|
|
977
|
+
</html>
|
|
978
|
+
`;
|
|
979
|
+
}
|
|
980
|
+
function renderReply(reply, format) {
|
|
981
|
+
if (format === "html") return wrapHtmlPage(markdownToHtmlBody(reply));
|
|
982
|
+
return reply.endsWith("\n") ? reply : reply + "\n";
|
|
983
|
+
}
|
|
984
|
+
function timestamp() {
|
|
985
|
+
const d = /* @__PURE__ */ new Date();
|
|
986
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
987
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
988
|
+
}
|
|
989
|
+
function saveReply(reply, format, name) {
|
|
990
|
+
const ext = format === "html" ? ".html" : ".md";
|
|
991
|
+
let base = (name ?? "").trim();
|
|
992
|
+
if (!base) base = `holt-reply-${timestamp()}`;
|
|
993
|
+
if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
|
|
994
|
+
const path = join5(process.cwd(), base);
|
|
995
|
+
writeFileSync5(path, renderReply(reply, format), "utf8");
|
|
996
|
+
return path;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/skills.ts
|
|
1000
|
+
import { readFileSync as readFileSync5, readdirSync, existsSync as existsSync5, statSync as statSync2 } from "fs";
|
|
1001
|
+
import { join as join6 } from "path";
|
|
1002
|
+
function skillsRoot(scope) {
|
|
1003
|
+
return scope === "workspace" ? join6(wsHoltDir(), "skills") : join6(GLOBAL_DIR, "skills");
|
|
1004
|
+
}
|
|
1005
|
+
function sanitizeName(name) {
|
|
1006
|
+
return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
1007
|
+
}
|
|
1008
|
+
function parseFrontmatter(md) {
|
|
1009
|
+
const text = md.replace(/^/, "");
|
|
1010
|
+
const lines = text.split("\n");
|
|
1011
|
+
let start = 0;
|
|
1012
|
+
while (start < lines.length && lines[start].trim() === "") start++;
|
|
1013
|
+
if (start >= lines.length || lines[start].trim() !== "---") {
|
|
1014
|
+
return { data: {}, body: text.trim() };
|
|
1015
|
+
}
|
|
1016
|
+
const data = {};
|
|
1017
|
+
let i = start + 1;
|
|
1018
|
+
let closed = false;
|
|
1019
|
+
for (; i < lines.length; i++) {
|
|
1020
|
+
const line = lines[i];
|
|
1021
|
+
if (line.trim() === "---") {
|
|
1022
|
+
closed = true;
|
|
1023
|
+
i++;
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
const idx = line.indexOf(":");
|
|
1027
|
+
if (idx === -1) continue;
|
|
1028
|
+
const key = line.slice(0, idx).trim();
|
|
1029
|
+
if (!key) continue;
|
|
1030
|
+
let value = line.slice(idx + 1).trim();
|
|
1031
|
+
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
1032
|
+
value = value.slice(1, -1);
|
|
1033
|
+
}
|
|
1034
|
+
data[key.toLowerCase()] = value;
|
|
1035
|
+
}
|
|
1036
|
+
if (!closed) {
|
|
1037
|
+
return { data: {}, body: text.trim() };
|
|
1038
|
+
}
|
|
1039
|
+
return { data, body: lines.slice(i).join("\n").trim() };
|
|
1040
|
+
}
|
|
1041
|
+
function readSkill(dir, folder, scope) {
|
|
1042
|
+
const file = join6(dir, "SKILL.md");
|
|
1043
|
+
if (!existsSync5(file)) return null;
|
|
1044
|
+
let raw = "";
|
|
1045
|
+
try {
|
|
1046
|
+
raw = readFileSync5(file, "utf8");
|
|
1047
|
+
} catch {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
1050
|
+
const { data, body } = parseFrontmatter(raw);
|
|
1051
|
+
const name = sanitizeName(data.name || folder) || folder;
|
|
1052
|
+
let description = data.description || "";
|
|
1053
|
+
if (!description) {
|
|
1054
|
+
const firstLine2 = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || "";
|
|
1055
|
+
description = firstLine2.replace(/^#+\s*/, "");
|
|
1056
|
+
}
|
|
1057
|
+
return { name, description, dir, scope };
|
|
1058
|
+
}
|
|
1059
|
+
function listScope(scope) {
|
|
1060
|
+
const root = skillsRoot(scope);
|
|
1061
|
+
if (!existsSync5(root)) return [];
|
|
1062
|
+
let entries;
|
|
1063
|
+
try {
|
|
1064
|
+
entries = readdirSync(root);
|
|
1065
|
+
} catch {
|
|
1066
|
+
return [];
|
|
1067
|
+
}
|
|
1068
|
+
const out = [];
|
|
1069
|
+
for (const folder of entries) {
|
|
1070
|
+
const dir = join6(root, folder);
|
|
1071
|
+
try {
|
|
1072
|
+
if (!statSync2(dir).isDirectory()) continue;
|
|
1073
|
+
} catch {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const skill = readSkill(dir, folder, scope);
|
|
1077
|
+
if (skill) out.push(skill);
|
|
1078
|
+
}
|
|
1079
|
+
return out;
|
|
1080
|
+
}
|
|
1081
|
+
function listSkills() {
|
|
1082
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1083
|
+
for (const s of listScope("global")) byName.set(s.name, s);
|
|
1084
|
+
for (const s of listScope("workspace")) byName.set(s.name, s);
|
|
1085
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1086
|
+
}
|
|
1087
|
+
function loadSkill(name) {
|
|
1088
|
+
const want = sanitizeName(name);
|
|
1089
|
+
const skill = listSkills().find((s) => s.name === want);
|
|
1090
|
+
if (!skill) return null;
|
|
1091
|
+
let raw = "";
|
|
1092
|
+
try {
|
|
1093
|
+
raw = readFileSync5(join6(skill.dir, "SKILL.md"), "utf8");
|
|
1094
|
+
} catch {
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
const { body } = parseFrontmatter(raw);
|
|
1098
|
+
return { skill, body };
|
|
1099
|
+
}
|
|
1100
|
+
function skillsPromptBlock() {
|
|
1101
|
+
const skills = listSkills();
|
|
1102
|
+
if (skills.length === 0) return "";
|
|
1103
|
+
const lines = [
|
|
1104
|
+
`Skills available to this user (invoked with "/skill <name>"). When the user invokes a skill, follow that skill's instructions:`,
|
|
1105
|
+
...skills.map((s) => `- ${s.name}: ${s.description || "(no description)"}`)
|
|
1106
|
+
];
|
|
1107
|
+
return lines.join("\n");
|
|
1108
|
+
}
|
|
1109
|
+
function resolveSkillInvocation(line) {
|
|
1110
|
+
const trimmed = line.trim();
|
|
1111
|
+
const m = trimmed.match(/^\/skill\s+(\S+)\s*([\s\S]*)$/i);
|
|
1112
|
+
if (!m) return null;
|
|
1113
|
+
const name = m[1];
|
|
1114
|
+
const input = (m[2] || "").trim();
|
|
1115
|
+
const loaded = loadSkill(name);
|
|
1116
|
+
if (!loaded) return null;
|
|
1117
|
+
const prompt = [
|
|
1118
|
+
loaded.body,
|
|
1119
|
+
"",
|
|
1120
|
+
"Apply the skill above to this request:",
|
|
1121
|
+
input || "Introduce what you can do with this skill."
|
|
1122
|
+
].join("\n");
|
|
1123
|
+
return { prompt, skillName: loaded.skill.name };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/commands/chat.ts
|
|
1127
|
+
function help() {
|
|
1128
|
+
console.log(c.dim([
|
|
1129
|
+
" commands:",
|
|
1130
|
+
" /brain [name] switch brain (CLI or API). context is kept.",
|
|
1131
|
+
" /memory [query] memory stats, or preview what a query would recall",
|
|
1132
|
+
' /skill [name] [input] run a skill, or list them. "holt skill" manages them.',
|
|
1133
|
+
" /output [fmt] show or set output format: markdown | html",
|
|
1134
|
+
" /save [name] save the last reply to a file in this folder",
|
|
1135
|
+
" /setting configure brains, API brains, and your launch command",
|
|
1136
|
+
" /clear forget this session so far (saved memory stays)",
|
|
1137
|
+
" /help this list",
|
|
1138
|
+
" /exit leave"
|
|
1139
|
+
].join("\n")));
|
|
1140
|
+
}
|
|
1141
|
+
function resolveActive(cfg, id) {
|
|
1142
|
+
if (BRAIN_IDS.includes(id)) {
|
|
1143
|
+
const b = cfg.brains[id];
|
|
1144
|
+
return { kind: "cli", id, label: b.label };
|
|
1145
|
+
}
|
|
1146
|
+
const api = findApiBrain(cfg, id);
|
|
1147
|
+
if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
async function chat() {
|
|
1151
|
+
const { ask, close } = createReader();
|
|
1152
|
+
if (!await ensureTrusted(ask)) {
|
|
1153
|
+
close();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
let cfg = loadConfig();
|
|
1157
|
+
if (!cfg || !cfg.defaultBrain) {
|
|
1158
|
+
const a = (await ask(c.dim("No Holt setup in this folder. Set it up now? [Y/n] ")) ?? "").trim().toLowerCase();
|
|
1159
|
+
close();
|
|
1160
|
+
if (a === "n" || a === "no") {
|
|
1161
|
+
console.log(c.dim(' Run "holt init" here when ready.\n'));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
await init();
|
|
1165
|
+
console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
let active = resolveActive(cfg, cfg.defaultBrain);
|
|
1169
|
+
if (!active) {
|
|
1170
|
+
const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
|
|
1171
|
+
active = fallback ? resolveActive(cfg, fallback) : null;
|
|
1172
|
+
if (!active) {
|
|
1173
|
+
close();
|
|
1174
|
+
console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const session = newSessionId();
|
|
1179
|
+
const history = [];
|
|
1180
|
+
let lastReply = "";
|
|
1181
|
+
const embedOk = await embeddingsAvailable();
|
|
1182
|
+
const stats = memStats();
|
|
1183
|
+
console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
|
|
1184
|
+
console.log(c.dim(
|
|
1185
|
+
`Memory: ${stats.turns} moments from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
|
|
1186
|
+
));
|
|
1187
|
+
if (embedOk && stats.withEmbeddings < stats.turns) {
|
|
1188
|
+
console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
|
|
1189
|
+
}
|
|
1190
|
+
console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit\n"));
|
|
1191
|
+
while (true) {
|
|
1192
|
+
const raw = await ask(c.accent("\u203A "));
|
|
1193
|
+
if (raw === null) break;
|
|
1194
|
+
const line = raw.trim();
|
|
1195
|
+
if (!line) continue;
|
|
1196
|
+
let promptOverride = null;
|
|
1197
|
+
if (/^\/skills?\s+\S/.test(line)) {
|
|
1198
|
+
const inv = resolveSkillInvocation(line.replace(/^\/skills\b/, "/skill"));
|
|
1199
|
+
if (!inv) {
|
|
1200
|
+
console.log(c.dim(' no such skill. Try "holt skill list".'));
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
promptOverride = inv.prompt;
|
|
1204
|
+
console.log(c.dim(` running skill "${inv.skillName}"...`));
|
|
1205
|
+
} else if (line.startsWith("/")) {
|
|
1206
|
+
const parts = line.slice(1).split(/\s+/);
|
|
1207
|
+
const cmd = (parts[0] || "").toLowerCase();
|
|
1208
|
+
const rest = parts.slice(1).join(" ");
|
|
1209
|
+
const arg = (parts[1] || "").toLowerCase();
|
|
1210
|
+
if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
|
|
1211
|
+
if (cmd === "help" || cmd === "h") {
|
|
1212
|
+
help();
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (cmd === "clear") {
|
|
1216
|
+
history.length = 0;
|
|
1217
|
+
console.log(c.dim(" session context cleared. Saved memory is untouched."));
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (cmd === "memory" || cmd === "mem") {
|
|
1221
|
+
if (rest) {
|
|
1222
|
+
const hits = await recall(rest, session, 5);
|
|
1223
|
+
if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
|
|
1224
|
+
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, " ")}`));
|
|
1225
|
+
} else {
|
|
1226
|
+
const s = memStats();
|
|
1227
|
+
console.log(c.dim(` ${s.turns} moments, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
|
|
1228
|
+
console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
|
|
1229
|
+
}
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (cmd === "output") {
|
|
1233
|
+
const want = arg;
|
|
1234
|
+
if (want === "markdown" || want === "md") {
|
|
1235
|
+
cfg.outputFormat = "markdown";
|
|
1236
|
+
saveConfig(cfg);
|
|
1237
|
+
console.log(c.green(" output format: markdown"));
|
|
1238
|
+
} else if (want === "html") {
|
|
1239
|
+
cfg.outputFormat = "html";
|
|
1240
|
+
saveConfig(cfg);
|
|
1241
|
+
console.log(c.green(" output format: html"));
|
|
1242
|
+
} else if (want) console.log(c.dim(" usage: /output markdown | html"));
|
|
1243
|
+
else console.log(c.dim(` output format: ${cfg.outputFormat} (change with /output markdown | html)`));
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (cmd === "save") {
|
|
1247
|
+
if (!lastReply) {
|
|
1248
|
+
console.log(c.dim(" nothing to save yet. Ask something first."));
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
const path = saveReply(lastReply, cfg.outputFormat, rest || void 0);
|
|
1253
|
+
console.log(c.green(` saved ${cfg.outputFormat} to ${path}`));
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
console.log(c.red(` could not save: ${e.message}`));
|
|
1256
|
+
}
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (cmd === "setting" || cmd === "settings") {
|
|
1260
|
+
cfg = await runSettings(ask);
|
|
1261
|
+
const next = cfg.defaultBrain ? resolveActive(cfg, cfg.defaultBrain) : null;
|
|
1262
|
+
const stillValid = resolveActive(cfg, active.id);
|
|
1263
|
+
active = stillValid ?? next ?? active;
|
|
1264
|
+
console.log(c.dim(` brain: ${active.label}`));
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (cmd === "brain") {
|
|
1268
|
+
const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
|
|
1269
|
+
const apiIds = cfg.apiBrains.map((a) => a.id);
|
|
1270
|
+
const all = [...cliEnabled, ...apiIds];
|
|
1271
|
+
if (arg && all.includes(arg)) {
|
|
1272
|
+
const next = resolveActive(cfg, arg);
|
|
1273
|
+
if (next) {
|
|
1274
|
+
active = next;
|
|
1275
|
+
const turns = Math.floor(history.length / 2);
|
|
1276
|
+
console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
|
|
1277
|
+
}
|
|
1278
|
+
} else if (arg) {
|
|
1279
|
+
console.log(c.dim(` "${arg}" is not available. Available: ${all.join(", ") || "none"}`));
|
|
1280
|
+
} else {
|
|
1281
|
+
const cfgRef = cfg;
|
|
1282
|
+
const activeId = active.id;
|
|
1283
|
+
const labels = all.map((id) => {
|
|
1284
|
+
const a = findApiBrain(cfgRef, id);
|
|
1285
|
+
const shown = a ? `${id} (api: ${a.provider}/${a.model})` : id;
|
|
1286
|
+
return id === activeId ? c.accent(shown + " (current)") : shown;
|
|
1287
|
+
});
|
|
1288
|
+
console.log(c.dim(" brains: " + labels.join(" ")));
|
|
1289
|
+
console.log(c.dim(" usage: /brain <name>"));
|
|
1290
|
+
}
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (cmd === "skill" || cmd === "skills") {
|
|
1294
|
+
const names = listSkills().map((s) => s.name);
|
|
1295
|
+
console.log(c.dim(" skills: " + (names.join(" ") || "none") + " usage: /skill <name> [input]"));
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
console.log(c.dim(` unknown command: /${cmd} (try /help)`));
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
|
|
1302
|
+
console.log(c.red(` ${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Use /brain to switch or /setting.`));
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (active.kind === "api" && !resolveApiKey(active.brain)) {
|
|
1306
|
+
console.log(c.red(` ${active.label} has no API key. Use /setting to add one, or /brain to switch.`));
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
const remembered = await recall(line, session, 4);
|
|
1310
|
+
const label = remembered.length ? `${active.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${active.label} is thinking...`;
|
|
1311
|
+
console.log(c.dim(` ${label}`) + "\n");
|
|
1312
|
+
const skillsBlock = skillsPromptBlock();
|
|
1313
|
+
const base = renderPrompt(history, line, remembered);
|
|
1314
|
+
const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
|
|
1315
|
+
let streamed = false;
|
|
1316
|
+
const onChunk = (chunk) => {
|
|
1317
|
+
streamed = true;
|
|
1318
|
+
process.stdout.write(chunk);
|
|
1319
|
+
};
|
|
1320
|
+
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
|
|
1321
|
+
if (res.ok) {
|
|
1322
|
+
if (!streamed) console.log(res.text);
|
|
1323
|
+
if (!res.text.endsWith("\n")) console.log("");
|
|
1324
|
+
console.log("");
|
|
1325
|
+
lastReply = res.text;
|
|
1326
|
+
history.push({ role: "user", content: line });
|
|
1327
|
+
history.push({ role: "assistant", content: res.text });
|
|
1328
|
+
const now = Date.now();
|
|
1329
|
+
appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
|
|
1330
|
+
appendTurn({ id: randomUUID2().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
|
|
1331
|
+
} else {
|
|
632
1332
|
console.log(c.red("\n " + res.text + "\n"));
|
|
633
1333
|
}
|
|
634
1334
|
}
|
|
@@ -676,6 +1376,28 @@ async function memoryCmd(sub, rest = []) {
|
|
|
676
1376
|
close();
|
|
677
1377
|
return;
|
|
678
1378
|
}
|
|
1379
|
+
if (action === "embed") {
|
|
1380
|
+
if (!await embeddingsAvailable()) {
|
|
1381
|
+
console.log(c.dim(`
|
|
1382
|
+
No local Ollama with ${EMBED_MODEL} reachable. Run "holt init" to set it up.
|
|
1383
|
+
`));
|
|
1384
|
+
close();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
|
|
1388
|
+
if (missing === 0) {
|
|
1389
|
+
console.log(c.dim("\n All moments already have embeddings.\n"));
|
|
1390
|
+
close();
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
console.log("");
|
|
1394
|
+
const r = await backfillEmbeddings((done, total) => {
|
|
1395
|
+
process.stdout.write(`\r embedding ${done}/${total}...`);
|
|
1396
|
+
});
|
|
1397
|
+
console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} moments embedded.`) + "\n");
|
|
1398
|
+
close();
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
679
1401
|
if (action === "search") {
|
|
680
1402
|
const q = rest.join(" ").trim();
|
|
681
1403
|
if (!q) {
|
|
@@ -703,13 +1425,969 @@ async function memoryCmd(sub, rest = []) {
|
|
|
703
1425
|
console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
|
|
704
1426
|
console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
|
|
705
1427
|
console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
|
|
1428
|
+
if (embedOk && s.withEmbeddings < s.turns) {
|
|
1429
|
+
console.log(c.dim(`
|
|
1430
|
+
${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
|
|
1431
|
+
}
|
|
706
1432
|
console.log(c.dim("\n holt memory search <query> find remembered moments"));
|
|
1433
|
+
console.log(c.dim(" holt memory embed embed older moments for semantic recall"));
|
|
707
1434
|
console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
|
|
708
1435
|
close();
|
|
709
1436
|
}
|
|
710
1437
|
|
|
1438
|
+
// src/commands/skill.ts
|
|
1439
|
+
import {
|
|
1440
|
+
mkdirSync as mkdirSync4,
|
|
1441
|
+
writeFileSync as writeFileSync6,
|
|
1442
|
+
readFileSync as readFileSync6,
|
|
1443
|
+
existsSync as existsSync6,
|
|
1444
|
+
readdirSync as readdirSync2,
|
|
1445
|
+
statSync as statSync3,
|
|
1446
|
+
rmSync as rmSync2,
|
|
1447
|
+
cpSync,
|
|
1448
|
+
mkdtempSync
|
|
1449
|
+
} from "fs";
|
|
1450
|
+
import { join as join7, resolve } from "path";
|
|
1451
|
+
import { tmpdir } from "os";
|
|
1452
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1453
|
+
function usage() {
|
|
1454
|
+
console.log(c.dim([
|
|
1455
|
+
"",
|
|
1456
|
+
" " + c.accent("holt skill") + c.dim(" - manage SKILL.md skills"),
|
|
1457
|
+
"",
|
|
1458
|
+
" holt skill list list installed skills",
|
|
1459
|
+
" holt skill show <name> print a skill",
|
|
1460
|
+
" holt skill create <name> [--global] scaffold a new skill",
|
|
1461
|
+
" holt skill add <source> [--global] install from a git URL or local path",
|
|
1462
|
+
" holt skill remove <name> delete a skill",
|
|
1463
|
+
"",
|
|
1464
|
+
" --global installs into ~/.holt/skills (available in every folder).",
|
|
1465
|
+
" Without it, skills live in this folder at ./.holt/skills.",
|
|
1466
|
+
""
|
|
1467
|
+
].join("\n")));
|
|
1468
|
+
}
|
|
1469
|
+
var SKILL_TEMPLATE = (name) => [
|
|
1470
|
+
"---",
|
|
1471
|
+
`name: ${name}`,
|
|
1472
|
+
"description: One sentence on what this skill does and when to use it.",
|
|
1473
|
+
"---",
|
|
1474
|
+
"",
|
|
1475
|
+
`# ${name}`,
|
|
1476
|
+
"",
|
|
1477
|
+
"## When to use",
|
|
1478
|
+
"",
|
|
1479
|
+
"Describe the situations where this skill applies.",
|
|
1480
|
+
"",
|
|
1481
|
+
"## Instructions",
|
|
1482
|
+
"",
|
|
1483
|
+
"1. Step one.",
|
|
1484
|
+
"2. Step two.",
|
|
1485
|
+
"3. Step three.",
|
|
1486
|
+
"",
|
|
1487
|
+
"## Example",
|
|
1488
|
+
"",
|
|
1489
|
+
"Show a short example of the skill in action.",
|
|
1490
|
+
""
|
|
1491
|
+
].join("\n");
|
|
1492
|
+
function isGitUrl(s) {
|
|
1493
|
+
return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
|
|
1494
|
+
}
|
|
1495
|
+
function findSkillDir(root) {
|
|
1496
|
+
if (existsSync6(join7(root, "SKILL.md"))) return root;
|
|
1497
|
+
let subs = [];
|
|
1498
|
+
try {
|
|
1499
|
+
subs = readdirSync2(root).filter((f) => {
|
|
1500
|
+
try {
|
|
1501
|
+
return statSync3(join7(root, f)).isDirectory();
|
|
1502
|
+
} catch {
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
} catch {
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
const withSkill = subs.filter((f) => existsSync6(join7(root, f, "SKILL.md")));
|
|
1510
|
+
if (withSkill.length === 1) return join7(root, withSkill[0]);
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
function cmdList() {
|
|
1514
|
+
const skills = listSkills();
|
|
1515
|
+
if (skills.length === 0) {
|
|
1516
|
+
console.log(c.dim('\n No skills yet. Create one with "holt skill create <name>".\n'));
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const nameW = Math.max(4, ...skills.map((s) => s.name.length));
|
|
1520
|
+
const scopeW = Math.max(5, ...skills.map((s) => s.scope.length));
|
|
1521
|
+
console.log("\n" + c.accent("Skills") + c.dim(" (workspace shadows global on name clash)"));
|
|
1522
|
+
console.log(
|
|
1523
|
+
" " + c.dim("name".padEnd(nameW)) + " " + c.dim("scope".padEnd(scopeW)) + " " + c.dim("description")
|
|
1524
|
+
);
|
|
1525
|
+
for (const s of skills) {
|
|
1526
|
+
const desc = s.description.length > 68 ? s.description.slice(0, 67) + "\u2026" : s.description;
|
|
1527
|
+
console.log(
|
|
1528
|
+
" " + c.bold(s.name.padEnd(nameW)) + " " + (s.scope === "workspace" ? c.cyan(s.scope.padEnd(scopeW)) : c.dim(s.scope.padEnd(scopeW))) + " " + (desc || c.dim("(no description)"))
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
console.log(c.dim("\n holt skill show <name> print a skill"));
|
|
1532
|
+
console.log(c.dim(" In chat: /skill <name> [input] run a skill\n"));
|
|
1533
|
+
}
|
|
1534
|
+
function cmdShow(name) {
|
|
1535
|
+
if (!name) {
|
|
1536
|
+
console.log(c.dim("\n Usage: holt skill show <name>\n"));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const loaded = loadSkill(name);
|
|
1540
|
+
if (!loaded) {
|
|
1541
|
+
console.log(c.dim(`
|
|
1542
|
+
No skill named "${sanitizeName(name)}". Try "holt skill list".
|
|
1543
|
+
`));
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
const { skill, body } = loaded;
|
|
1547
|
+
console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
|
|
1548
|
+
if (skill.description) console.log(" " + skill.description);
|
|
1549
|
+
console.log(c.dim(" " + join7(skill.dir, "SKILL.md")));
|
|
1550
|
+
console.log("");
|
|
1551
|
+
console.log(c.dim(body));
|
|
1552
|
+
console.log("");
|
|
1553
|
+
}
|
|
1554
|
+
function cmdCreate(name, global) {
|
|
1555
|
+
const clean = sanitizeName(name || "");
|
|
1556
|
+
if (!clean) {
|
|
1557
|
+
console.log(c.dim("\n Usage: holt skill create <name> [--global]\n"));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const scope = global ? "global" : "workspace";
|
|
1561
|
+
const dir = join7(skillsRoot(scope), clean);
|
|
1562
|
+
if (existsSync6(dir)) {
|
|
1563
|
+
console.log(c.red(`
|
|
1564
|
+
A ${scope} skill named "${clean}" already exists.`));
|
|
1565
|
+
console.log(c.dim(" " + dir + "\n"));
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
mkdirSync4(dir, { recursive: true });
|
|
1569
|
+
writeFileSync6(join7(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
|
|
1570
|
+
console.log(c.green(`
|
|
1571
|
+
Created ${scope} skill "${clean}".`));
|
|
1572
|
+
console.log(c.dim(" " + join7(dir, "SKILL.md")));
|
|
1573
|
+
console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
|
|
1574
|
+
}
|
|
1575
|
+
function cmdAdd(source, global) {
|
|
1576
|
+
if (!source) {
|
|
1577
|
+
console.log(c.dim("\n Usage: holt skill add <git-url|path> [--global]\n"));
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const scope = global ? "global" : "workspace";
|
|
1581
|
+
let fetched = "";
|
|
1582
|
+
let tempDir = "";
|
|
1583
|
+
try {
|
|
1584
|
+
if (isGitUrl(source)) {
|
|
1585
|
+
tempDir = mkdtempSync(join7(tmpdir(), "holt-skill-"));
|
|
1586
|
+
console.log(c.dim(`
|
|
1587
|
+
Cloning ${source} ...`));
|
|
1588
|
+
const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
|
|
1589
|
+
if (res.status !== 0) {
|
|
1590
|
+
console.log(c.red(" Clone failed. Check the URL and that git is installed.\n"));
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
fetched = tempDir;
|
|
1594
|
+
} else {
|
|
1595
|
+
fetched = resolve(source);
|
|
1596
|
+
if (!existsSync6(fetched)) {
|
|
1597
|
+
console.log(c.red(`
|
|
1598
|
+
No such path: ${fetched}
|
|
1599
|
+
`));
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const skillDir = findSkillDir(fetched);
|
|
1604
|
+
if (!skillDir) {
|
|
1605
|
+
console.log(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
let skillName = "";
|
|
1609
|
+
try {
|
|
1610
|
+
const { data } = parseFrontmatter(readFileSync6(join7(skillDir, "SKILL.md"), "utf8"));
|
|
1611
|
+
skillName = sanitizeName(data.name || "");
|
|
1612
|
+
} catch {
|
|
1613
|
+
skillName = "";
|
|
1614
|
+
}
|
|
1615
|
+
if (!skillName) skillName = sanitizeName(skillDir.split(/[\\/]/).pop() || "");
|
|
1616
|
+
if (!skillName) {
|
|
1617
|
+
console.log(c.red("\n Could not determine a valid skill name.\n"));
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
const dest = join7(skillsRoot(scope), skillName);
|
|
1621
|
+
if (existsSync6(dest)) {
|
|
1622
|
+
console.log(c.red(`
|
|
1623
|
+
A ${scope} skill named "${skillName}" already exists.`));
|
|
1624
|
+
console.log(c.dim(" " + dest + "\n"));
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
mkdirSync4(skillsRoot(scope), { recursive: true });
|
|
1628
|
+
cpSync(skillDir, dest, { recursive: true });
|
|
1629
|
+
const nestedGit = join7(dest, ".git");
|
|
1630
|
+
if (existsSync6(nestedGit)) rmSync2(nestedGit, { recursive: true, force: true });
|
|
1631
|
+
console.log(c.green(`
|
|
1632
|
+
Installed ${scope} skill "${skillName}".`));
|
|
1633
|
+
console.log(c.dim(" " + join7(dest, "SKILL.md") + "\n"));
|
|
1634
|
+
} finally {
|
|
1635
|
+
if (tempDir && existsSync6(tempDir)) {
|
|
1636
|
+
try {
|
|
1637
|
+
rmSync2(tempDir, { recursive: true, force: true });
|
|
1638
|
+
} catch {
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
async function cmdRemove(name, ask) {
|
|
1644
|
+
const clean = sanitizeName(name || "");
|
|
1645
|
+
if (!clean) {
|
|
1646
|
+
console.log(c.dim("\n Usage: holt skill remove <name>\n"));
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const wsDir = join7(skillsRoot("workspace"), clean);
|
|
1650
|
+
const globalDir = join7(skillsRoot("global"), clean);
|
|
1651
|
+
const target = existsSync6(wsDir) ? wsDir : existsSync6(globalDir) ? globalDir : "";
|
|
1652
|
+
if (!target) {
|
|
1653
|
+
console.log(c.dim(`
|
|
1654
|
+
No skill named "${clean}" found.
|
|
1655
|
+
`));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const scope = target === wsDir ? "workspace" : "global";
|
|
1659
|
+
const a = (await ask(`
|
|
1660
|
+
Delete ${scope} skill "${clean}"? [y/N] `) ?? "").trim().toLowerCase();
|
|
1661
|
+
if (a === "y" || a === "yes") {
|
|
1662
|
+
rmSync2(target, { recursive: true, force: true });
|
|
1663
|
+
console.log(c.green(" Removed.\n"));
|
|
1664
|
+
} else {
|
|
1665
|
+
console.log(c.dim(" Kept.\n"));
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
async function skillCmd(sub, rest = []) {
|
|
1669
|
+
const { ask, close } = createReader();
|
|
1670
|
+
if (!await ensureTrusted(ask)) {
|
|
1671
|
+
close();
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
const global = rest.includes("--global");
|
|
1675
|
+
const args = rest.filter((a) => a !== "--global");
|
|
1676
|
+
const action = (sub || "").toLowerCase();
|
|
1677
|
+
try {
|
|
1678
|
+
switch (action) {
|
|
1679
|
+
case "list":
|
|
1680
|
+
case "ls":
|
|
1681
|
+
cmdList();
|
|
1682
|
+
break;
|
|
1683
|
+
case "show":
|
|
1684
|
+
case "view":
|
|
1685
|
+
cmdShow(args[0]);
|
|
1686
|
+
break;
|
|
1687
|
+
case "create":
|
|
1688
|
+
case "new":
|
|
1689
|
+
cmdCreate(args[0], global);
|
|
1690
|
+
break;
|
|
1691
|
+
case "add":
|
|
1692
|
+
case "install":
|
|
1693
|
+
cmdAdd(args[0], global);
|
|
1694
|
+
break;
|
|
1695
|
+
case "remove":
|
|
1696
|
+
case "rm":
|
|
1697
|
+
case "delete":
|
|
1698
|
+
await cmdRemove(args[0], ask);
|
|
1699
|
+
break;
|
|
1700
|
+
default:
|
|
1701
|
+
usage();
|
|
1702
|
+
}
|
|
1703
|
+
} finally {
|
|
1704
|
+
close();
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/commands/graph.ts
|
|
1709
|
+
import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync5 } from "fs";
|
|
1710
|
+
import { join as join8, dirname, resolve as resolve2 } from "path";
|
|
1711
|
+
import { spawn as spawn3 } from "child_process";
|
|
1712
|
+
|
|
1713
|
+
// src/graphview.ts
|
|
1714
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1715
|
+
"the",
|
|
1716
|
+
"and",
|
|
1717
|
+
"that",
|
|
1718
|
+
"have",
|
|
1719
|
+
"this",
|
|
1720
|
+
"with",
|
|
1721
|
+
"from",
|
|
1722
|
+
"they",
|
|
1723
|
+
"what",
|
|
1724
|
+
"when",
|
|
1725
|
+
"your",
|
|
1726
|
+
"would",
|
|
1727
|
+
"there",
|
|
1728
|
+
"their",
|
|
1729
|
+
"will",
|
|
1730
|
+
"about",
|
|
1731
|
+
"which",
|
|
1732
|
+
"them",
|
|
1733
|
+
"then",
|
|
1734
|
+
"than",
|
|
1735
|
+
"were",
|
|
1736
|
+
"been",
|
|
1737
|
+
"being",
|
|
1738
|
+
"into",
|
|
1739
|
+
"over",
|
|
1740
|
+
"some",
|
|
1741
|
+
"such",
|
|
1742
|
+
"only",
|
|
1743
|
+
"also",
|
|
1744
|
+
"because",
|
|
1745
|
+
"these",
|
|
1746
|
+
"those",
|
|
1747
|
+
"here",
|
|
1748
|
+
"more",
|
|
1749
|
+
"most",
|
|
1750
|
+
"other",
|
|
1751
|
+
"want",
|
|
1752
|
+
"need",
|
|
1753
|
+
"like",
|
|
1754
|
+
"just",
|
|
1755
|
+
"make",
|
|
1756
|
+
"made",
|
|
1757
|
+
"does",
|
|
1758
|
+
"done",
|
|
1759
|
+
"each",
|
|
1760
|
+
"very",
|
|
1761
|
+
"much",
|
|
1762
|
+
"many",
|
|
1763
|
+
"could",
|
|
1764
|
+
"should",
|
|
1765
|
+
"shall",
|
|
1766
|
+
"must",
|
|
1767
|
+
"might",
|
|
1768
|
+
"even",
|
|
1769
|
+
"still",
|
|
1770
|
+
"while",
|
|
1771
|
+
"where",
|
|
1772
|
+
"good",
|
|
1773
|
+
"know",
|
|
1774
|
+
"take",
|
|
1775
|
+
"them",
|
|
1776
|
+
"thing",
|
|
1777
|
+
"things",
|
|
1778
|
+
"okay",
|
|
1779
|
+
"yeah",
|
|
1780
|
+
"sure",
|
|
1781
|
+
"help",
|
|
1782
|
+
"please",
|
|
1783
|
+
"thanks",
|
|
1784
|
+
"lets"
|
|
1785
|
+
]);
|
|
1786
|
+
function cosine2(a, b) {
|
|
1787
|
+
let dot = 0;
|
|
1788
|
+
let na = 0;
|
|
1789
|
+
let nb = 0;
|
|
1790
|
+
const n = Math.min(a.length, b.length);
|
|
1791
|
+
for (let i = 0; i < n; i++) {
|
|
1792
|
+
const x = a[i];
|
|
1793
|
+
const y = b[i];
|
|
1794
|
+
dot += x * y;
|
|
1795
|
+
na += x * x;
|
|
1796
|
+
nb += y * y;
|
|
1797
|
+
}
|
|
1798
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
1799
|
+
}
|
|
1800
|
+
function firstLine(s, max = 60) {
|
|
1801
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
1802
|
+
return flat.length > max ? flat.slice(0, max - 1).trimEnd() + "\u2026" : flat;
|
|
1803
|
+
}
|
|
1804
|
+
function words(s) {
|
|
1805
|
+
return s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w));
|
|
1806
|
+
}
|
|
1807
|
+
var SEMANTIC_MIN = 0.55;
|
|
1808
|
+
var SEMANTIC_PAIR_CAP = 300;
|
|
1809
|
+
var SEMANTIC_TURN_CAP = 2e3;
|
|
1810
|
+
var CONCEPT_CAP = 40;
|
|
1811
|
+
var CONCEPT_MIN_TURNS = 2;
|
|
1812
|
+
function buildGraph(turns) {
|
|
1813
|
+
const nodes = [];
|
|
1814
|
+
const edges = [];
|
|
1815
|
+
for (const t of turns) {
|
|
1816
|
+
nodes.push({
|
|
1817
|
+
id: t.id,
|
|
1818
|
+
kind: "turn",
|
|
1819
|
+
label: firstLine(t.content),
|
|
1820
|
+
role: t.role,
|
|
1821
|
+
session: t.session,
|
|
1822
|
+
ts: t.ts,
|
|
1823
|
+
content: t.content
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
1827
|
+
for (const t of turns) {
|
|
1828
|
+
const arr = bySession.get(t.session);
|
|
1829
|
+
if (arr) arr.push(t);
|
|
1830
|
+
else bySession.set(t.session, [t]);
|
|
1831
|
+
}
|
|
1832
|
+
for (const arr of bySession.values()) {
|
|
1833
|
+
for (let i = 1; i < arr.length; i++) {
|
|
1834
|
+
const prev = arr[i - 1];
|
|
1835
|
+
const cur = arr[i];
|
|
1836
|
+
edges.push({ source: prev.id, target: cur.id, kind: "sequential", width: 1 });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
if (turns.length <= SEMANTIC_TURN_CAP) {
|
|
1840
|
+
const withEmb = turns.filter((t) => Array.isArray(t.emb) && t.emb.length > 0);
|
|
1841
|
+
const pairs = [];
|
|
1842
|
+
for (let i = 0; i < withEmb.length; i++) {
|
|
1843
|
+
const a = withEmb[i];
|
|
1844
|
+
for (let j = i + 1; j < withEmb.length; j++) {
|
|
1845
|
+
const b = withEmb[j];
|
|
1846
|
+
const sim = cosine2(a.emb, b.emb);
|
|
1847
|
+
if (sim >= SEMANTIC_MIN) pairs.push({ source: a.id, target: b.id, sim });
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
pairs.sort((x, y) => y.sim - x.sim);
|
|
1851
|
+
for (const p of pairs.slice(0, SEMANTIC_PAIR_CAP)) {
|
|
1852
|
+
const width = 1.5 + (p.sim - SEMANTIC_MIN) / (1 - SEMANTIC_MIN) * 3.5;
|
|
1853
|
+
edges.push({ source: p.source, target: p.target, kind: "semantic", width: Math.round(width * 100) / 100 });
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const conceptTurns = /* @__PURE__ */ new Map();
|
|
1857
|
+
for (const t of turns) {
|
|
1858
|
+
for (const w of new Set(words(t.content))) {
|
|
1859
|
+
const set = conceptTurns.get(w);
|
|
1860
|
+
if (set) set.add(t.id);
|
|
1861
|
+
else conceptTurns.set(w, /* @__PURE__ */ new Set([t.id]));
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const concepts = [...conceptTurns.entries()].filter(([, ids]) => ids.size >= CONCEPT_MIN_TURNS).sort((a, b) => b[1].size - a[1].size || a[0].localeCompare(b[0])).slice(0, CONCEPT_CAP);
|
|
1865
|
+
for (const [word, ids] of concepts) {
|
|
1866
|
+
const cid = "concept:" + word;
|
|
1867
|
+
nodes.push({ id: cid, kind: "concept", label: word, freq: ids.size });
|
|
1868
|
+
for (const turnId of ids) {
|
|
1869
|
+
edges.push({ source: cid, target: turnId, kind: "concept", width: 1 });
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
return { nodes, edges };
|
|
1873
|
+
}
|
|
1874
|
+
function escapeForScript(json) {
|
|
1875
|
+
return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\!--");
|
|
1876
|
+
}
|
|
1877
|
+
function escapeHtml2(s) {
|
|
1878
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1879
|
+
}
|
|
1880
|
+
function renderGraphHtml(graph2, meta) {
|
|
1881
|
+
const dataJson = escapeForScript(JSON.stringify(graph2));
|
|
1882
|
+
const wsSafe = escapeHtml2(meta.workspace);
|
|
1883
|
+
return `<!doctype html>
|
|
1884
|
+
<html lang="en">
|
|
1885
|
+
<head>
|
|
1886
|
+
<meta charset="utf-8" />
|
|
1887
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1888
|
+
<title>Holt memory graph</title>
|
|
1889
|
+
<style>
|
|
1890
|
+
:root {
|
|
1891
|
+
--bg: #0f1115;
|
|
1892
|
+
--panel: #171a21;
|
|
1893
|
+
--text: #e7e9ee;
|
|
1894
|
+
--muted: #9aa3b2;
|
|
1895
|
+
--amber: #f0b91e;
|
|
1896
|
+
--cyan: #35d0d6;
|
|
1897
|
+
--violet: #9a8cff;
|
|
1898
|
+
--line: #232733;
|
|
1899
|
+
}
|
|
1900
|
+
* { box-sizing: border-box; }
|
|
1901
|
+
html, body { margin: 0; height: 100%; }
|
|
1902
|
+
body {
|
|
1903
|
+
background: var(--bg); color: var(--text); overflow: hidden;
|
|
1904
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1905
|
+
-webkit-font-smoothing: antialiased;
|
|
1906
|
+
}
|
|
1907
|
+
header {
|
|
1908
|
+
position: fixed; top: 0; left: 0; right: 0; z-index: 5;
|
|
1909
|
+
display: flex; align-items: center; gap: 16px;
|
|
1910
|
+
padding: 10px 16px; background: rgba(15,17,21,0.82);
|
|
1911
|
+
border-bottom: 1px solid var(--line); backdrop-filter: blur(6px);
|
|
1912
|
+
}
|
|
1913
|
+
.wordmark { color: var(--amber); font-weight: 700; font-size: 18px; letter-spacing: 1px; }
|
|
1914
|
+
.ws { color: var(--muted); font-size: 12px; max-width: 34vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1915
|
+
.stats { color: var(--muted); font-size: 12px; margin-left: auto; }
|
|
1916
|
+
.stats b { color: var(--text); font-weight: 600; }
|
|
1917
|
+
#search {
|
|
1918
|
+
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
|
1919
|
+
border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 13px; width: 220px;
|
|
1920
|
+
}
|
|
1921
|
+
#search:focus { outline: none; border-color: var(--amber); }
|
|
1922
|
+
#stage { position: fixed; inset: 0; display: block; }
|
|
1923
|
+
#tooltip {
|
|
1924
|
+
position: fixed; z-index: 8; pointer-events: none; display: none;
|
|
1925
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
|
|
1926
|
+
padding: 6px 9px; font-size: 12px; max-width: 320px; color: var(--text);
|
|
1927
|
+
box-shadow: 0 6px 20px rgba(0,0,0,0.5);
|
|
1928
|
+
}
|
|
1929
|
+
#tooltip .t-meta { color: var(--muted); font-size: 11px; margin-top: 3px; }
|
|
1930
|
+
#panel {
|
|
1931
|
+
position: fixed; top: 0; right: 0; bottom: 0; z-index: 7; width: 380px; max-width: 92vw;
|
|
1932
|
+
background: var(--panel); border-left: 1px solid var(--line);
|
|
1933
|
+
transform: translateX(100%); transition: transform 160ms ease;
|
|
1934
|
+
display: flex; flex-direction: column; padding: 18px; overflow-y: auto;
|
|
1935
|
+
}
|
|
1936
|
+
#panel.open { transform: translateX(0); }
|
|
1937
|
+
#panel .p-close { position: absolute; top: 12px; right: 14px; cursor: pointer; color: var(--muted); font-size: 18px; border: none; background: none; }
|
|
1938
|
+
#panel .p-close:hover { color: var(--text); }
|
|
1939
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
1940
|
+
.chip .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
|
1941
|
+
#p-role { font-size: 13px; color: var(--amber); text-transform: uppercase; letter-spacing: 1px; margin: 2px 0 10px; }
|
|
1942
|
+
#p-content { white-space: pre-wrap; word-break: break-word; line-height: 1.5; font-size: 13px; color: var(--text); }
|
|
1943
|
+
#p-time { color: var(--muted); font-size: 12px; margin-top: 14px; }
|
|
1944
|
+
#legend {
|
|
1945
|
+
position: fixed; left: 12px; bottom: 12px; z-index: 6;
|
|
1946
|
+
background: rgba(23,26,33,0.85); border: 1px solid var(--line); border-radius: 8px;
|
|
1947
|
+
padding: 10px 12px; font-size: 12px; color: var(--muted);
|
|
1948
|
+
}
|
|
1949
|
+
#legend .row { display: flex; align-items: center; gap: 8px; margin: 3px 0; }
|
|
1950
|
+
#legend .swatch { width: 11px; height: 11px; border-radius: 50%; }
|
|
1951
|
+
#hint { position: fixed; right: 12px; bottom: 12px; z-index: 6; color: var(--muted); font-size: 11px; opacity: 0.7; }
|
|
1952
|
+
#empty { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; color: var(--muted); }
|
|
1953
|
+
</style>
|
|
1954
|
+
</head>
|
|
1955
|
+
<body>
|
|
1956
|
+
<header>
|
|
1957
|
+
<span class="wordmark">Holt</span>
|
|
1958
|
+
<span class="ws" title="${wsSafe}">${wsSafe}</span>
|
|
1959
|
+
<input id="search" type="search" placeholder="search memory..." autocomplete="off" spellcheck="false" />
|
|
1960
|
+
<span class="stats"><b>${meta.turns}</b> turns · <b>${meta.sessions}</b> sessions · <b>${meta.concepts}</b> concepts · <b>${meta.edges}</b> edges</span>
|
|
1961
|
+
</header>
|
|
1962
|
+
|
|
1963
|
+
<canvas id="stage"></canvas>
|
|
1964
|
+
|
|
1965
|
+
<div id="tooltip"></div>
|
|
1966
|
+
|
|
1967
|
+
<aside id="panel">
|
|
1968
|
+
<button class="p-close" id="p-close" aria-label="close">×</button>
|
|
1969
|
+
<div class="chip"><span class="dot" id="p-dot"></span><span id="p-session"></span></div>
|
|
1970
|
+
<div id="p-role"></div>
|
|
1971
|
+
<div id="p-content"></div>
|
|
1972
|
+
<div id="p-time"></div>
|
|
1973
|
+
</aside>
|
|
1974
|
+
|
|
1975
|
+
<div id="legend">
|
|
1976
|
+
<div class="row"><span class="swatch" style="background:var(--amber)"></span>you</div>
|
|
1977
|
+
<div class="row"><span class="swatch" style="background:var(--cyan)"></span>assistant</div>
|
|
1978
|
+
<div class="row"><span class="swatch" style="background:var(--violet)"></span>concept</div>
|
|
1979
|
+
<div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session</div>
|
|
1980
|
+
</div>
|
|
1981
|
+
<div id="hint">drag to pan · wheel to zoom · click a node · Esc to clear</div>
|
|
1982
|
+
<div id="empty">This graph has no nodes yet.</div>
|
|
1983
|
+
|
|
1984
|
+
<script id="graph-data" type="application/json">${dataJson}</script>
|
|
1985
|
+
<script>
|
|
1986
|
+
(function () {
|
|
1987
|
+
"use strict";
|
|
1988
|
+
var DATA = JSON.parse(document.getElementById("graph-data").textContent);
|
|
1989
|
+
var nodes = DATA.nodes || [];
|
|
1990
|
+
var edges = DATA.edges || [];
|
|
1991
|
+
|
|
1992
|
+
var canvas = document.getElementById("stage");
|
|
1993
|
+
var ctx = canvas.getContext("2d");
|
|
1994
|
+
var tooltip = document.getElementById("tooltip");
|
|
1995
|
+
var panel = document.getElementById("panel");
|
|
1996
|
+
var searchBox = document.getElementById("search");
|
|
1997
|
+
var reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1998
|
+
|
|
1999
|
+
if (!nodes.length) { document.getElementById("empty").style.display = "flex"; return; }
|
|
2000
|
+
|
|
2001
|
+
// ---- color per session (distinct hues) ----
|
|
2002
|
+
var sessionList = [];
|
|
2003
|
+
nodes.forEach(function (n) { if (n.session && sessionList.indexOf(n.session) < 0) sessionList.push(n.session); });
|
|
2004
|
+
function sessionHue(session) {
|
|
2005
|
+
var i = sessionList.indexOf(session);
|
|
2006
|
+
if (i < 0) i = 0;
|
|
2007
|
+
return (i * 137.508) % 360; // golden-angle spread
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// ---- index + degree ----
|
|
2011
|
+
var byId = {};
|
|
2012
|
+
nodes.forEach(function (n) { byId[n.id] = n; n._deg = 0; });
|
|
2013
|
+
var links = [];
|
|
2014
|
+
edges.forEach(function (e) {
|
|
2015
|
+
var s = byId[e.source], t = byId[e.target];
|
|
2016
|
+
if (!s || !t) return;
|
|
2017
|
+
s._deg++; t._deg++;
|
|
2018
|
+
links.push({ s: s, t: t, kind: e.kind, width: e.width });
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
function radiusOf(n) {
|
|
2022
|
+
var base = n.kind === "concept" ? 3 : 5;
|
|
2023
|
+
return base + Math.min(9, Math.sqrt(n._deg) * 1.6);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// ---- initial layout: spread on a spiral so physics has somewhere to start ----
|
|
2027
|
+
nodes.forEach(function (n, i) {
|
|
2028
|
+
var ang = i * 2.399963;
|
|
2029
|
+
var rad = 22 * Math.sqrt(i);
|
|
2030
|
+
n.x = Math.cos(ang) * rad;
|
|
2031
|
+
n.y = Math.sin(ang) * rad;
|
|
2032
|
+
n.vx = 0; n.vy = 0;
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
// ---- physics ----
|
|
2036
|
+
var REPULSION = 5200, SPRING = 0.02, GRAVITY = 0.015, DAMP = 0.86;
|
|
2037
|
+
var restLen = { sequential: 60, semantic: 45, concept: 70 };
|
|
2038
|
+
|
|
2039
|
+
function step() {
|
|
2040
|
+
var i, j, n, m, dx, dy, d2, d, f;
|
|
2041
|
+
// repulsion (all pairs; fine for the sizes Holt produces)
|
|
2042
|
+
for (i = 0; i < nodes.length; i++) {
|
|
2043
|
+
n = nodes[i];
|
|
2044
|
+
for (j = i + 1; j < nodes.length; j++) {
|
|
2045
|
+
m = nodes[j];
|
|
2046
|
+
dx = n.x - m.x; dy = n.y - m.y;
|
|
2047
|
+
d2 = dx * dx + dy * dy || 0.01;
|
|
2048
|
+
if (d2 > 90000) continue; // ignore far-apart pairs
|
|
2049
|
+
d = Math.sqrt(d2);
|
|
2050
|
+
f = REPULSION / d2;
|
|
2051
|
+
var ux = dx / d, uy = dy / d;
|
|
2052
|
+
n.vx += ux * f; n.vy += uy * f;
|
|
2053
|
+
m.vx -= ux * f; m.vy -= uy * f;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
// springs
|
|
2057
|
+
for (i = 0; i < links.length; i++) {
|
|
2058
|
+
var lk = links[i];
|
|
2059
|
+
dx = lk.t.x - lk.s.x; dy = lk.t.y - lk.s.y;
|
|
2060
|
+
d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
2061
|
+
var rest = restLen[lk.kind] || 60;
|
|
2062
|
+
f = (d - rest) * SPRING;
|
|
2063
|
+
var ax = (dx / d) * f, ay = (dy / d) * f;
|
|
2064
|
+
lk.s.vx += ax; lk.s.vy += ay;
|
|
2065
|
+
lk.t.vx -= ax; lk.t.vy -= ay;
|
|
2066
|
+
}
|
|
2067
|
+
// gravity to center + integrate
|
|
2068
|
+
var moved = 0;
|
|
2069
|
+
for (i = 0; i < nodes.length; i++) {
|
|
2070
|
+
n = nodes[i];
|
|
2071
|
+
n.vx -= n.x * GRAVITY; n.vy -= n.y * GRAVITY;
|
|
2072
|
+
n.vx *= DAMP; n.vy *= DAMP;
|
|
2073
|
+
if (n === dragNode) continue;
|
|
2074
|
+
n.x += n.vx; n.y += n.vy;
|
|
2075
|
+
moved += Math.abs(n.vx) + Math.abs(n.vy);
|
|
2076
|
+
}
|
|
2077
|
+
return moved / nodes.length;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// ---- view transform ----
|
|
2081
|
+
var view = { x: 0, y: 0, scale: 1 };
|
|
2082
|
+
var W = 0, H = 0, dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
2083
|
+
function resize() {
|
|
2084
|
+
W = window.innerWidth; H = window.innerHeight;
|
|
2085
|
+
canvas.width = Math.floor(W * dpr); canvas.height = Math.floor(H * dpr);
|
|
2086
|
+
canvas.style.width = W + "px"; canvas.style.height = H + "px";
|
|
2087
|
+
}
|
|
2088
|
+
window.addEventListener("resize", resize);
|
|
2089
|
+
resize();
|
|
2090
|
+
view.x = W / 2; view.y = H / 2;
|
|
2091
|
+
|
|
2092
|
+
function worldToScreen(x, y) { return { x: x * view.scale + view.x, y: y * view.scale + view.y }; }
|
|
2093
|
+
function screenToWorld(x, y) { return { x: (x - view.x) / view.scale, y: (y - view.y) / view.scale }; }
|
|
2094
|
+
|
|
2095
|
+
// ---- selection + search state ----
|
|
2096
|
+
var selected = null; // a node id
|
|
2097
|
+
var highlightSet = null; // Set of ids to emphasise (search or concept click)
|
|
2098
|
+
var query = "";
|
|
2099
|
+
|
|
2100
|
+
function roleColor(n) {
|
|
2101
|
+
if (n.kind === "concept") return "#9a8cff";
|
|
2102
|
+
return n.role === "user" ? "#f0b91e" : "#35d0d6";
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function matchesQuery(n) {
|
|
2106
|
+
if (!query) return false;
|
|
2107
|
+
var hay = (n.label || "") + " " + (n.content || "");
|
|
2108
|
+
return hay.toLowerCase().indexOf(query) >= 0;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function draw() {
|
|
2112
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2113
|
+
ctx.clearRect(0, 0, W, H);
|
|
2114
|
+
|
|
2115
|
+
// edges
|
|
2116
|
+
for (var i = 0; i < links.length; i++) {
|
|
2117
|
+
var lk = links[i];
|
|
2118
|
+
var a = worldToScreen(lk.s.x, lk.s.y), b = worldToScreen(lk.t.x, lk.t.y);
|
|
2119
|
+
var dim = 0.16;
|
|
2120
|
+
if (highlightSet) {
|
|
2121
|
+
if (highlightSet.has(lk.s.id) || highlightSet.has(lk.t.id)) dim = 0.55; else dim = 0.05;
|
|
2122
|
+
} else {
|
|
2123
|
+
dim = lk.kind === "semantic" ? 0.4 : lk.kind === "concept" ? 0.14 : 0.22;
|
|
2124
|
+
}
|
|
2125
|
+
ctx.globalAlpha = dim;
|
|
2126
|
+
ctx.lineWidth = Math.max(0.4, lk.width * view.scale * 0.5);
|
|
2127
|
+
ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : "#4a5262";
|
|
2128
|
+
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
2129
|
+
}
|
|
2130
|
+
ctx.globalAlpha = 1;
|
|
2131
|
+
|
|
2132
|
+
// nodes
|
|
2133
|
+
for (var k = 0; k < nodes.length; k++) {
|
|
2134
|
+
var n = nodes[k];
|
|
2135
|
+
var p = worldToScreen(n.x, n.y);
|
|
2136
|
+
var r = radiusOf(n) * view.scale;
|
|
2137
|
+
var isHot = (highlightSet && highlightSet.has(n.id)) || (query && matchesQuery(n));
|
|
2138
|
+
var faded = (highlightSet && !highlightSet.has(n.id)) || (query && !matchesQuery(n));
|
|
2139
|
+
ctx.globalAlpha = faded ? 0.18 : 1;
|
|
2140
|
+
|
|
2141
|
+
// session ring
|
|
2142
|
+
if (n.kind === "turn") {
|
|
2143
|
+
ctx.beginPath();
|
|
2144
|
+
ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
|
|
2145
|
+
ctx.strokeStyle = "hsl(" + sessionHue(n.session) + ",70%,58%)";
|
|
2146
|
+
ctx.lineWidth = 1.6; ctx.stroke();
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
ctx.beginPath();
|
|
2150
|
+
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
|
2151
|
+
ctx.fillStyle = roleColor(n);
|
|
2152
|
+
ctx.fill();
|
|
2153
|
+
|
|
2154
|
+
if (n.id === selected || isHot) {
|
|
2155
|
+
ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.stroke();
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// concept labels always; turn labels when zoomed in
|
|
2159
|
+
if (n.kind === "concept" || view.scale > 1.4) {
|
|
2160
|
+
ctx.globalAlpha = faded ? 0.25 : 0.9;
|
|
2161
|
+
ctx.fillStyle = "#e7e9ee";
|
|
2162
|
+
ctx.font = (n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
|
|
2163
|
+
ctx.fillText(n.label, p.x + r + 4, p.y + 3);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
ctx.globalAlpha = 1;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// ---- animation loop with auto-settle ----
|
|
2170
|
+
var settleTicks = 0, running = true;
|
|
2171
|
+
function frame() {
|
|
2172
|
+
var m = step();
|
|
2173
|
+
draw();
|
|
2174
|
+
if (m < 0.05) { settleTicks++; } else { settleTicks = 0; }
|
|
2175
|
+
if (settleTicks > 30 && !dragNode) { running = false; return; } // rest
|
|
2176
|
+
requestAnimationFrame(frame);
|
|
2177
|
+
}
|
|
2178
|
+
function kick() { if (!running) { running = true; settleTicks = 0; requestAnimationFrame(frame); } }
|
|
2179
|
+
|
|
2180
|
+
if (reduceMotion) {
|
|
2181
|
+
for (var s = 0; s < 400; s++) step(); // settle off-screen
|
|
2182
|
+
draw();
|
|
2183
|
+
} else {
|
|
2184
|
+
requestAnimationFrame(frame);
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// ---- hit testing ----
|
|
2188
|
+
function nodeAt(sx, sy) {
|
|
2189
|
+
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
2190
|
+
var n = nodes[i];
|
|
2191
|
+
var p = worldToScreen(n.x, n.y);
|
|
2192
|
+
var r = radiusOf(n) * view.scale + 3;
|
|
2193
|
+
var dx = sx - p.x, dy = sy - p.y;
|
|
2194
|
+
if (dx * dx + dy * dy <= r * r) return n;
|
|
2195
|
+
}
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// ---- pointer: pan, drag node, hover ----
|
|
2200
|
+
var dragNode = null, panning = false, last = { x: 0, y: 0 }, downAt = null, moved = false;
|
|
2201
|
+
|
|
2202
|
+
canvas.addEventListener("mousedown", function (e) {
|
|
2203
|
+
var n = nodeAt(e.clientX, e.clientY);
|
|
2204
|
+
downAt = { x: e.clientX, y: e.clientY }; moved = false;
|
|
2205
|
+
if (n) { dragNode = n; } else { panning = true; }
|
|
2206
|
+
last = { x: e.clientX, y: e.clientY };
|
|
2207
|
+
kick();
|
|
2208
|
+
});
|
|
2209
|
+
window.addEventListener("mousemove", function (e) {
|
|
2210
|
+
if (Math.abs(e.clientX - (downAt ? downAt.x : e.clientX)) + Math.abs(e.clientY - (downAt ? downAt.y : e.clientY)) > 3) moved = true;
|
|
2211
|
+
if (dragNode) {
|
|
2212
|
+
var w = screenToWorld(e.clientX, e.clientY);
|
|
2213
|
+
dragNode.x = w.x; dragNode.y = w.y; dragNode.vx = 0; dragNode.vy = 0;
|
|
2214
|
+
kick();
|
|
2215
|
+
} else if (panning) {
|
|
2216
|
+
view.x += e.clientX - last.x; view.y += e.clientY - last.y;
|
|
2217
|
+
last = { x: e.clientX, y: e.clientY };
|
|
2218
|
+
if (!running) draw();
|
|
2219
|
+
} else {
|
|
2220
|
+
var hov = nodeAt(e.clientX, e.clientY);
|
|
2221
|
+
if (hov) {
|
|
2222
|
+
canvas.style.cursor = "pointer";
|
|
2223
|
+
var when = hov.ts ? new Date(hov.ts).toISOString().slice(0, 10) : "";
|
|
2224
|
+
var meta = hov.kind === "concept"
|
|
2225
|
+
? ("concept · in " + (hov.freq || 0) + " turns")
|
|
2226
|
+
: (esc(hov.session || "") + (when ? " · " + when : ""));
|
|
2227
|
+
tooltip.innerHTML = "<div>" + esc(hov.label) + "</div><div class='t-meta'>" + meta + "</div>";
|
|
2228
|
+
tooltip.style.display = "block";
|
|
2229
|
+
tooltip.style.left = (e.clientX + 14) + "px";
|
|
2230
|
+
tooltip.style.top = (e.clientY + 14) + "px";
|
|
2231
|
+
} else {
|
|
2232
|
+
canvas.style.cursor = "default";
|
|
2233
|
+
tooltip.style.display = "none";
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
window.addEventListener("mouseup", function (e) {
|
|
2238
|
+
if (!moved) {
|
|
2239
|
+
var n = nodeAt(e.clientX, e.clientY);
|
|
2240
|
+
if (n) onNodeClick(n); else clearSelection();
|
|
2241
|
+
}
|
|
2242
|
+
dragNode = null; panning = false; downAt = null;
|
|
2243
|
+
if (!running) draw();
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
// ---- wheel zoom, cursor-anchored ----
|
|
2247
|
+
canvas.addEventListener("wheel", function (e) {
|
|
2248
|
+
e.preventDefault();
|
|
2249
|
+
var before = screenToWorld(e.clientX, e.clientY);
|
|
2250
|
+
var factor = Math.exp(-e.deltaY * 0.0015);
|
|
2251
|
+
view.scale = Math.max(0.15, Math.min(6, view.scale * factor));
|
|
2252
|
+
var after = screenToWorld(e.clientX, e.clientY);
|
|
2253
|
+
view.x += (after.x - before.x) * view.scale;
|
|
2254
|
+
view.y += (after.y - before.y) * view.scale;
|
|
2255
|
+
if (!running) draw();
|
|
2256
|
+
}, { passive: false });
|
|
2257
|
+
|
|
2258
|
+
// ---- clicks ----
|
|
2259
|
+
function neighbors(id) {
|
|
2260
|
+
var set = new Set([id]);
|
|
2261
|
+
for (var i = 0; i < links.length; i++) {
|
|
2262
|
+
if (links[i].s.id === id) set.add(links[i].t.id);
|
|
2263
|
+
if (links[i].t.id === id) set.add(links[i].s.id);
|
|
2264
|
+
}
|
|
2265
|
+
return set;
|
|
2266
|
+
}
|
|
2267
|
+
function onNodeClick(n) {
|
|
2268
|
+
selected = n.id;
|
|
2269
|
+
if (n.kind === "concept") {
|
|
2270
|
+
highlightSet = neighbors(n.id); // light up its connected turns
|
|
2271
|
+
closePanel();
|
|
2272
|
+
} else {
|
|
2273
|
+
highlightSet = null;
|
|
2274
|
+
openPanel(n);
|
|
2275
|
+
}
|
|
2276
|
+
if (!running) draw();
|
|
2277
|
+
}
|
|
2278
|
+
function clearSelection() {
|
|
2279
|
+
selected = null; highlightSet = null; closePanel();
|
|
2280
|
+
if (!running) draw();
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// ---- side panel ----
|
|
2284
|
+
function openPanel(n) {
|
|
2285
|
+
document.getElementById("p-session").textContent = n.session || "";
|
|
2286
|
+
document.getElementById("p-dot").style.background = "hsl(" + sessionHue(n.session) + ",70%,58%)";
|
|
2287
|
+
document.getElementById("p-role").textContent = n.role || "";
|
|
2288
|
+
document.getElementById("p-role").style.color = roleColor(n);
|
|
2289
|
+
document.getElementById("p-content").textContent = n.content || n.label || "";
|
|
2290
|
+
document.getElementById("p-time").textContent = n.ts ? new Date(n.ts).toLocaleString() : "";
|
|
2291
|
+
panel.classList.add("open");
|
|
2292
|
+
}
|
|
2293
|
+
function closePanel() { panel.classList.remove("open"); }
|
|
2294
|
+
document.getElementById("p-close").addEventListener("click", clearSelection);
|
|
2295
|
+
|
|
2296
|
+
// ---- search ----
|
|
2297
|
+
searchBox.addEventListener("input", function () {
|
|
2298
|
+
query = searchBox.value.trim().toLowerCase();
|
|
2299
|
+
if (!running) draw();
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
// ---- keyboard ----
|
|
2303
|
+
window.addEventListener("keydown", function (e) {
|
|
2304
|
+
if (e.key === "Escape") {
|
|
2305
|
+
searchBox.value = ""; query = "";
|
|
2306
|
+
clearSelection();
|
|
2307
|
+
searchBox.blur();
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
|
2312
|
+
})();
|
|
2313
|
+
</script>
|
|
2314
|
+
</body>
|
|
2315
|
+
</html>
|
|
2316
|
+
`;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// src/commands/graph.ts
|
|
2320
|
+
function parseArgs(args) {
|
|
2321
|
+
const opts = { open: true };
|
|
2322
|
+
for (let i = 0; i < args.length; i++) {
|
|
2323
|
+
const a = args[i];
|
|
2324
|
+
if (a === "--no-open") opts.open = false;
|
|
2325
|
+
else if (a === "--out") opts.out = args[++i];
|
|
2326
|
+
else if (a && a.startsWith("--out=")) opts.out = a.slice("--out=".length);
|
|
2327
|
+
}
|
|
2328
|
+
return opts;
|
|
2329
|
+
}
|
|
2330
|
+
function openInBrowser(path) {
|
|
2331
|
+
try {
|
|
2332
|
+
let cmd;
|
|
2333
|
+
let cmdArgs;
|
|
2334
|
+
if (process.platform === "darwin") {
|
|
2335
|
+
cmd = "open";
|
|
2336
|
+
cmdArgs = [path];
|
|
2337
|
+
} else if (process.platform === "win32") {
|
|
2338
|
+
cmd = "cmd";
|
|
2339
|
+
cmdArgs = ["/c", "start", "", path];
|
|
2340
|
+
} else {
|
|
2341
|
+
cmd = "xdg-open";
|
|
2342
|
+
cmdArgs = [path];
|
|
2343
|
+
}
|
|
2344
|
+
const child = spawn3(cmd, cmdArgs, { detached: true, stdio: "ignore" });
|
|
2345
|
+
child.on("error", () => {
|
|
2346
|
+
});
|
|
2347
|
+
child.unref();
|
|
2348
|
+
} catch {
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
async function graph(args = []) {
|
|
2352
|
+
const { ask, close } = createReader();
|
|
2353
|
+
if (!await ensureTrusted(ask)) {
|
|
2354
|
+
close();
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
close();
|
|
2358
|
+
const opts = parseArgs(args);
|
|
2359
|
+
const turns = loadTurns();
|
|
2360
|
+
if (turns.length === 0) {
|
|
2361
|
+
console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const g = buildGraph(turns);
|
|
2365
|
+
const conceptCount = g.nodes.filter((n) => n.kind === "concept").length;
|
|
2366
|
+
const sessions = new Set(turns.map((t) => t.session)).size;
|
|
2367
|
+
const html = renderGraphHtml(g, {
|
|
2368
|
+
workspace: workspace(),
|
|
2369
|
+
turns: turns.length,
|
|
2370
|
+
sessions,
|
|
2371
|
+
concepts: conceptCount,
|
|
2372
|
+
edges: g.edges.length
|
|
2373
|
+
});
|
|
2374
|
+
const outPath = opts.out ? resolve2(opts.out) : join8(wsHoltDir(), "graph.html");
|
|
2375
|
+
mkdirSync5(dirname(outPath), { recursive: true });
|
|
2376
|
+
writeFileSync7(outPath, html, "utf8");
|
|
2377
|
+
console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
|
|
2378
|
+
console.log(` nodes ${g.nodes.length} (${turns.length} turns, ${conceptCount} concepts)`);
|
|
2379
|
+
console.log(` edges ${g.edges.length}`);
|
|
2380
|
+
console.log(` file ${outPath}`);
|
|
2381
|
+
if (opts.open) {
|
|
2382
|
+
openInBrowser(outPath);
|
|
2383
|
+
console.log(c.dim("\n Opening in your browser...\n"));
|
|
2384
|
+
} else {
|
|
2385
|
+
console.log(c.dim("\n Open it in any browser to explore.\n"));
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
711
2389
|
// src/cli.ts
|
|
712
|
-
var VERSION = "0.
|
|
2390
|
+
var VERSION = "0.5.0";
|
|
713
2391
|
var BANNER = `
|
|
714
2392
|
\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
|
|
715
2393
|
\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
|
|
@@ -725,14 +2403,17 @@ Usage: holt <command>
|
|
|
725
2403
|
Commands:
|
|
726
2404
|
init Trust this folder, choose and install brains, sign in, set defaults
|
|
727
2405
|
chat Start a session. It remembers past sessions in this folder
|
|
728
|
-
memory Inspect memory: holt memory [search <query> | clear]
|
|
729
|
-
|
|
2406
|
+
memory Inspect memory: holt memory [search <query> | embed | clear]
|
|
2407
|
+
graph See your memory as an interactive knowledge graph in the browser
|
|
2408
|
+
skill Manage skills: holt skill [list | show | create | add | remove]
|
|
2409
|
+
setting Configure brains, API brains, and your launch command (per folder)
|
|
730
2410
|
login <brain> Sign in to a brain: claude, codex, or gemini
|
|
731
2411
|
version Print the Holt version
|
|
732
2412
|
help Show this help
|
|
733
2413
|
|
|
734
2414
|
Holt runs in the folder you launch it from and asks to trust it first.
|
|
735
|
-
Brains are
|
|
2415
|
+
Brains are agent CLIs on your machine (claude, codex, gemini) or direct
|
|
2416
|
+
API connections you add in settings.
|
|
736
2417
|
|
|
737
2418
|
Docs: https://productsdecoded.com/holt
|
|
738
2419
|
Repo: https://github.com/holt-os/holt
|
|
@@ -767,6 +2448,13 @@ async function main() {
|
|
|
767
2448
|
case "memory":
|
|
768
2449
|
await memoryCmd(process.argv[3], process.argv.slice(4));
|
|
769
2450
|
break;
|
|
2451
|
+
case "skill":
|
|
2452
|
+
case "skills":
|
|
2453
|
+
await skillCmd(process.argv[3], process.argv.slice(4));
|
|
2454
|
+
break;
|
|
2455
|
+
case "graph":
|
|
2456
|
+
await graph(process.argv.slice(3));
|
|
2457
|
+
break;
|
|
770
2458
|
default:
|
|
771
2459
|
console.log(`
|
|
772
2460
|
Unknown command: "${cmd}"`);
|