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