@holt-os/holt 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -9
- package/dist/cli.js +2111 -1127
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -41,11 +41,11 @@ function createReader() {
|
|
|
41
41
|
closed = true;
|
|
42
42
|
while (waiters.length) waiters.shift()(null);
|
|
43
43
|
});
|
|
44
|
-
const ask = (q) => new Promise((
|
|
44
|
+
const ask = (q) => new Promise((resolve8) => {
|
|
45
45
|
if (q) process.stdout.write(q);
|
|
46
|
-
if (buffer.length)
|
|
47
|
-
else if (closed)
|
|
48
|
-
else waiters.push(
|
|
46
|
+
if (buffer.length) resolve8(buffer.shift());
|
|
47
|
+
else if (closed) resolve8(null);
|
|
48
|
+
else waiters.push(resolve8);
|
|
49
49
|
});
|
|
50
50
|
return { ask, close: () => rl.close() };
|
|
51
51
|
}
|
|
@@ -243,12 +243,12 @@ function renderPrompt(history, message, memory = []) {
|
|
|
243
243
|
return parts.join("\n");
|
|
244
244
|
}
|
|
245
245
|
function runBrain(brain, prompt, onChunk) {
|
|
246
|
-
return new Promise((
|
|
246
|
+
return new Promise((resolve8) => {
|
|
247
247
|
let child;
|
|
248
248
|
try {
|
|
249
249
|
child = spawn(brain.command, [...brain.args, prompt], { stdio: ["ignore", "pipe", "pipe"] });
|
|
250
250
|
} catch (e) {
|
|
251
|
-
|
|
251
|
+
resolve8({ ok: false, text: `Could not launch "${brain.command}": ${e.message}` });
|
|
252
252
|
return;
|
|
253
253
|
}
|
|
254
254
|
let out = "";
|
|
@@ -261,11 +261,11 @@ function runBrain(brain, prompt, onChunk) {
|
|
|
261
261
|
child.stderr.on("data", (d) => {
|
|
262
262
|
err2 += d.toString();
|
|
263
263
|
});
|
|
264
|
-
child.on("error", (e) =>
|
|
264
|
+
child.on("error", (e) => resolve8({ ok: false, text: `Could not run "${brain.command}": ${e.message}` }));
|
|
265
265
|
child.on("close", (code) => {
|
|
266
266
|
const text = out.trim();
|
|
267
|
-
if (code === 0 && text)
|
|
268
|
-
else
|
|
267
|
+
if (code === 0 && text) resolve8({ ok: true, text });
|
|
268
|
+
else resolve8({ ok: false, text: err2.trim() || text || `"${brain.command}" exited with code ${code}` });
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
}
|
|
@@ -435,16 +435,16 @@ function currentAlias() {
|
|
|
435
435
|
// src/install.ts
|
|
436
436
|
import { spawn as spawn2 } from "child_process";
|
|
437
437
|
function runInteractive(cmd, args) {
|
|
438
|
-
return new Promise((
|
|
438
|
+
return new Promise((resolve8) => {
|
|
439
439
|
let child;
|
|
440
440
|
try {
|
|
441
441
|
child = spawn2(cmd, args, { stdio: "inherit" });
|
|
442
442
|
} catch {
|
|
443
|
-
|
|
443
|
+
resolve8(-1);
|
|
444
444
|
return;
|
|
445
445
|
}
|
|
446
|
-
child.on("error", () =>
|
|
447
|
-
child.on("close", (code) =>
|
|
446
|
+
child.on("error", () => resolve8(-1));
|
|
447
|
+
child.on("close", (code) => resolve8(code ?? -1));
|
|
448
448
|
});
|
|
449
449
|
}
|
|
450
450
|
|
|
@@ -2224,1086 +2224,1123 @@ async function extractAndSaveFacts(active, cfg, history, session) {
|
|
|
2224
2224
|
}
|
|
2225
2225
|
}
|
|
2226
2226
|
|
|
2227
|
-
// src/
|
|
2228
|
-
import {
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2227
|
+
// src/wiki.ts
|
|
2228
|
+
import {
|
|
2229
|
+
readFileSync as readFileSync9,
|
|
2230
|
+
writeFileSync as writeFileSync7,
|
|
2231
|
+
existsSync as existsSync8,
|
|
2232
|
+
mkdirSync as mkdirSync7,
|
|
2233
|
+
rmSync as rmSync4,
|
|
2234
|
+
readdirSync as readdirSync2,
|
|
2235
|
+
statSync as statSync3
|
|
2236
|
+
} from "fs";
|
|
2237
|
+
import { join as join8 } from "path";
|
|
2238
|
+
import { totalmem } from "os";
|
|
2239
|
+
|
|
2240
|
+
// src/localmodel.ts
|
|
2241
|
+
var OLLAMA_URL2 = process.env.HOLT_OLLAMA_URL || "http://127.0.0.1:11434";
|
|
2242
|
+
async function localModelStatus(model) {
|
|
2243
|
+
try {
|
|
2244
|
+
const res = await fetch(`${OLLAMA_URL2}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
2245
|
+
if (!res.ok) return { reachable: false, hasModel: false };
|
|
2246
|
+
const data = await res.json();
|
|
2247
|
+
const want = model.includes(":") ? model : `${model}:latest`;
|
|
2248
|
+
const hasModel = !!data.models?.some((m) => {
|
|
2249
|
+
const n = m.name || "";
|
|
2250
|
+
return n === model || n === want || n.startsWith(`${model}:`);
|
|
2251
|
+
});
|
|
2252
|
+
return { reachable: true, hasModel };
|
|
2253
|
+
} catch {
|
|
2254
|
+
return { reachable: false, hasModel: false };
|
|
2255
|
+
}
|
|
2252
2256
|
}
|
|
2253
|
-
function
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
}
|
|
2270
|
-
};
|
|
2271
|
-
while (i < lines.length) {
|
|
2272
|
-
const line = lines[i] ?? "";
|
|
2273
|
-
const fence = line.match(/^```(.*)$/);
|
|
2274
|
-
if (fence) {
|
|
2275
|
-
flushParagraph();
|
|
2276
|
-
closeList();
|
|
2277
|
-
const code = [];
|
|
2278
|
-
i++;
|
|
2279
|
-
while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
|
|
2280
|
-
code.push(lines[i] ?? "");
|
|
2281
|
-
i++;
|
|
2282
|
-
}
|
|
2283
|
-
i++;
|
|
2284
|
-
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
2285
|
-
continue;
|
|
2286
|
-
}
|
|
2287
|
-
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
2288
|
-
if (h) {
|
|
2289
|
-
flushParagraph();
|
|
2290
|
-
closeList();
|
|
2291
|
-
const level = (h[1] ?? "#").length;
|
|
2292
|
-
out.push(`<h${level}>${renderInline((h[2] ?? "").trim())}</h${level}>`);
|
|
2293
|
-
i++;
|
|
2294
|
-
continue;
|
|
2295
|
-
}
|
|
2296
|
-
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
2297
|
-
if (li) {
|
|
2298
|
-
flushParagraph();
|
|
2299
|
-
if (!listOpen) {
|
|
2300
|
-
out.push("<ul>");
|
|
2301
|
-
listOpen = true;
|
|
2302
|
-
}
|
|
2303
|
-
out.push(`<li>${renderInline((li[1] ?? "").trim())}</li>`);
|
|
2304
|
-
i++;
|
|
2305
|
-
continue;
|
|
2306
|
-
}
|
|
2307
|
-
if (line.trim() === "") {
|
|
2308
|
-
flushParagraph();
|
|
2309
|
-
closeList();
|
|
2310
|
-
i++;
|
|
2311
|
-
continue;
|
|
2257
|
+
async function localGenerate(model, prompt, timeoutMs = 12e4) {
|
|
2258
|
+
let res;
|
|
2259
|
+
try {
|
|
2260
|
+
res = await fetch(`${OLLAMA_URL2}/api/generate`, {
|
|
2261
|
+
method: "POST",
|
|
2262
|
+
headers: { "content-type": "application/json" },
|
|
2263
|
+
body: JSON.stringify({ model, prompt, stream: false }),
|
|
2264
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2265
|
+
});
|
|
2266
|
+
} catch (e) {
|
|
2267
|
+
return { ok: false, text: `Could not reach local Ollama at ${OLLAMA_URL2}: ${e.message}` };
|
|
2268
|
+
}
|
|
2269
|
+
if (!res.ok) {
|
|
2270
|
+
let body = "";
|
|
2271
|
+
try {
|
|
2272
|
+
body = await res.text();
|
|
2273
|
+
} catch {
|
|
2312
2274
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2275
|
+
return { ok: false, text: `Ollama error ${res.status}: ${body.slice(0, 200)}` };
|
|
2276
|
+
}
|
|
2277
|
+
try {
|
|
2278
|
+
const data = await res.json();
|
|
2279
|
+
const text = (data.response || "").trim();
|
|
2280
|
+
if (text) return { ok: true, text };
|
|
2281
|
+
return { ok: false, text: "Local model returned an empty reply." };
|
|
2282
|
+
} catch (e) {
|
|
2283
|
+
return { ok: false, text: `Could not parse Ollama reply: ${e.message}` };
|
|
2316
2284
|
}
|
|
2317
|
-
flushParagraph();
|
|
2318
|
-
closeList();
|
|
2319
|
-
return out.join("\n");
|
|
2320
2285
|
}
|
|
2321
|
-
function
|
|
2322
|
-
return
|
|
2323
|
-
<html lang="en">
|
|
2324
|
-
<head>
|
|
2325
|
-
<meta charset="utf-8">
|
|
2326
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2327
|
-
<title>${escapeHtml(title)}</title>
|
|
2328
|
-
<style>
|
|
2329
|
-
:root { --bg:#0f1115; --fg:#e7e9ee; --accent:#f0b91e; }
|
|
2330
|
-
* { box-sizing: border-box; }
|
|
2331
|
-
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
2332
|
-
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
|
|
2333
|
-
main { max-width: 760px; margin: 0 auto; }
|
|
2334
|
-
h1,h2,h3 { color:var(--accent); line-height:1.25; margin:1.4em 0 .5em; }
|
|
2335
|
-
h1 { font-size:1.6rem; } h2 { font-size:1.3rem; } h3 { font-size:1.1rem; }
|
|
2336
|
-
a { color:var(--accent); }
|
|
2337
|
-
code { background:#1b1e26; padding:.1em .35em; border-radius:4px; font-size:.9em; }
|
|
2338
|
-
pre { background:#1b1e26; padding:1rem; border-radius:8px; overflow:auto; }
|
|
2339
|
-
pre code { background:none; padding:0; }
|
|
2340
|
-
ul { padding-left:1.3rem; }
|
|
2341
|
-
p { margin:.7em 0; }
|
|
2342
|
-
</style>
|
|
2343
|
-
</head>
|
|
2344
|
-
<body>
|
|
2345
|
-
<main>
|
|
2346
|
-
${body}
|
|
2347
|
-
</main>
|
|
2348
|
-
</body>
|
|
2349
|
-
</html>
|
|
2350
|
-
`;
|
|
2286
|
+
function pullHint(model) {
|
|
2287
|
+
return `ollama pull ${model}`;
|
|
2351
2288
|
}
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2289
|
+
|
|
2290
|
+
// src/wiki.ts
|
|
2291
|
+
function wikiDir() {
|
|
2292
|
+
return join8(wsHoltDir(), "wiki");
|
|
2355
2293
|
}
|
|
2356
|
-
function
|
|
2357
|
-
|
|
2358
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
2359
|
-
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
2294
|
+
function wikiStatePath() {
|
|
2295
|
+
return join8(wikiDir(), ".state.json");
|
|
2360
2296
|
}
|
|
2361
|
-
function
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2297
|
+
function wikiIndexPath() {
|
|
2298
|
+
return join8(wikiDir(), "index.md");
|
|
2299
|
+
}
|
|
2300
|
+
function pagePath(slug) {
|
|
2301
|
+
return join8(wikiDir(), `${slug}.md`);
|
|
2302
|
+
}
|
|
2303
|
+
function loadState() {
|
|
2304
|
+
try {
|
|
2305
|
+
const s = JSON.parse(readFileSync9(wikiStatePath(), "utf8"));
|
|
2306
|
+
return { lastSyncTs: typeof s.lastSyncTs === "number" ? s.lastSyncTs : 0, updatedAt: s.updatedAt ?? 0 };
|
|
2307
|
+
} catch {
|
|
2308
|
+
return { lastSyncTs: 0, updatedAt: 0 };
|
|
2367
2309
|
}
|
|
2368
|
-
if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
|
|
2369
|
-
const path = join8(process.cwd(), base);
|
|
2370
|
-
writeFileSync7(path, renderReply(reply, format), "utf8");
|
|
2371
|
-
return path;
|
|
2372
2310
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
const live = Math.min(history.length, MAX_REPLAY_TURNS);
|
|
2377
|
-
const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
|
|
2378
|
-
const pct = Math.round(frac * 100);
|
|
2379
|
-
const mem = memStats().turns;
|
|
2380
|
-
return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
|
|
2311
|
+
function saveState(s) {
|
|
2312
|
+
mkdirSync7(wikiDir(), { recursive: true });
|
|
2313
|
+
writeFileSync7(wikiStatePath(), JSON.stringify(s, null, 2) + "\n", "utf8");
|
|
2381
2314
|
}
|
|
2382
|
-
function
|
|
2383
|
-
|
|
2384
|
-
" commands:",
|
|
2385
|
-
" /brain [name] switch brain (CLI or API). context is kept.",
|
|
2386
|
-
" /memory [query] memory stats, or preview what a query would recall",
|
|
2387
|
-
' /skill [name] [input] run a skill, or list them. "holt skill" manages them.',
|
|
2388
|
-
" /output [fmt] show or set output format: markdown | html",
|
|
2389
|
-
" /save [name] save the last reply to a file in this folder",
|
|
2390
|
-
" /setting configure brains, API brains, and your launch command",
|
|
2391
|
-
" /clear forget this session so far (saved memory stays)",
|
|
2392
|
-
" /help this list",
|
|
2393
|
-
" /exit leave"
|
|
2394
|
-
].join("\n")));
|
|
2315
|
+
function saveSyncMarker(ts) {
|
|
2316
|
+
saveState({ lastSyncTs: ts, updatedAt: Date.now() });
|
|
2395
2317
|
}
|
|
2396
|
-
function
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2318
|
+
function isPageFile(name) {
|
|
2319
|
+
return name.endsWith(".md") && name !== "index.md";
|
|
2320
|
+
}
|
|
2321
|
+
function listPageSlugs() {
|
|
2322
|
+
const dir = wikiDir();
|
|
2323
|
+
if (!existsSync8(dir)) return [];
|
|
2324
|
+
try {
|
|
2325
|
+
return readdirSync2(dir).filter(isPageFile).map((f) => f.slice(0, -3)).sort();
|
|
2326
|
+
} catch {
|
|
2327
|
+
return [];
|
|
2400
2328
|
}
|
|
2401
|
-
const api = findApiBrain(cfg, id);
|
|
2402
|
-
if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
|
|
2403
|
-
return null;
|
|
2404
2329
|
}
|
|
2405
|
-
|
|
2406
|
-
const
|
|
2407
|
-
if (!
|
|
2408
|
-
|
|
2409
|
-
|
|
2330
|
+
function readPage(slug) {
|
|
2331
|
+
const p = pagePath(slug);
|
|
2332
|
+
if (!existsSync8(p)) return null;
|
|
2333
|
+
let raw = "";
|
|
2334
|
+
try {
|
|
2335
|
+
raw = readFileSync9(p, "utf8");
|
|
2336
|
+
} catch {
|
|
2337
|
+
return null;
|
|
2410
2338
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
|
|
2426
|
-
active = fallback ? resolveActive2(cfg, fallback) : null;
|
|
2427
|
-
if (!active) {
|
|
2428
|
-
close();
|
|
2429
|
-
console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
|
|
2430
|
-
return;
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
const session = newSessionId();
|
|
2434
|
-
const history = [];
|
|
2435
|
-
let lastReply = "";
|
|
2436
|
-
const embedOk = await embeddingsAvailable();
|
|
2437
|
-
const stats = memStats();
|
|
2438
|
-
console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
|
|
2439
|
-
console.log(c.dim(
|
|
2440
|
-
`Memory: ${stats.turns} moments (${stats.facts} facts) from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
|
|
2441
|
-
));
|
|
2442
|
-
if (embedOk && stats.withEmbeddings < stats.turns) {
|
|
2443
|
-
console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
|
|
2444
|
-
}
|
|
2445
|
-
console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit"));
|
|
2446
|
-
console.log(statusLine(active.label, history) + "\n");
|
|
2447
|
-
while (true) {
|
|
2448
|
-
const raw = await ask(c.accent("\u203A "));
|
|
2449
|
-
if (raw === null) break;
|
|
2450
|
-
const line = raw.trim();
|
|
2451
|
-
if (!line) continue;
|
|
2452
|
-
let promptOverride = null;
|
|
2453
|
-
if (/^\/skills?\s+\S/.test(line)) {
|
|
2454
|
-
const inv = resolveSkillInvocation(line.replace(/^\/skills\b/, "/skill"));
|
|
2455
|
-
if (!inv) {
|
|
2456
|
-
console.log(c.dim(' no such skill. Try "holt skill list".'));
|
|
2457
|
-
continue;
|
|
2458
|
-
}
|
|
2459
|
-
promptOverride = inv.prompt;
|
|
2460
|
-
console.log(c.dim(` running skill "${inv.skillName}"...`));
|
|
2461
|
-
} else if (line.startsWith("/")) {
|
|
2462
|
-
const parts = line.slice(1).split(/\s+/);
|
|
2463
|
-
const cmd = (parts[0] || "").toLowerCase();
|
|
2464
|
-
const rest = parts.slice(1).join(" ");
|
|
2465
|
-
const arg = (parts[1] || "").toLowerCase();
|
|
2466
|
-
if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
|
|
2467
|
-
if (cmd === "help" || cmd === "h") {
|
|
2468
|
-
help();
|
|
2469
|
-
continue;
|
|
2470
|
-
}
|
|
2471
|
-
if (cmd === "clear") {
|
|
2472
|
-
history.length = 0;
|
|
2473
|
-
console.log(c.dim(" session context cleared. Saved memory is untouched."));
|
|
2474
|
-
continue;
|
|
2475
|
-
}
|
|
2476
|
-
if (cmd === "memory" || cmd === "mem") {
|
|
2477
|
-
if (rest) {
|
|
2478
|
-
const hits = await recall(rest, session, 5);
|
|
2479
|
-
if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
|
|
2480
|
-
else for (const h of hits) console.log(c.dim(` ${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.slice(0, 110).replace(/\s+/g, " ")}`));
|
|
2481
|
-
} else {
|
|
2482
|
-
const s = memStats();
|
|
2483
|
-
console.log(c.dim(` ${s.turns} moments, ${s.facts} facts, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
|
|
2484
|
-
console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
|
|
2485
|
-
}
|
|
2486
|
-
continue;
|
|
2487
|
-
}
|
|
2488
|
-
if (cmd === "output") {
|
|
2489
|
-
const want = arg;
|
|
2490
|
-
if (want === "markdown" || want === "md") {
|
|
2491
|
-
cfg.outputFormat = "markdown";
|
|
2492
|
-
saveConfig(cfg);
|
|
2493
|
-
console.log(c.green(" output format: markdown"));
|
|
2494
|
-
} else if (want === "html") {
|
|
2495
|
-
cfg.outputFormat = "html";
|
|
2496
|
-
saveConfig(cfg);
|
|
2497
|
-
console.log(c.green(" output format: html"));
|
|
2498
|
-
} else if (want) console.log(c.dim(" usage: /output markdown | html"));
|
|
2499
|
-
else console.log(c.dim(` output format: ${cfg.outputFormat} (change with /output markdown | html)`));
|
|
2500
|
-
continue;
|
|
2501
|
-
}
|
|
2502
|
-
if (cmd === "save") {
|
|
2503
|
-
if (!lastReply) {
|
|
2504
|
-
console.log(c.dim(" nothing to save yet. Ask something first."));
|
|
2505
|
-
continue;
|
|
2506
|
-
}
|
|
2507
|
-
try {
|
|
2508
|
-
const path = saveReply(lastReply, cfg.outputFormat, rest || void 0);
|
|
2509
|
-
console.log(c.green(` saved ${cfg.outputFormat} to ${path}`));
|
|
2510
|
-
} catch (e) {
|
|
2511
|
-
console.log(c.red(` could not save: ${e.message}`));
|
|
2512
|
-
}
|
|
2513
|
-
continue;
|
|
2514
|
-
}
|
|
2515
|
-
if (cmd === "setting" || cmd === "settings") {
|
|
2516
|
-
cfg = await runSettings(ask);
|
|
2517
|
-
const next = cfg.defaultBrain ? resolveActive2(cfg, cfg.defaultBrain) : null;
|
|
2518
|
-
const stillValid = resolveActive2(cfg, active.id);
|
|
2519
|
-
active = stillValid ?? next ?? active;
|
|
2520
|
-
console.log(c.dim(` brain: ${active.label}`));
|
|
2521
|
-
continue;
|
|
2522
|
-
}
|
|
2523
|
-
if (cmd === "brain") {
|
|
2524
|
-
const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
|
|
2525
|
-
const apiIds = cfg.apiBrains.map((a) => a.id);
|
|
2526
|
-
const all = [...cliEnabled, ...apiIds];
|
|
2527
|
-
const rawArg = parts[1] || "";
|
|
2528
|
-
const match = all.find((id) => id.toLowerCase() === rawArg.toLowerCase());
|
|
2529
|
-
if (rawArg && match) {
|
|
2530
|
-
const next = resolveActive2(cfg, match);
|
|
2531
|
-
if (next) {
|
|
2532
|
-
active = next;
|
|
2533
|
-
const turns = Math.floor(history.length / 2);
|
|
2534
|
-
console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
|
|
2535
|
-
}
|
|
2536
|
-
} else if (rawArg) {
|
|
2537
|
-
console.log(c.dim(` "${rawArg}" is not available. Available: ${all.join(", ") || "none"}`));
|
|
2538
|
-
} else {
|
|
2539
|
-
const cfgRef = cfg;
|
|
2540
|
-
const activeId = active.id;
|
|
2541
|
-
const labels = all.map((id) => {
|
|
2542
|
-
const a = findApiBrain(cfgRef, id);
|
|
2543
|
-
const shown = a ? `${id} (api: ${a.provider}/${a.model})` : id;
|
|
2544
|
-
return id === activeId ? c.accent(shown + " (current)") : shown;
|
|
2545
|
-
});
|
|
2546
|
-
console.log(c.dim(" brains: " + labels.join(" ")));
|
|
2547
|
-
console.log(c.dim(" usage: /brain <name>"));
|
|
2548
|
-
}
|
|
2549
|
-
continue;
|
|
2550
|
-
}
|
|
2551
|
-
if (cmd === "skill" || cmd === "skills") {
|
|
2552
|
-
const names = listSkills().map((s) => s.name);
|
|
2553
|
-
console.log(c.dim(" skills: " + (names.join(" ") || "none") + " usage: /skill <name> [input]"));
|
|
2554
|
-
continue;
|
|
2555
|
-
}
|
|
2556
|
-
console.log(c.dim(` unknown command: /${cmd} (try /help)`));
|
|
2557
|
-
continue;
|
|
2558
|
-
}
|
|
2559
|
-
if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
|
|
2560
|
-
console.log(c.red(` ${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Use /brain to switch or /setting.`));
|
|
2561
|
-
continue;
|
|
2562
|
-
}
|
|
2563
|
-
if (active.kind === "api" && !resolveApiKey(active.brain)) {
|
|
2564
|
-
console.log(c.red(` ${active.label} has no API key. Use /setting to add one, or /brain to switch.`));
|
|
2565
|
-
continue;
|
|
2566
|
-
}
|
|
2567
|
-
const remembered = await recall(line, session, 4);
|
|
2568
|
-
const label = remembered.length ? `${active.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${active.label} is thinking...`;
|
|
2569
|
-
console.log(c.dim(` ${label}`) + "\n");
|
|
2570
|
-
const skillsBlock = skillsPromptBlock();
|
|
2571
|
-
const base = renderPrompt(history, line, remembered);
|
|
2572
|
-
const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
|
|
2573
|
-
let streamed = false;
|
|
2574
|
-
const onChunk = (chunk2) => {
|
|
2575
|
-
streamed = true;
|
|
2576
|
-
process.stdout.write(chunk2);
|
|
2577
|
-
};
|
|
2578
|
-
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
|
|
2579
|
-
if (res.ok) {
|
|
2580
|
-
if (!streamed) console.log(res.text);
|
|
2581
|
-
if (!res.text.endsWith("\n")) console.log("");
|
|
2582
|
-
console.log("");
|
|
2583
|
-
lastReply = res.text;
|
|
2584
|
-
history.push({ role: "user", content: line });
|
|
2585
|
-
history.push({ role: "assistant", content: res.text });
|
|
2586
|
-
const now = Date.now();
|
|
2587
|
-
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
|
|
2588
|
-
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
|
|
2589
|
-
console.log(statusLine(active.label, history) + "\n");
|
|
2590
|
-
} else {
|
|
2591
|
-
console.log(c.red("\n " + res.text + "\n"));
|
|
2592
|
-
}
|
|
2339
|
+
const { data, body } = parseFrontmatter(raw);
|
|
2340
|
+
return {
|
|
2341
|
+
slug,
|
|
2342
|
+
title: data.title || slug,
|
|
2343
|
+
updated: data.updated || "",
|
|
2344
|
+
sources: parseSources(data.sources),
|
|
2345
|
+
body
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
function loadPages() {
|
|
2349
|
+
const out = [];
|
|
2350
|
+
for (const slug of listPageSlugs()) {
|
|
2351
|
+
const pg = readPage(slug);
|
|
2352
|
+
if (pg) out.push(pg);
|
|
2593
2353
|
}
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2354
|
+
return out;
|
|
2355
|
+
}
|
|
2356
|
+
function parseSources(raw) {
|
|
2357
|
+
if (!raw) return [];
|
|
2358
|
+
return raw.replace(/^\[|\]$/g, "").split(/[\s,]+/).map((s) => s.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
2359
|
+
}
|
|
2360
|
+
var PAGE_HEADER_NOTE = "Derived page, maintained by Holt. Safe to edit; a rebuild will regenerate it from facts.";
|
|
2361
|
+
function renderPage(pg) {
|
|
2362
|
+
const fm = [
|
|
2363
|
+
"---",
|
|
2364
|
+
`title: ${pg.title}`,
|
|
2365
|
+
`updated: ${pg.updated}`,
|
|
2366
|
+
`sources: ${pg.sources.join(", ")}`,
|
|
2367
|
+
"---",
|
|
2368
|
+
"",
|
|
2369
|
+
`> ${PAGE_HEADER_NOTE}`,
|
|
2370
|
+
"",
|
|
2371
|
+
pg.body.trim(),
|
|
2372
|
+
""
|
|
2373
|
+
].join("\n");
|
|
2374
|
+
return fm;
|
|
2375
|
+
}
|
|
2376
|
+
function writePage(pg) {
|
|
2377
|
+
mkdirSync7(wikiDir(), { recursive: true });
|
|
2378
|
+
writeFileSync7(pagePath(pg.slug), renderPage(pg), "utf8");
|
|
2379
|
+
}
|
|
2380
|
+
var WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
2381
|
+
function extractLinks(body) {
|
|
2382
|
+
const out = [];
|
|
2383
|
+
let m;
|
|
2384
|
+
WIKILINK_RE.lastIndex = 0;
|
|
2385
|
+
while ((m = WIKILINK_RE.exec(body)) !== null) {
|
|
2386
|
+
const target = (m[1] || "").split("|")[0].trim();
|
|
2387
|
+
if (target) out.push(target);
|
|
2601
2388
|
}
|
|
2602
|
-
|
|
2603
|
-
console.log(c.dim("\nBye.\n"));
|
|
2389
|
+
return out;
|
|
2604
2390
|
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
console.log(c.dim(`
|
|
2617
|
-
Usage: holt login <${BRAIN_IDS.join("|")}>
|
|
2618
|
-
`));
|
|
2619
|
-
}
|
|
2620
|
-
return;
|
|
2391
|
+
function cosine2(a, b) {
|
|
2392
|
+
let dot = 0;
|
|
2393
|
+
let na = 0;
|
|
2394
|
+
let nb = 0;
|
|
2395
|
+
const n = Math.min(a.length, b.length);
|
|
2396
|
+
for (let i = 0; i < n; i++) {
|
|
2397
|
+
const x = a[i];
|
|
2398
|
+
const y = b[i];
|
|
2399
|
+
dot += x * y;
|
|
2400
|
+
na += x * x;
|
|
2401
|
+
nb += y * y;
|
|
2621
2402
|
}
|
|
2622
|
-
|
|
2623
|
-
console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
|
|
2624
|
-
console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool.
|
|
2625
|
-
`));
|
|
2626
|
-
await runInteractive(s.login[0], s.login.slice(1));
|
|
2403
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
2627
2404
|
}
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
close();
|
|
2636
|
-
return;
|
|
2405
|
+
var ROUTE_SIMILARITY_THRESHOLD = 0.55;
|
|
2406
|
+
async function pageEmbeddings(pages) {
|
|
2407
|
+
const out = [];
|
|
2408
|
+
for (const page of pages) {
|
|
2409
|
+
const emb = await embed(`${page.title}
|
|
2410
|
+
${page.body.slice(0, 1500)}`);
|
|
2411
|
+
if (emb) out.push({ page, emb });
|
|
2637
2412
|
}
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
return;
|
|
2645
|
-
}
|
|
2646
|
-
const a = (await ask(`
|
|
2647
|
-
Delete all ${s2.turns} remembered moments in this folder? [y/N] `) ?? "").trim().toLowerCase();
|
|
2648
|
-
if (a === "y" || a === "yes") {
|
|
2649
|
-
clearMemory();
|
|
2650
|
-
console.log(c.green(" Memory cleared.\n"));
|
|
2651
|
-
} else console.log(c.dim(" Kept.\n"));
|
|
2652
|
-
close();
|
|
2653
|
-
return;
|
|
2413
|
+
return out;
|
|
2414
|
+
}
|
|
2415
|
+
async function routeFact(content, targets) {
|
|
2416
|
+
const e = await embed(content);
|
|
2417
|
+
if (!e) {
|
|
2418
|
+
return { kind: targets.length ? "existing" : "new", slug: targets[0]?.page.slug ?? "notes", score: 0 };
|
|
2654
2419
|
}
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
}
|
|
2663
|
-
const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
|
|
2664
|
-
if (missing === 0) {
|
|
2665
|
-
console.log(c.dim("\n All memories already have embeddings.\n"));
|
|
2666
|
-
close();
|
|
2667
|
-
return;
|
|
2420
|
+
let best = null;
|
|
2421
|
+
let bestScore = -1;
|
|
2422
|
+
for (const t of targets) {
|
|
2423
|
+
const s = cosine2(e, t.emb);
|
|
2424
|
+
if (s > bestScore) {
|
|
2425
|
+
bestScore = s;
|
|
2426
|
+
best = t;
|
|
2668
2427
|
}
|
|
2669
|
-
console.log("");
|
|
2670
|
-
const r = await backfillEmbeddings((done, total) => {
|
|
2671
|
-
process.stdout.write(`\r embedding ${done}/${total}...`);
|
|
2672
|
-
});
|
|
2673
|
-
console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} memories embedded.`) + "\n");
|
|
2674
|
-
close();
|
|
2675
|
-
return;
|
|
2676
2428
|
}
|
|
2677
|
-
if (
|
|
2678
|
-
|
|
2679
|
-
console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
|
|
2680
|
-
close();
|
|
2681
|
-
return;
|
|
2682
|
-
}
|
|
2683
|
-
const body = readFileSync9(factsMdPath(), "utf8").trim();
|
|
2684
|
-
console.log("\n" + body + "\n");
|
|
2685
|
-
close();
|
|
2686
|
-
return;
|
|
2429
|
+
if (best && bestScore >= ROUTE_SIMILARITY_THRESHOLD) {
|
|
2430
|
+
return { kind: "existing", slug: best.page.slug, score: bestScore };
|
|
2687
2431
|
}
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2432
|
+
return { kind: "new", slug: proposeSlug(content), score: bestScore < 0 ? 0 : bestScore };
|
|
2433
|
+
}
|
|
2434
|
+
function proposeSlug(content) {
|
|
2435
|
+
const words2 = content.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !ROUTE_STOPWORDS.has(w)).slice(0, 4);
|
|
2436
|
+
const slug = sanitizeName(words2.join("-"));
|
|
2437
|
+
return slug || "notes";
|
|
2438
|
+
}
|
|
2439
|
+
var ROUTE_STOPWORDS = /* @__PURE__ */ new Set([
|
|
2440
|
+
"the",
|
|
2441
|
+
"and",
|
|
2442
|
+
"that",
|
|
2443
|
+
"this",
|
|
2444
|
+
"with",
|
|
2445
|
+
"from",
|
|
2446
|
+
"for",
|
|
2447
|
+
"was",
|
|
2448
|
+
"are",
|
|
2449
|
+
"has",
|
|
2450
|
+
"have",
|
|
2451
|
+
"will",
|
|
2452
|
+
"user",
|
|
2453
|
+
"their",
|
|
2454
|
+
"they",
|
|
2455
|
+
"about",
|
|
2456
|
+
"into",
|
|
2457
|
+
"per",
|
|
2458
|
+
"via"
|
|
2459
|
+
]);
|
|
2460
|
+
async function resolveMaintainer(cfg, resolveBrain) {
|
|
2461
|
+
if (cfg.wiki.maintainer === "local") {
|
|
2462
|
+
const model = cfg.wiki.localModel;
|
|
2463
|
+
const status2 = await localModelStatus(model);
|
|
2464
|
+
if (status2.reachable && status2.hasModel) {
|
|
2465
|
+
const m = { kind: "local", model, label: `local (${model})` };
|
|
2466
|
+
return { maintainer: m, report: { maintainer: m, fellBack: false } };
|
|
2694
2467
|
}
|
|
2695
|
-
const
|
|
2696
|
-
|
|
2697
|
-
if (
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role})${src} ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`
|
|
2703
|
-
);
|
|
2468
|
+
const why = status2.reachable ? `local model "${model}" is not pulled. Run: ${pullHint(model)}` : "local Ollama is not reachable.";
|
|
2469
|
+
const brain2 = resolveBrain();
|
|
2470
|
+
if (brain2) {
|
|
2471
|
+
return {
|
|
2472
|
+
maintainer: brain2,
|
|
2473
|
+
report: { maintainer: brain2, fellBack: true, note: `${why} Falling back to ${brain2.label}.` }
|
|
2474
|
+
};
|
|
2704
2475
|
}
|
|
2705
|
-
|
|
2706
|
-
close();
|
|
2707
|
-
return;
|
|
2476
|
+
return { maintainer: null, report: null };
|
|
2708
2477
|
}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2478
|
+
const brain = resolveBrain();
|
|
2479
|
+
if (!brain) return { maintainer: null, report: null };
|
|
2480
|
+
return { maintainer: brain, report: { maintainer: brain, fellBack: false } };
|
|
2481
|
+
}
|
|
2482
|
+
function resolveBrainMaintainer(cfg) {
|
|
2483
|
+
const id = cfg.defaultBrain;
|
|
2484
|
+
if (!id) return null;
|
|
2485
|
+
if (BRAIN_IDS.includes(id)) {
|
|
2486
|
+
const b = cfg.brains[id];
|
|
2487
|
+
if (!isInstalled(b.command)) return null;
|
|
2488
|
+
return { kind: "cli", id, label: b.label };
|
|
2713
2489
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2490
|
+
const api = findApiBrain(cfg, id);
|
|
2491
|
+
if (api && resolveApiKey(api)) return { kind: "api", brain: api, label: `${id} (api: ${api.provider}/${api.model})` };
|
|
2492
|
+
return null;
|
|
2493
|
+
}
|
|
2494
|
+
async function runMaintainer(m, cfg, prompt) {
|
|
2495
|
+
if (m.kind === "cli") return runBrain(cfg.brains[m.id], prompt);
|
|
2496
|
+
if (m.kind === "api") return runApiBrain(m.brain, prompt);
|
|
2497
|
+
const r = await localGenerate(m.model, prompt);
|
|
2498
|
+
return { ok: r.ok, text: r.text };
|
|
2499
|
+
}
|
|
2500
|
+
function buildPagePrompt(title, existingBody, facts, otherTitles) {
|
|
2501
|
+
const lines = [
|
|
2502
|
+
`You maintain a personal knowledge wiki. Write or update ONE page titled "${title}".`,
|
|
2503
|
+
"Output ONLY the page body in Markdown. No frontmatter, no code fences, no preamble.",
|
|
2504
|
+
"",
|
|
2505
|
+
"Rules:",
|
|
2506
|
+
"- Synthesize the facts into concise, self-contained prose (a few short paragraphs or tight bullets).",
|
|
2507
|
+
"- Merge new facts into the existing text; do not just append. Resolve overlaps. Keep it current.",
|
|
2508
|
+
"- Do not invent anything beyond the facts given.",
|
|
2509
|
+
'- End with a line "## Related" followed by [[Wiki Page]] links to other pages that connect.',
|
|
2510
|
+
"- Use [[double-bracket]] links for any other page you reference."
|
|
2511
|
+
];
|
|
2512
|
+
if (otherTitles.length) {
|
|
2513
|
+
lines.push("", "Other existing pages you may link to:", ...otherTitles.map((t) => `- ${t}`));
|
|
2721
2514
|
}
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
const sessions = new Set(loadTurns().map((t) => t.session)).size;
|
|
2725
|
-
console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
|
|
2726
|
-
console.log(` moments ${s.turns}`);
|
|
2727
|
-
console.log(` facts ${s.facts} (./.holt/memory/facts.md)`);
|
|
2728
|
-
console.log(` sessions ${sessions}`);
|
|
2729
|
-
console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
|
|
2730
|
-
console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
|
|
2731
|
-
console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
|
|
2732
|
-
if (embedOk && s.withEmbeddings < s.turns) {
|
|
2733
|
-
console.log(c.dim(`
|
|
2734
|
-
${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
|
|
2515
|
+
if (existingBody.trim()) {
|
|
2516
|
+
lines.push("", "Current page body:", existingBody.trim());
|
|
2735
2517
|
}
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
console.log(c.dim("\n holt memory search <query> find remembered moments"));
|
|
2739
|
-
console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
|
|
2740
|
-
console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
|
|
2741
|
-
console.log(c.dim(" holt memory global share high-value facts across your folders"));
|
|
2742
|
-
console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
|
|
2743
|
-
close();
|
|
2518
|
+
lines.push("", "New facts to integrate:", ...facts.map((f) => `- ${f}`), "", "Updated page body:");
|
|
2519
|
+
return lines.join("\n");
|
|
2744
2520
|
}
|
|
2745
|
-
function
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2521
|
+
function emptySummary() {
|
|
2522
|
+
return { created: 0, updated: 0, factsIntegrated: 0 };
|
|
2523
|
+
}
|
|
2524
|
+
function dedupe(a) {
|
|
2525
|
+
return [...new Set(a)];
|
|
2526
|
+
}
|
|
2527
|
+
function deriveTitle(content, slug) {
|
|
2528
|
+
const fromSlug = slug.split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
2529
|
+
return fromSlug || content.slice(0, 40);
|
|
2530
|
+
}
|
|
2531
|
+
function stripFrontmatterEcho(text) {
|
|
2532
|
+
let s = text.trim();
|
|
2533
|
+
s = s.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
2534
|
+
if (s.startsWith("---")) {
|
|
2535
|
+
const end = s.indexOf("\n---", 3);
|
|
2536
|
+
if (end >= 0) s = s.slice(end + 4).trim();
|
|
2757
2537
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2538
|
+
return s;
|
|
2539
|
+
}
|
|
2540
|
+
async function foldFacts(cfg, maintainer, facts) {
|
|
2541
|
+
const summary = emptySummary();
|
|
2542
|
+
if (facts.length === 0) return { summary, changed: [] };
|
|
2543
|
+
let pages = loadPages();
|
|
2544
|
+
const targets = await pageEmbeddings(pages);
|
|
2545
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2546
|
+
for (const f of facts) {
|
|
2547
|
+
const decision = await routeFact(f.content, targets);
|
|
2548
|
+
const slug = decision.slug;
|
|
2549
|
+
let isNew = decision.kind === "new";
|
|
2550
|
+
const existingGroup = groups.get(slug);
|
|
2551
|
+
if (existingGroup) {
|
|
2552
|
+
existingGroup.facts.push(f);
|
|
2553
|
+
} else {
|
|
2554
|
+
if (isNew && listPageSlugs().includes(slug)) isNew = false;
|
|
2555
|
+
groups.set(slug, { slug, isNew, facts: [f] });
|
|
2763
2556
|
}
|
|
2764
|
-
const { purged } = disableGlobal(ws, purge);
|
|
2765
|
-
console.log("\n" + c.green(" Global memory OFF for this folder.") + c.dim(" (recall is local-only again)"));
|
|
2766
|
-
if (purge) console.log(c.dim(` Purged ${purged} of this folder's row${purged === 1 ? "" : "s"} from the shared store.`));
|
|
2767
|
-
else console.log(c.dim(" This folder's rows stay in the shared store. Re-run with --purge to remove them."));
|
|
2768
|
-
console.log("");
|
|
2769
|
-
return;
|
|
2770
2557
|
}
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
const contributors = globalWorkspaces();
|
|
2775
|
-
console.log("\n" + c.accent("Holt global memory") + c.dim(" (opt-in, shared facts across your folders)"));
|
|
2776
|
-
console.log(` this folder ${enabled ? c.green("on") : c.dim("off (isolated, the default)")}`);
|
|
2777
|
-
console.log(` contributing ${g.workspaces} folder${g.workspaces === 1 ? "" : "s"}`);
|
|
2778
|
-
console.log(` shared facts ${g.facts}`);
|
|
2779
|
-
console.log(` size ${(g.bytes / 1024).toFixed(1)} KB (${globalMemPath()})`);
|
|
2780
|
-
console.log(` registry ${memoryScopesPath()}`);
|
|
2781
|
-
if (contributors.length) {
|
|
2782
|
-
console.log(c.dim("\n Folders sharing facts:"));
|
|
2783
|
-
for (const w of contributors) console.log(c.dim(` ${basename(w)} ${w === ws ? "(this folder)" : w}`));
|
|
2784
|
-
}
|
|
2785
|
-
console.log(c.dim("\n holt memory global on share this folder's facts + read others"));
|
|
2786
|
-
console.log(c.dim(" holt memory global off stop sharing + reading (add --purge to delete rows)"));
|
|
2787
|
-
console.log(c.dim(" holt memory global status this view\n"));
|
|
2788
|
-
return;
|
|
2558
|
+
const knownTitles = new Set(pages.map((p) => p.title));
|
|
2559
|
+
for (const group of groups.values()) {
|
|
2560
|
+
knownTitles.add(readPage(group.slug)?.title ?? deriveTitle(group.facts[0].content, group.slug));
|
|
2789
2561
|
}
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2562
|
+
const changed = [];
|
|
2563
|
+
for (const group of groups.values()) {
|
|
2564
|
+
const existing = readPage(group.slug);
|
|
2565
|
+
const title = existing?.title ?? deriveTitle(group.facts[0].content, group.slug);
|
|
2566
|
+
const prompt = buildPagePrompt(
|
|
2567
|
+
title,
|
|
2568
|
+
existing?.body ?? "",
|
|
2569
|
+
group.facts.map((f) => f.content),
|
|
2570
|
+
[...knownTitles].filter((t) => t !== title)
|
|
2571
|
+
);
|
|
2572
|
+
const res = await runMaintainer(maintainer, cfg, prompt);
|
|
2573
|
+
if (!res.ok || !res.text.trim()) continue;
|
|
2574
|
+
const body = stripFrontmatterEcho(res.text);
|
|
2575
|
+
const prevSources = existing?.sources ?? [];
|
|
2576
|
+
const page = {
|
|
2577
|
+
slug: group.slug,
|
|
2578
|
+
title,
|
|
2579
|
+
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2580
|
+
sources: dedupe([...prevSources, ...group.facts.map((f) => f.id)]),
|
|
2581
|
+
body
|
|
2582
|
+
};
|
|
2583
|
+
writePage(page);
|
|
2584
|
+
changed.push(page);
|
|
2585
|
+
if (existing) summary.updated++;
|
|
2586
|
+
else summary.created++;
|
|
2587
|
+
summary.factsIntegrated += group.facts.length;
|
|
2588
|
+
}
|
|
2589
|
+
pages = loadPages();
|
|
2590
|
+
writeIndex(pages);
|
|
2591
|
+
await indexPagesForRecall(changed);
|
|
2592
|
+
return { summary, changed };
|
|
2593
|
+
}
|
|
2594
|
+
async function syncWiki(cfg, resolveBrain) {
|
|
2595
|
+
const base = {
|
|
2596
|
+
...emptySummary(),
|
|
2597
|
+
status: "nothing",
|
|
2598
|
+
freshFacts: 0,
|
|
2599
|
+
maintainer: null,
|
|
2600
|
+
report: null,
|
|
2601
|
+
changed: false
|
|
2602
|
+
};
|
|
2603
|
+
try {
|
|
2604
|
+
const state = loadState();
|
|
2605
|
+
const fresh = factRows().filter((f) => f.ts > state.lastSyncTs);
|
|
2606
|
+
if (fresh.length === 0) return base;
|
|
2607
|
+
base.freshFacts = fresh.length;
|
|
2608
|
+
const { maintainer, report } = await resolveMaintainer(cfg, resolveBrain);
|
|
2609
|
+
base.maintainer = maintainer;
|
|
2610
|
+
base.report = report;
|
|
2611
|
+
if (!maintainer) return { ...base, status: "no-maintainer" };
|
|
2612
|
+
const { summary, changed } = await foldFacts(cfg, maintainer, fresh);
|
|
2613
|
+
const newest = Math.max(state.lastSyncTs, ...fresh.map((f) => f.ts));
|
|
2614
|
+
saveSyncMarker(newest);
|
|
2615
|
+
return {
|
|
2616
|
+
...summary,
|
|
2617
|
+
status: "ok",
|
|
2618
|
+
freshFacts: fresh.length,
|
|
2619
|
+
maintainer,
|
|
2620
|
+
report,
|
|
2621
|
+
changed: changed.length > 0
|
|
2622
|
+
};
|
|
2623
|
+
} catch {
|
|
2624
|
+
return base;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
function renderIndex(pages) {
|
|
2628
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2629
|
+
const lines = [
|
|
2630
|
+
"---",
|
|
2631
|
+
"title: Wiki Index",
|
|
2632
|
+
`updated: ${today}`,
|
|
2633
|
+
"---",
|
|
2634
|
+
"",
|
|
2635
|
+
"# Wiki",
|
|
2636
|
+
"",
|
|
2637
|
+
"Derived knowledge for this folder. Pages are synthesized from `facts.md` and",
|
|
2638
|
+
"`turns.jsonl` and are regenerable with `holt wiki rebuild`. Open in Obsidian to",
|
|
2639
|
+
"browse the [[links]].",
|
|
2640
|
+
""
|
|
2641
|
+
];
|
|
2642
|
+
if (pages.length === 0) {
|
|
2643
|
+
lines.push("_No pages yet. Run `holt wiki sync` after chatting._", "");
|
|
2644
|
+
return lines.join("\n");
|
|
2645
|
+
}
|
|
2646
|
+
lines.push("## Pages", "");
|
|
2647
|
+
for (const pg of [...pages].sort((a, b) => a.title.localeCompare(b.title))) {
|
|
2648
|
+
const when = pg.updated ? ` _(updated ${pg.updated})_` : "";
|
|
2649
|
+
lines.push(`- [[${pg.title}]]${when}`);
|
|
2650
|
+
}
|
|
2651
|
+
lines.push("");
|
|
2652
|
+
return lines.join("\n");
|
|
2653
|
+
}
|
|
2654
|
+
function writeIndex(pages) {
|
|
2655
|
+
mkdirSync7(wikiDir(), { recursive: true });
|
|
2656
|
+
writeFileSync7(wikiIndexPath(), renderIndex(pages), "utf8");
|
|
2794
2657
|
}
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
async function
|
|
2658
|
+
var WIKI_SESSION = "wiki";
|
|
2659
|
+
function wikiRowId(slug) {
|
|
2660
|
+
return "wiki-" + sanitizeName(slug).slice(0, 24);
|
|
2661
|
+
}
|
|
2662
|
+
function dropWikiRows(slugs) {
|
|
2663
|
+
const keep = loadTurns().filter((t) => !(t.session === WIKI_SESSION && slugs.has(t.id.replace(/^wiki-/, ""))));
|
|
2664
|
+
return keep;
|
|
2665
|
+
}
|
|
2666
|
+
async function indexPagesForRecall(pages) {
|
|
2804
2667
|
try {
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2668
|
+
if (pages.length === 0) return;
|
|
2669
|
+
const slugs = new Set(pages.map((p) => sanitizeName(p.slug).slice(0, 24)));
|
|
2670
|
+
const kept = dropWikiRows(slugs);
|
|
2671
|
+
mkdirSync7(join8(wsHoltDir(), "memory"), { recursive: true });
|
|
2672
|
+
writeFileSync7(
|
|
2673
|
+
join8(wsHoltDir(), "memory", "turns.jsonl"),
|
|
2674
|
+
kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
|
|
2675
|
+
"utf8"
|
|
2676
|
+
);
|
|
2677
|
+
for (const pg of pages) {
|
|
2678
|
+
const content = `${pg.title}
|
|
2679
|
+
${pg.body}`.slice(0, 4e3);
|
|
2680
|
+
appendTurn({
|
|
2681
|
+
id: wikiRowId(pg.slug),
|
|
2682
|
+
ts: Date.now(),
|
|
2683
|
+
session: WIKI_SESSION,
|
|
2684
|
+
role: "fact",
|
|
2685
|
+
content,
|
|
2686
|
+
emb: await embed(content) ?? void 0
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2814
2689
|
} catch {
|
|
2815
|
-
return { reachable: false, hasModel: false };
|
|
2816
2690
|
}
|
|
2817
2691
|
}
|
|
2818
|
-
|
|
2819
|
-
let res;
|
|
2692
|
+
function clearWikiRecallRows() {
|
|
2820
2693
|
try {
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2694
|
+
const kept = loadTurns().filter((t) => t.session !== WIKI_SESSION);
|
|
2695
|
+
mkdirSync7(join8(wsHoltDir(), "memory"), { recursive: true });
|
|
2696
|
+
writeFileSync7(
|
|
2697
|
+
join8(wsHoltDir(), "memory", "turns.jsonl"),
|
|
2698
|
+
kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
|
|
2699
|
+
"utf8"
|
|
2700
|
+
);
|
|
2701
|
+
} catch {
|
|
2829
2702
|
}
|
|
2830
|
-
|
|
2831
|
-
|
|
2703
|
+
}
|
|
2704
|
+
function wipeWiki() {
|
|
2705
|
+
if (existsSync8(wikiDir())) rmSync4(wikiDir(), { recursive: true, force: true });
|
|
2706
|
+
}
|
|
2707
|
+
function wikiStats() {
|
|
2708
|
+
const slugs = listPageSlugs();
|
|
2709
|
+
let bytes = 0;
|
|
2710
|
+
for (const slug of slugs) {
|
|
2832
2711
|
try {
|
|
2833
|
-
|
|
2712
|
+
bytes += statSync3(pagePath(slug)).size;
|
|
2834
2713
|
} catch {
|
|
2835
2714
|
}
|
|
2836
|
-
return { ok: false, text: `Ollama error ${res.status}: ${body.slice(0, 200)}` };
|
|
2837
|
-
}
|
|
2838
|
-
try {
|
|
2839
|
-
const data = await res.json();
|
|
2840
|
-
const text = (data.response || "").trim();
|
|
2841
|
-
if (text) return { ok: true, text };
|
|
2842
|
-
return { ok: false, text: "Local model returned an empty reply." };
|
|
2843
|
-
} catch (e) {
|
|
2844
|
-
return { ok: false, text: `Could not parse Ollama reply: ${e.message}` };
|
|
2845
2715
|
}
|
|
2716
|
+
return { pages: slugs.length, bytes, lastSyncTs: loadState().lastSyncTs };
|
|
2846
2717
|
}
|
|
2847
|
-
function
|
|
2848
|
-
return
|
|
2718
|
+
function factRows() {
|
|
2719
|
+
return loadTurns().filter((t) => t.role === "fact" && t.session !== WIKI_SESSION).map((t) => ({ id: t.id, content: t.content, ts: t.ts }));
|
|
2849
2720
|
}
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
readdirSync as readdirSync2,
|
|
2859
|
-
statSync as statSync3
|
|
2860
|
-
} from "fs";
|
|
2861
|
-
import { join as join9 } from "path";
|
|
2862
|
-
import { totalmem } from "os";
|
|
2863
|
-
function wikiDir() {
|
|
2864
|
-
return join9(wsHoltDir(), "wiki");
|
|
2721
|
+
var RAM_RECOMMENDATIONS = [
|
|
2722
|
+
{ minGiB: 48, model: "qwen2.5:32b", note: "plenty of headroom for a large local model (~20GB)." },
|
|
2723
|
+
{ minGiB: 24, model: "qwen2.5:14b", note: "a 14B model (~9GB) fits comfortably." },
|
|
2724
|
+
{ minGiB: 16, model: "qwen2.5:7b", note: "default. ~4.7GB; tight alongside the embed model + editor. An always-on machine is a better host. Alternative: llama3.1:8b." },
|
|
2725
|
+
{ minGiB: 0, model: "llama3.2:3b", note: "under 16GB, local is discouraged; if you insist, this ~2GB model is modest quality. The brain maintainer is the better default." }
|
|
2726
|
+
];
|
|
2727
|
+
function totalGiB() {
|
|
2728
|
+
return Math.round(totalmem() / 1024 ** 3 * 10) / 10;
|
|
2865
2729
|
}
|
|
2866
|
-
function
|
|
2867
|
-
|
|
2730
|
+
function recommendLocalModel(gib = totalGiB()) {
|
|
2731
|
+
const tier = RAM_RECOMMENDATIONS.find((r) => gib >= r.minGiB) ?? RAM_RECOMMENDATIONS[RAM_RECOMMENDATIONS.length - 1];
|
|
2732
|
+
return {
|
|
2733
|
+
gib,
|
|
2734
|
+
model: tier.model,
|
|
2735
|
+
note: tier.note,
|
|
2736
|
+
pull: pullHint(tier.model),
|
|
2737
|
+
discourageLocal: gib < 16
|
|
2738
|
+
};
|
|
2868
2739
|
}
|
|
2869
|
-
|
|
2870
|
-
|
|
2740
|
+
|
|
2741
|
+
// src/output.ts
|
|
2742
|
+
import { writeFileSync as writeFileSync8 } from "fs";
|
|
2743
|
+
import { join as join9, isAbsolute } from "path";
|
|
2744
|
+
function escapeHtml(s) {
|
|
2745
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2871
2746
|
}
|
|
2872
|
-
function
|
|
2873
|
-
|
|
2747
|
+
function renderInline(text) {
|
|
2748
|
+
const codes = [];
|
|
2749
|
+
let s = text.replace(/`([^`]+)`/g, (_m, code) => {
|
|
2750
|
+
codes.push(`<code>${escapeHtml(code)}</code>`);
|
|
2751
|
+
return `\0${codes.length - 1}\0`;
|
|
2752
|
+
});
|
|
2753
|
+
s = escapeHtml(s);
|
|
2754
|
+
s = s.replace(/\[([^\]]+)\]\((\S*?(?:\([^)\s]*\)[^)\s]*)*)\)/g, (_m, label, url) => {
|
|
2755
|
+
const trimmed = url.trim();
|
|
2756
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
|
|
2757
|
+
const safeScheme = !scheme || /^(https?|mailto)$/i.test(scheme[1]);
|
|
2758
|
+
if (!safeScheme) return label;
|
|
2759
|
+
const safe = trimmed.replace(/"/g, "%22");
|
|
2760
|
+
return `<a href="${safe}">${label}</a>`;
|
|
2761
|
+
});
|
|
2762
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
2763
|
+
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
2764
|
+
s = s.replace(/(\d+)/g, (_m, i) => codes[Number(i)] ?? "");
|
|
2765
|
+
return s;
|
|
2874
2766
|
}
|
|
2875
|
-
function
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2767
|
+
function markdownToHtmlBody(md) {
|
|
2768
|
+
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
|
2769
|
+
const out = [];
|
|
2770
|
+
let i = 0;
|
|
2771
|
+
let listOpen = false;
|
|
2772
|
+
let paragraph = [];
|
|
2773
|
+
const flushParagraph = () => {
|
|
2774
|
+
if (paragraph.length) {
|
|
2775
|
+
out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
|
|
2776
|
+
paragraph = [];
|
|
2777
|
+
}
|
|
2778
|
+
};
|
|
2779
|
+
const closeList = () => {
|
|
2780
|
+
if (listOpen) {
|
|
2781
|
+
out.push("</ul>");
|
|
2782
|
+
listOpen = false;
|
|
2783
|
+
}
|
|
2784
|
+
};
|
|
2785
|
+
while (i < lines.length) {
|
|
2786
|
+
const line = lines[i] ?? "";
|
|
2787
|
+
const fence = line.match(/^```(.*)$/);
|
|
2788
|
+
if (fence) {
|
|
2789
|
+
flushParagraph();
|
|
2790
|
+
closeList();
|
|
2791
|
+
const code = [];
|
|
2792
|
+
i++;
|
|
2793
|
+
while (i < lines.length && !/^```/.test(lines[i] ?? "")) {
|
|
2794
|
+
code.push(lines[i] ?? "");
|
|
2795
|
+
i++;
|
|
2796
|
+
}
|
|
2797
|
+
i++;
|
|
2798
|
+
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
2799
|
+
continue;
|
|
2800
|
+
}
|
|
2801
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
2802
|
+
if (h) {
|
|
2803
|
+
flushParagraph();
|
|
2804
|
+
closeList();
|
|
2805
|
+
const level = (h[1] ?? "#").length;
|
|
2806
|
+
out.push(`<h${level}>${renderInline((h[2] ?? "").trim())}</h${level}>`);
|
|
2807
|
+
i++;
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
2811
|
+
if (li) {
|
|
2812
|
+
flushParagraph();
|
|
2813
|
+
if (!listOpen) {
|
|
2814
|
+
out.push("<ul>");
|
|
2815
|
+
listOpen = true;
|
|
2816
|
+
}
|
|
2817
|
+
out.push(`<li>${renderInline((li[1] ?? "").trim())}</li>`);
|
|
2818
|
+
i++;
|
|
2819
|
+
continue;
|
|
2820
|
+
}
|
|
2821
|
+
if (line.trim() === "") {
|
|
2822
|
+
flushParagraph();
|
|
2823
|
+
closeList();
|
|
2824
|
+
i++;
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
closeList();
|
|
2828
|
+
paragraph.push(line.trim());
|
|
2829
|
+
i++;
|
|
2881
2830
|
}
|
|
2831
|
+
flushParagraph();
|
|
2832
|
+
closeList();
|
|
2833
|
+
return out.join("\n");
|
|
2882
2834
|
}
|
|
2883
|
-
function
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
}
|
|
2890
|
-
|
|
2891
|
-
|
|
2835
|
+
function wrapHtmlPage(body, title = "Holt reply") {
|
|
2836
|
+
return `<!doctype html>
|
|
2837
|
+
<html lang="en">
|
|
2838
|
+
<head>
|
|
2839
|
+
<meta charset="utf-8">
|
|
2840
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2841
|
+
<title>${escapeHtml(title)}</title>
|
|
2842
|
+
<style>
|
|
2843
|
+
:root { --bg:#0f1115; --fg:#e7e9ee; --accent:#f0b91e; }
|
|
2844
|
+
* { box-sizing: border-box; }
|
|
2845
|
+
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
2846
|
+
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
|
|
2847
|
+
main { max-width: 760px; margin: 0 auto; }
|
|
2848
|
+
h1,h2,h3 { color:var(--accent); line-height:1.25; margin:1.4em 0 .5em; }
|
|
2849
|
+
h1 { font-size:1.6rem; } h2 { font-size:1.3rem; } h3 { font-size:1.1rem; }
|
|
2850
|
+
a { color:var(--accent); }
|
|
2851
|
+
code { background:#1b1e26; padding:.1em .35em; border-radius:4px; font-size:.9em; }
|
|
2852
|
+
pre { background:#1b1e26; padding:1rem; border-radius:8px; overflow:auto; }
|
|
2853
|
+
pre code { background:none; padding:0; }
|
|
2854
|
+
ul { padding-left:1.3rem; }
|
|
2855
|
+
p { margin:.7em 0; }
|
|
2856
|
+
</style>
|
|
2857
|
+
</head>
|
|
2858
|
+
<body>
|
|
2859
|
+
<main>
|
|
2860
|
+
${body}
|
|
2861
|
+
</main>
|
|
2862
|
+
</body>
|
|
2863
|
+
</html>
|
|
2864
|
+
`;
|
|
2892
2865
|
}
|
|
2893
|
-
function
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
try {
|
|
2897
|
-
return readdirSync2(dir).filter(isPageFile).map((f) => f.slice(0, -3)).sort();
|
|
2898
|
-
} catch {
|
|
2899
|
-
return [];
|
|
2900
|
-
}
|
|
2866
|
+
function renderReply(reply, format) {
|
|
2867
|
+
if (format === "html") return wrapHtmlPage(markdownToHtmlBody(reply));
|
|
2868
|
+
return reply.endsWith("\n") ? reply : reply + "\n";
|
|
2901
2869
|
}
|
|
2902
|
-
function
|
|
2903
|
-
const
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
try {
|
|
2907
|
-
raw = readFileSync10(p, "utf8");
|
|
2908
|
-
} catch {
|
|
2909
|
-
return null;
|
|
2910
|
-
}
|
|
2911
|
-
const { data, body } = parseFrontmatter(raw);
|
|
2912
|
-
return {
|
|
2913
|
-
slug,
|
|
2914
|
-
title: data.title || slug,
|
|
2915
|
-
updated: data.updated || "",
|
|
2916
|
-
sources: parseSources(data.sources),
|
|
2917
|
-
body
|
|
2918
|
-
};
|
|
2870
|
+
function timestamp() {
|
|
2871
|
+
const d = /* @__PURE__ */ new Date();
|
|
2872
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2873
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
2919
2874
|
}
|
|
2920
|
-
function
|
|
2921
|
-
const
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2875
|
+
function saveReply(reply, format, name) {
|
|
2876
|
+
const ext = format === "html" ? ".html" : ".md";
|
|
2877
|
+
let base = (name ?? "").trim();
|
|
2878
|
+
if (!base) base = `holt-reply-${timestamp()}`;
|
|
2879
|
+
if (isAbsolute(base) || /[\\/]/.test(base) || base === "." || base === "..") {
|
|
2880
|
+
throw new Error('name must be a plain file name in this folder (no "/", "\\", or "..").');
|
|
2925
2881
|
}
|
|
2926
|
-
|
|
2882
|
+
if (!/\.[a-z0-9]+$/i.test(base)) base += ext;
|
|
2883
|
+
const path = join9(process.cwd(), base);
|
|
2884
|
+
writeFileSync8(path, renderReply(reply, format), "utf8");
|
|
2885
|
+
return path;
|
|
2927
2886
|
}
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2887
|
+
|
|
2888
|
+
// src/commands/chat.ts
|
|
2889
|
+
function statusLine(brainLabel, history) {
|
|
2890
|
+
const live = Math.min(history.length, MAX_REPLAY_TURNS);
|
|
2891
|
+
const frac = MAX_REPLAY_TURNS ? live / MAX_REPLAY_TURNS : 0;
|
|
2892
|
+
const pct = Math.round(frac * 100);
|
|
2893
|
+
const mem = memStats().turns;
|
|
2894
|
+
return c.dim(" " + brainLabel + " ") + c.dim("[") + bar(frac) + c.dim("]") + c.dim(` ${pct}% context \xB7 ${mem} in memory`);
|
|
2931
2895
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
"
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
"
|
|
2940
|
-
"",
|
|
2941
|
-
|
|
2942
|
-
"",
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
].join("\n");
|
|
2946
|
-
return fm;
|
|
2896
|
+
function help() {
|
|
2897
|
+
console.log(c.dim([
|
|
2898
|
+
" commands:",
|
|
2899
|
+
" /brain [name] switch brain (CLI or API). context is kept.",
|
|
2900
|
+
" /memory [query] memory stats, or preview what a query would recall",
|
|
2901
|
+
' /skill [name] [input] run a skill, or list them. "holt skill" manages them.',
|
|
2902
|
+
" /output [fmt] show or set output format: markdown | html",
|
|
2903
|
+
" /save [name] save the last reply to a file in this folder",
|
|
2904
|
+
" /setting configure brains, API brains, and your launch command",
|
|
2905
|
+
" /clear forget this session so far (saved memory stays)",
|
|
2906
|
+
" /help this list",
|
|
2907
|
+
" /exit leave"
|
|
2908
|
+
].join("\n")));
|
|
2947
2909
|
}
|
|
2948
|
-
function
|
|
2949
|
-
|
|
2950
|
-
|
|
2910
|
+
function resolveActive2(cfg, id) {
|
|
2911
|
+
if (BRAIN_IDS.includes(id)) {
|
|
2912
|
+
const b = cfg.brains[id];
|
|
2913
|
+
return { kind: "cli", id, label: b.label };
|
|
2914
|
+
}
|
|
2915
|
+
const api = findApiBrain(cfg, id);
|
|
2916
|
+
if (api) return { kind: "api", id, label: `${id} (api: ${api.provider}/${api.model})`, brain: api };
|
|
2917
|
+
return null;
|
|
2951
2918
|
}
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2919
|
+
async function chat() {
|
|
2920
|
+
const { ask, close } = createReader();
|
|
2921
|
+
if (!await ensureTrusted(ask)) {
|
|
2922
|
+
close();
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
let cfg = loadConfig();
|
|
2926
|
+
if (!cfg || !cfg.defaultBrain) {
|
|
2927
|
+
const a = (await ask(c.dim("No Holt setup in this folder. Set it up now? [Y/n] ")) ?? "").trim().toLowerCase();
|
|
2928
|
+
close();
|
|
2929
|
+
if (a === "n" || a === "no") {
|
|
2930
|
+
console.log(c.dim(' Run "holt init" here when ready.\n'));
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
await init();
|
|
2934
|
+
console.log(c.dim('\nSetup done. Run "holt chat" to start talking.\n'));
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
let active = resolveActive2(cfg, cfg.defaultBrain);
|
|
2938
|
+
if (!active) {
|
|
2939
|
+
const fallback = BRAIN_IDS.find((id) => cfg.brains[id].enabled) ?? cfg.apiBrains[0]?.id ?? null;
|
|
2940
|
+
active = fallback ? resolveActive2(cfg, fallback) : null;
|
|
2941
|
+
if (!active) {
|
|
2942
|
+
close();
|
|
2943
|
+
console.log(c.dim('\nNo brain is ready. Run "holt setting".\n'));
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
const session = newSessionId();
|
|
2948
|
+
const history = [];
|
|
2949
|
+
let lastReply = "";
|
|
2950
|
+
const embedOk = await embeddingsAvailable();
|
|
2951
|
+
const stats = memStats();
|
|
2952
|
+
console.log("\n" + c.accent("Holt") + c.dim(` brain: ${active.label}`));
|
|
2953
|
+
console.log(c.dim(
|
|
2954
|
+
`Memory: ${stats.turns} moments (${stats.facts} facts) from ${stats.sessions} session${stats.sessions === 1 ? "" : "s"} in this folder (recall: ${embedOk ? "embeddings via local Ollama" : "keyword match"}).`
|
|
2955
|
+
));
|
|
2956
|
+
if (embedOk && stats.withEmbeddings < stats.turns) {
|
|
2957
|
+
console.log(c.dim(` ${stats.turns - stats.withEmbeddings} older moments lack embeddings. Run "holt memory embed" to upgrade them.`));
|
|
2958
|
+
}
|
|
2959
|
+
console.log(c.dim("Type a message. Commands: /brain /memory /output /save /setting /clear /help /exit"));
|
|
2960
|
+
console.log(statusLine(active.label, history) + "\n");
|
|
2961
|
+
while (true) {
|
|
2962
|
+
const raw = await ask(c.accent("\u203A "));
|
|
2963
|
+
if (raw === null) break;
|
|
2964
|
+
const line = raw.trim();
|
|
2965
|
+
if (!line) continue;
|
|
2966
|
+
let promptOverride = null;
|
|
2967
|
+
if (/^\/skills?\s+\S/.test(line)) {
|
|
2968
|
+
const inv = resolveSkillInvocation(line.replace(/^\/skills\b/, "/skill"));
|
|
2969
|
+
if (!inv) {
|
|
2970
|
+
console.log(c.dim(' no such skill. Try "holt skill list".'));
|
|
2971
|
+
continue;
|
|
2972
|
+
}
|
|
2973
|
+
promptOverride = inv.prompt;
|
|
2974
|
+
console.log(c.dim(` running skill "${inv.skillName}"...`));
|
|
2975
|
+
} else if (line.startsWith("/")) {
|
|
2976
|
+
const parts = line.slice(1).split(/\s+/);
|
|
2977
|
+
const cmd = (parts[0] || "").toLowerCase();
|
|
2978
|
+
const rest = parts.slice(1).join(" ");
|
|
2979
|
+
const arg = (parts[1] || "").toLowerCase();
|
|
2980
|
+
if (cmd === "exit" || cmd === "quit" || cmd === "q") break;
|
|
2981
|
+
if (cmd === "help" || cmd === "h") {
|
|
2982
|
+
help();
|
|
2983
|
+
continue;
|
|
2984
|
+
}
|
|
2985
|
+
if (cmd === "clear") {
|
|
2986
|
+
history.length = 0;
|
|
2987
|
+
console.log(c.dim(" session context cleared. Saved memory is untouched."));
|
|
2988
|
+
continue;
|
|
2989
|
+
}
|
|
2990
|
+
if (cmd === "memory" || cmd === "mem") {
|
|
2991
|
+
if (rest) {
|
|
2992
|
+
const hits = await recall(rest, session, 5);
|
|
2993
|
+
if (hits.length === 0) console.log(c.dim(" nothing relevant in memory for that."));
|
|
2994
|
+
else for (const h of hits) console.log(c.dim(` ${h.score.toFixed(2)} (${h.turn.role}) ${h.turn.content.slice(0, 110).replace(/\s+/g, " ")}`));
|
|
2995
|
+
} else {
|
|
2996
|
+
const s = memStats();
|
|
2997
|
+
console.log(c.dim(` ${s.turns} moments, ${s.facts} facts, ${s.sessions} sessions, ${s.withEmbeddings} embedded, ${(s.bytes / 1024).toFixed(1)} KB in ./.holt/memory/`));
|
|
2998
|
+
console.log(c.dim(' usage: /memory <query> to preview recall, or "holt memory clear" to wipe.'));
|
|
2999
|
+
}
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
if (cmd === "output") {
|
|
3003
|
+
const want = arg;
|
|
3004
|
+
if (want === "markdown" || want === "md") {
|
|
3005
|
+
cfg.outputFormat = "markdown";
|
|
3006
|
+
saveConfig(cfg);
|
|
3007
|
+
console.log(c.green(" output format: markdown"));
|
|
3008
|
+
} else if (want === "html") {
|
|
3009
|
+
cfg.outputFormat = "html";
|
|
3010
|
+
saveConfig(cfg);
|
|
3011
|
+
console.log(c.green(" output format: html"));
|
|
3012
|
+
} else if (want) console.log(c.dim(" usage: /output markdown | html"));
|
|
3013
|
+
else console.log(c.dim(` output format: ${cfg.outputFormat} (change with /output markdown | html)`));
|
|
3014
|
+
continue;
|
|
3015
|
+
}
|
|
3016
|
+
if (cmd === "save") {
|
|
3017
|
+
if (!lastReply) {
|
|
3018
|
+
console.log(c.dim(" nothing to save yet. Ask something first."));
|
|
3019
|
+
continue;
|
|
3020
|
+
}
|
|
3021
|
+
try {
|
|
3022
|
+
const path = saveReply(lastReply, cfg.outputFormat, rest || void 0);
|
|
3023
|
+
console.log(c.green(` saved ${cfg.outputFormat} to ${path}`));
|
|
3024
|
+
} catch (e) {
|
|
3025
|
+
console.log(c.red(` could not save: ${e.message}`));
|
|
3026
|
+
}
|
|
3027
|
+
continue;
|
|
3028
|
+
}
|
|
3029
|
+
if (cmd === "setting" || cmd === "settings") {
|
|
3030
|
+
cfg = await runSettings(ask);
|
|
3031
|
+
const next = cfg.defaultBrain ? resolveActive2(cfg, cfg.defaultBrain) : null;
|
|
3032
|
+
const stillValid = resolveActive2(cfg, active.id);
|
|
3033
|
+
active = stillValid ?? next ?? active;
|
|
3034
|
+
console.log(c.dim(` brain: ${active.label}`));
|
|
3035
|
+
continue;
|
|
3036
|
+
}
|
|
3037
|
+
if (cmd === "brain") {
|
|
3038
|
+
const cliEnabled = BRAIN_IDS.filter((id) => cfg.brains[id].enabled);
|
|
3039
|
+
const apiIds = cfg.apiBrains.map((a) => a.id);
|
|
3040
|
+
const all = [...cliEnabled, ...apiIds];
|
|
3041
|
+
const rawArg = parts[1] || "";
|
|
3042
|
+
const match = all.find((id) => id.toLowerCase() === rawArg.toLowerCase());
|
|
3043
|
+
if (rawArg && match) {
|
|
3044
|
+
const next = resolveActive2(cfg, match);
|
|
3045
|
+
if (next) {
|
|
3046
|
+
active = next;
|
|
3047
|
+
const turns = Math.floor(history.length / 2);
|
|
3048
|
+
console.log(c.green(` switched to ${active.label}. Context kept (${turns} turn${turns === 1 ? "" : "s"}).`));
|
|
3049
|
+
}
|
|
3050
|
+
} else if (rawArg) {
|
|
3051
|
+
console.log(c.dim(` "${rawArg}" is not available. Available: ${all.join(", ") || "none"}`));
|
|
3052
|
+
} else {
|
|
3053
|
+
const cfgRef = cfg;
|
|
3054
|
+
const activeId = active.id;
|
|
3055
|
+
const labels = all.map((id) => {
|
|
3056
|
+
const a = findApiBrain(cfgRef, id);
|
|
3057
|
+
const shown = a ? `${id} (api: ${a.provider}/${a.model})` : id;
|
|
3058
|
+
return id === activeId ? c.accent(shown + " (current)") : shown;
|
|
3059
|
+
});
|
|
3060
|
+
console.log(c.dim(" brains: " + labels.join(" ")));
|
|
3061
|
+
console.log(c.dim(" usage: /brain <name>"));
|
|
3062
|
+
}
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
if (cmd === "skill" || cmd === "skills") {
|
|
3066
|
+
const names = listSkills().map((s) => s.name);
|
|
3067
|
+
console.log(c.dim(" skills: " + (names.join(" ") || "none") + " usage: /skill <name> [input]"));
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
console.log(c.dim(` unknown command: /${cmd} (try /help)`));
|
|
3071
|
+
continue;
|
|
3072
|
+
}
|
|
3073
|
+
if (active.kind === "cli" && !isInstalled(cfg.brains[active.id].command)) {
|
|
3074
|
+
console.log(c.red(` ${active.label} (${cfg.brains[active.id].command}) is not on your PATH. Use /brain to switch or /setting.`));
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
if (active.kind === "api" && !resolveApiKey(active.brain)) {
|
|
3078
|
+
console.log(c.red(` ${active.label} has no API key. Use /setting to add one, or /brain to switch.`));
|
|
3079
|
+
continue;
|
|
3080
|
+
}
|
|
3081
|
+
const remembered = await recall(line, session, 4);
|
|
3082
|
+
const label = remembered.length ? `${active.label} is thinking (recalled ${remembered.length} moment${remembered.length === 1 ? "" : "s"})...` : `${active.label} is thinking...`;
|
|
3083
|
+
console.log(c.dim(` ${label}`) + "\n");
|
|
3084
|
+
const skillsBlock = skillsPromptBlock();
|
|
3085
|
+
const base = renderPrompt(history, line, remembered);
|
|
3086
|
+
const prompt = promptOverride ?? (skillsBlock ? skillsBlock + "\n\n" + base : base);
|
|
3087
|
+
let streamed = false;
|
|
3088
|
+
const onChunk = (chunk2) => {
|
|
3089
|
+
streamed = true;
|
|
3090
|
+
process.stdout.write(chunk2);
|
|
3091
|
+
};
|
|
3092
|
+
const res = active.kind === "cli" ? await runBrain(cfg.brains[active.id], prompt, onChunk) : await runApiBrain(active.brain, prompt, onChunk);
|
|
3093
|
+
if (res.ok) {
|
|
3094
|
+
if (!streamed) console.log(res.text);
|
|
3095
|
+
if (!res.text.endsWith("\n")) console.log("");
|
|
3096
|
+
console.log("");
|
|
3097
|
+
lastReply = res.text;
|
|
3098
|
+
history.push({ role: "user", content: line });
|
|
3099
|
+
history.push({ role: "assistant", content: res.text });
|
|
3100
|
+
const now = Date.now();
|
|
3101
|
+
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "user", content: line, emb: await embed(line) ?? void 0 });
|
|
3102
|
+
appendTurn({ id: randomUUID3().slice(0, 8), ts: now, session, role: "assistant", content: res.text, emb: await embed(res.text) ?? void 0 });
|
|
3103
|
+
console.log(statusLine(active.label, history) + "\n");
|
|
3104
|
+
} else {
|
|
3105
|
+
console.log(c.red("\n " + res.text + "\n"));
|
|
3106
|
+
}
|
|
2960
3107
|
}
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
for (let i = 0; i < n; i++) {
|
|
2969
|
-
const x = a[i];
|
|
2970
|
-
const y = b[i];
|
|
2971
|
-
dot += x * y;
|
|
2972
|
-
na += x * x;
|
|
2973
|
-
nb += y * y;
|
|
3108
|
+
if (cfg.memory?.extractFacts !== false) {
|
|
3109
|
+
try {
|
|
3110
|
+
const arg = active.kind === "cli" ? { kind: "cli", id: active.id } : { kind: "api", brain: active.brain };
|
|
3111
|
+
const n = await extractAndSaveFacts(arg, cfg, history, session);
|
|
3112
|
+
if (n > 0) console.log(c.dim(` distilled ${n} fact${n === 1 ? "" : "s"} from this session.`));
|
|
3113
|
+
} catch {
|
|
3114
|
+
}
|
|
2974
3115
|
}
|
|
2975
|
-
|
|
3116
|
+
if (cfg.wiki?.autoSync) {
|
|
3117
|
+
try {
|
|
3118
|
+
const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
|
|
3119
|
+
if (res.status === "ok" && res.changed) console.log(c.dim(" updated the wiki."));
|
|
3120
|
+
} catch {
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
close();
|
|
3124
|
+
console.log(c.dim("\nBye.\n"));
|
|
2976
3125
|
}
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
3126
|
+
|
|
3127
|
+
// src/commands/login.ts
|
|
3128
|
+
async function login(which) {
|
|
3129
|
+
const id = (which || "").toLowerCase();
|
|
3130
|
+
if (!BRAIN_IDS.includes(id)) {
|
|
3131
|
+
if (which) {
|
|
3132
|
+
console.error(`
|
|
3133
|
+
Unknown brain "${which}". Use one of: ${BRAIN_IDS.join(", ")}
|
|
3134
|
+
`);
|
|
3135
|
+
process.exitCode = 1;
|
|
3136
|
+
} else {
|
|
3137
|
+
console.log(c.dim(`
|
|
3138
|
+
Usage: holt login <${BRAIN_IDS.join("|")}>
|
|
3139
|
+
`));
|
|
3140
|
+
}
|
|
3141
|
+
return;
|
|
2984
3142
|
}
|
|
2985
|
-
|
|
3143
|
+
const s = BRAIN_SETUP[id];
|
|
3144
|
+
console.log("\n" + c.accent(`Sign in to ${BRAIN_DEFS[id].label}`));
|
|
3145
|
+
console.log(c.dim(` Starting "${s.login.join(" ")}". Complete sign-in, then exit that tool.
|
|
3146
|
+
`));
|
|
3147
|
+
await runInteractive(s.login[0], s.login.slice(1));
|
|
2986
3148
|
}
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
3149
|
+
|
|
3150
|
+
// src/commands/memory.ts
|
|
3151
|
+
import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
|
|
3152
|
+
import { basename } from "path";
|
|
3153
|
+
async function memoryCmd(sub, rest = []) {
|
|
3154
|
+
const { ask, close } = createReader();
|
|
3155
|
+
if (!await ensureTrusted(ask)) {
|
|
3156
|
+
close();
|
|
3157
|
+
return;
|
|
2991
3158
|
}
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3159
|
+
const action = (sub || "").toLowerCase();
|
|
3160
|
+
if (action === "clear") {
|
|
3161
|
+
const s2 = memStats();
|
|
3162
|
+
if (s2.turns === 0) {
|
|
3163
|
+
console.log(c.dim("\n Memory is already empty.\n"));
|
|
3164
|
+
close();
|
|
3165
|
+
return;
|
|
2999
3166
|
}
|
|
3167
|
+
const a = (await ask(`
|
|
3168
|
+
Delete all ${s2.turns} remembered moments in this folder? [y/N] `) ?? "").trim().toLowerCase();
|
|
3169
|
+
if (a === "y" || a === "yes") {
|
|
3170
|
+
clearMemory();
|
|
3171
|
+
console.log(c.green(" Memory cleared.\n"));
|
|
3172
|
+
} else console.log(c.dim(" Kept.\n"));
|
|
3173
|
+
close();
|
|
3174
|
+
return;
|
|
3000
3175
|
}
|
|
3001
|
-
if (
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
const slug = sanitizeName(words2.join("-"));
|
|
3009
|
-
return slug || "notes";
|
|
3010
|
-
}
|
|
3011
|
-
var ROUTE_STOPWORDS = /* @__PURE__ */ new Set([
|
|
3012
|
-
"the",
|
|
3013
|
-
"and",
|
|
3014
|
-
"that",
|
|
3015
|
-
"this",
|
|
3016
|
-
"with",
|
|
3017
|
-
"from",
|
|
3018
|
-
"for",
|
|
3019
|
-
"was",
|
|
3020
|
-
"are",
|
|
3021
|
-
"has",
|
|
3022
|
-
"have",
|
|
3023
|
-
"will",
|
|
3024
|
-
"user",
|
|
3025
|
-
"their",
|
|
3026
|
-
"they",
|
|
3027
|
-
"about",
|
|
3028
|
-
"into",
|
|
3029
|
-
"per",
|
|
3030
|
-
"via"
|
|
3031
|
-
]);
|
|
3032
|
-
async function resolveMaintainer(cfg, resolveBrain) {
|
|
3033
|
-
if (cfg.wiki.maintainer === "local") {
|
|
3034
|
-
const model = cfg.wiki.localModel;
|
|
3035
|
-
const status2 = await localModelStatus(model);
|
|
3036
|
-
if (status2.reachable && status2.hasModel) {
|
|
3037
|
-
const m = { kind: "local", model, label: `local (${model})` };
|
|
3038
|
-
return { maintainer: m, report: { maintainer: m, fellBack: false } };
|
|
3176
|
+
if (action === "embed") {
|
|
3177
|
+
if (!await embeddingsAvailable()) {
|
|
3178
|
+
console.log(c.dim(`
|
|
3179
|
+
No local Ollama with ${EMBED_MODEL} reachable. Run "holt init" to set it up.
|
|
3180
|
+
`));
|
|
3181
|
+
close();
|
|
3182
|
+
return;
|
|
3039
3183
|
}
|
|
3040
|
-
const
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
report: { maintainer: brain2, fellBack: true, note: `${why} Falling back to ${brain2.label}.` }
|
|
3046
|
-
};
|
|
3184
|
+
const missing = loadTurns().filter((t) => !Array.isArray(t.emb)).length;
|
|
3185
|
+
if (missing === 0) {
|
|
3186
|
+
console.log(c.dim("\n All memories already have embeddings.\n"));
|
|
3187
|
+
close();
|
|
3188
|
+
return;
|
|
3047
3189
|
}
|
|
3048
|
-
|
|
3190
|
+
console.log("");
|
|
3191
|
+
const r = await backfillEmbeddings((done, total) => {
|
|
3192
|
+
process.stdout.write(`\r embedding ${done}/${total}...`);
|
|
3193
|
+
});
|
|
3194
|
+
console.log("\n" + c.green(` Done. ${r.embedded} of ${r.total} memories embedded.`) + "\n");
|
|
3195
|
+
close();
|
|
3196
|
+
return;
|
|
3049
3197
|
}
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
function buildPagePrompt(title, existingBody, facts, otherTitles) {
|
|
3061
|
-
const lines = [
|
|
3062
|
-
`You maintain a personal knowledge wiki. Write or update ONE page titled "${title}".`,
|
|
3063
|
-
"Output ONLY the page body in Markdown. No frontmatter, no code fences, no preamble.",
|
|
3064
|
-
"",
|
|
3065
|
-
"Rules:",
|
|
3066
|
-
"- Synthesize the facts into concise, self-contained prose (a few short paragraphs or tight bullets).",
|
|
3067
|
-
"- Merge new facts into the existing text; do not just append. Resolve overlaps. Keep it current.",
|
|
3068
|
-
"- Do not invent anything beyond the facts given.",
|
|
3069
|
-
'- End with a line "## Related" followed by [[Wiki Page]] links to other pages that connect.',
|
|
3070
|
-
"- Use [[double-bracket]] links for any other page you reference."
|
|
3071
|
-
];
|
|
3072
|
-
if (otherTitles.length) {
|
|
3073
|
-
lines.push("", "Other existing pages you may link to:", ...otherTitles.map((t) => `- ${t}`));
|
|
3198
|
+
if (action === "facts") {
|
|
3199
|
+
if (!existsSync9(factsMdPath())) {
|
|
3200
|
+
console.log(c.dim("\n No facts distilled yet. They form when you end a chat session.\n"));
|
|
3201
|
+
close();
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
const body = readFileSync10(factsMdPath(), "utf8").trim();
|
|
3205
|
+
console.log("\n" + body + "\n");
|
|
3206
|
+
close();
|
|
3207
|
+
return;
|
|
3074
3208
|
}
|
|
3075
|
-
if (
|
|
3076
|
-
|
|
3209
|
+
if (action === "search") {
|
|
3210
|
+
const q = rest.join(" ").trim();
|
|
3211
|
+
if (!q) {
|
|
3212
|
+
console.log(c.dim("\n Usage: holt memory search <query>\n"));
|
|
3213
|
+
close();
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
const hits = await recall(q, "__none__", 8);
|
|
3217
|
+
console.log("");
|
|
3218
|
+
if (hits.length === 0) console.log(c.dim(" Nothing relevant found."));
|
|
3219
|
+
else for (const h of hits) {
|
|
3220
|
+
const when = new Date(h.turn.ts).toISOString().slice(0, 10);
|
|
3221
|
+
const src = h.workspace ? c.dim(` [${basename(h.workspace)}]`) : "";
|
|
3222
|
+
console.log(
|
|
3223
|
+
` ${c.accent(h.score.toFixed(2))} ${c.dim(when)} (${h.turn.role})${src} ${h.turn.content.slice(0, 100).replace(/\s+/g, " ")}`
|
|
3224
|
+
);
|
|
3225
|
+
}
|
|
3226
|
+
console.log("");
|
|
3227
|
+
close();
|
|
3228
|
+
return;
|
|
3077
3229
|
}
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3083
|
-
const lines = [
|
|
3084
|
-
"---",
|
|
3085
|
-
"title: Wiki Index",
|
|
3086
|
-
`updated: ${today}`,
|
|
3087
|
-
"---",
|
|
3088
|
-
"",
|
|
3089
|
-
"# Wiki",
|
|
3090
|
-
"",
|
|
3091
|
-
"Derived knowledge for this folder. Pages are synthesized from `facts.md` and",
|
|
3092
|
-
"`turns.jsonl` and are regenerable with `holt wiki rebuild`. Open in Obsidian to",
|
|
3093
|
-
"browse the [[links]].",
|
|
3094
|
-
""
|
|
3095
|
-
];
|
|
3096
|
-
if (pages.length === 0) {
|
|
3097
|
-
lines.push("_No pages yet. Run `holt wiki sync` after chatting._", "");
|
|
3098
|
-
return lines.join("\n");
|
|
3230
|
+
if (action === "global") {
|
|
3231
|
+
globalCmd((rest[0] || "").toLowerCase(), rest.slice(1));
|
|
3232
|
+
close();
|
|
3233
|
+
return;
|
|
3099
3234
|
}
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3235
|
+
if (action && !["clear", "embed", "facts", "search", "global"].includes(action)) {
|
|
3236
|
+
console.error(`
|
|
3237
|
+
Unknown memory subcommand: "${sub}". Use: search <query> | facts | embed | global | clear
|
|
3238
|
+
`);
|
|
3239
|
+
process.exitCode = 1;
|
|
3240
|
+
close();
|
|
3241
|
+
return;
|
|
3104
3242
|
}
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
}
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
return keep;
|
|
3119
|
-
}
|
|
3120
|
-
async function indexPagesForRecall(pages) {
|
|
3121
|
-
try {
|
|
3122
|
-
if (pages.length === 0) return;
|
|
3123
|
-
const slugs = new Set(pages.map((p) => sanitizeName(p.slug).slice(0, 24)));
|
|
3124
|
-
const kept = dropWikiRows(slugs);
|
|
3125
|
-
mkdirSync7(join9(wsHoltDir(), "memory"), { recursive: true });
|
|
3126
|
-
writeFileSync8(
|
|
3127
|
-
join9(wsHoltDir(), "memory", "turns.jsonl"),
|
|
3128
|
-
kept.map((t) => JSON.stringify(t)).join("\n") + (kept.length ? "\n" : ""),
|
|
3129
|
-
"utf8"
|
|
3130
|
-
);
|
|
3131
|
-
for (const pg of pages) {
|
|
3132
|
-
const content = `${pg.title}
|
|
3133
|
-
${pg.body}`.slice(0, 4e3);
|
|
3134
|
-
appendTurn({
|
|
3135
|
-
id: wikiRowId(pg.slug),
|
|
3136
|
-
ts: Date.now(),
|
|
3137
|
-
session: WIKI_SESSION,
|
|
3138
|
-
role: "fact",
|
|
3139
|
-
content,
|
|
3140
|
-
emb: await embed(content) ?? void 0
|
|
3141
|
-
});
|
|
3142
|
-
}
|
|
3143
|
-
} catch {
|
|
3243
|
+
const s = memStats();
|
|
3244
|
+
const embedOk = await embeddingsAvailable();
|
|
3245
|
+
const sessions = new Set(loadTurns().map((t) => t.session)).size;
|
|
3246
|
+
console.log("\n" + c.accent("Holt memory") + c.dim(" (this folder)"));
|
|
3247
|
+
console.log(` moments ${s.turns}`);
|
|
3248
|
+
console.log(` facts ${s.facts} (./.holt/memory/facts.md)`);
|
|
3249
|
+
console.log(` sessions ${sessions}`);
|
|
3250
|
+
console.log(` embedded ${s.withEmbeddings} of ${s.turns}`);
|
|
3251
|
+
console.log(` size ${(s.bytes / 1024).toFixed(1)} KB (./.holt/memory/turns.jsonl)`);
|
|
3252
|
+
console.log(` recall via ${embedOk ? "embeddings (local Ollama)" : "keyword match (start Ollama with an embed model for semantic recall)"}`);
|
|
3253
|
+
if (embedOk && s.withEmbeddings < s.turns) {
|
|
3254
|
+
console.log(c.dim(`
|
|
3255
|
+
${s.turns - s.withEmbeddings} moments lack embeddings. Run "holt memory embed" to upgrade them to semantic recall.`));
|
|
3144
3256
|
}
|
|
3257
|
+
const gEnabled = isGlobalEnabled(workspace());
|
|
3258
|
+
console.log(` global ${gEnabled ? c.green("on") + c.dim(" (contributes + reads shared facts)") : c.dim("off (this folder is isolated, the default)")}`);
|
|
3259
|
+
console.log(c.dim("\n holt memory search <query> find remembered moments"));
|
|
3260
|
+
console.log(c.dim(" holt memory facts show distilled facts (facts.md)"));
|
|
3261
|
+
console.log(c.dim(" holt memory embed embed older memories for semantic recall"));
|
|
3262
|
+
console.log(c.dim(" holt memory global share high-value facts across your folders"));
|
|
3263
|
+
console.log(c.dim(" holt memory clear wipe this folder's memory\n"));
|
|
3264
|
+
close();
|
|
3145
3265
|
}
|
|
3146
|
-
function
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
);
|
|
3155
|
-
}
|
|
3266
|
+
function globalCmd(act, rest) {
|
|
3267
|
+
const ws = workspace();
|
|
3268
|
+
if (act === "on") {
|
|
3269
|
+
const already = isGlobalEnabled(ws);
|
|
3270
|
+
const added = enableGlobal(ws);
|
|
3271
|
+
const g = globalStats();
|
|
3272
|
+
if (already) console.log("\n" + c.green(" Global memory already on for this folder."));
|
|
3273
|
+
else console.log("\n" + c.green(" Global memory ON for this folder."));
|
|
3274
|
+
console.log(c.dim(` Backfilled ${added} fact${added === 1 ? "" : "s"} into the shared store.`));
|
|
3275
|
+
console.log(c.dim(` New facts here mirror automatically; recall now spans ${g.workspaces} contributing folder${g.workspaces === 1 ? "" : "s"}.
|
|
3276
|
+
`));
|
|
3277
|
+
return;
|
|
3156
3278
|
}
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
const slugs = listPageSlugs();
|
|
3163
|
-
let bytes = 0;
|
|
3164
|
-
for (const slug of slugs) {
|
|
3165
|
-
try {
|
|
3166
|
-
bytes += statSync3(pagePath(slug)).size;
|
|
3167
|
-
} catch {
|
|
3279
|
+
if (act === "off") {
|
|
3280
|
+
const purge = rest.includes("--purge");
|
|
3281
|
+
if (!isGlobalEnabled(ws) && !purge) {
|
|
3282
|
+
console.log("\n" + c.dim(" Global memory is already off for this folder.\n"));
|
|
3283
|
+
return;
|
|
3168
3284
|
}
|
|
3285
|
+
const { purged } = disableGlobal(ws, purge);
|
|
3286
|
+
console.log("\n" + c.green(" Global memory OFF for this folder.") + c.dim(" (recall is local-only again)"));
|
|
3287
|
+
if (purge) console.log(c.dim(` Purged ${purged} of this folder's row${purged === 1 ? "" : "s"} from the shared store.`));
|
|
3288
|
+
else console.log(c.dim(" This folder's rows stay in the shared store. Re-run with --purge to remove them."));
|
|
3289
|
+
console.log("");
|
|
3290
|
+
return;
|
|
3169
3291
|
}
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
{
|
|
3177
|
-
|
|
3178
|
-
{
|
|
3179
|
-
{
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
}
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3292
|
+
if (act === "" || act === "status") {
|
|
3293
|
+
const enabled = isGlobalEnabled(ws);
|
|
3294
|
+
const g = globalStats();
|
|
3295
|
+
const contributors = globalWorkspaces();
|
|
3296
|
+
console.log("\n" + c.accent("Holt global memory") + c.dim(" (opt-in, shared facts across your folders)"));
|
|
3297
|
+
console.log(` this folder ${enabled ? c.green("on") : c.dim("off (isolated, the default)")}`);
|
|
3298
|
+
console.log(` contributing ${g.workspaces} folder${g.workspaces === 1 ? "" : "s"}`);
|
|
3299
|
+
console.log(` shared facts ${g.facts}`);
|
|
3300
|
+
console.log(` size ${(g.bytes / 1024).toFixed(1)} KB (${globalMemPath()})`);
|
|
3301
|
+
console.log(` registry ${memoryScopesPath()}`);
|
|
3302
|
+
if (contributors.length) {
|
|
3303
|
+
console.log(c.dim("\n Folders sharing facts:"));
|
|
3304
|
+
for (const w of contributors) console.log(c.dim(` ${basename(w)} ${w === ws ? "(this folder)" : w}`));
|
|
3305
|
+
}
|
|
3306
|
+
console.log(c.dim("\n holt memory global on share this folder's facts + read others"));
|
|
3307
|
+
console.log(c.dim(" holt memory global off stop sharing + reading (add --purge to delete rows)"));
|
|
3308
|
+
console.log(c.dim(" holt memory global status this view\n"));
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
console.error(`
|
|
3312
|
+
Unknown "memory global" action: "${act}". Use: on | off [--purge] | status
|
|
3313
|
+
`);
|
|
3314
|
+
process.exitCode = 1;
|
|
3193
3315
|
}
|
|
3194
3316
|
|
|
3195
3317
|
// src/commands/wiki.ts
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
if (BRAIN_IDS.includes(id)) {
|
|
3200
|
-
const b = cfg.brains[id];
|
|
3201
|
-
if (!isInstalled(b.command)) return null;
|
|
3202
|
-
return { kind: "cli", id, label: b.label };
|
|
3203
|
-
}
|
|
3204
|
-
const api = findApiBrain(cfg, id);
|
|
3205
|
-
if (api && resolveApiKey(api)) return { kind: "api", brain: api, label: `${id} (api: ${api.provider}/${api.model})` };
|
|
3206
|
-
return null;
|
|
3207
|
-
}
|
|
3318
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11 } from "fs";
|
|
3319
|
+
import { join as join10 } from "path";
|
|
3320
|
+
import { spawn as spawn3 } from "child_process";
|
|
3208
3321
|
function titleToSlug(title) {
|
|
3209
3322
|
return proposeSlug(title);
|
|
3210
3323
|
}
|
|
3211
|
-
async function foldFacts(cfg, maintainer, facts) {
|
|
3212
|
-
const summary = { created: 0, updated: 0, factsIntegrated: 0 };
|
|
3213
|
-
if (facts.length === 0) return summary;
|
|
3214
|
-
let pages = loadPages();
|
|
3215
|
-
const targets = await pageEmbeddings(pages);
|
|
3216
|
-
const groups = /* @__PURE__ */ new Map();
|
|
3217
|
-
for (const f of facts) {
|
|
3218
|
-
const decision = await routeFact(f.content, targets);
|
|
3219
|
-
let slug = decision.slug;
|
|
3220
|
-
let isNew = decision.kind === "new";
|
|
3221
|
-
const existingGroup = groups.get(slug);
|
|
3222
|
-
if (existingGroup) {
|
|
3223
|
-
existingGroup.facts.push(f);
|
|
3224
|
-
} else {
|
|
3225
|
-
if (isNew && listPageSlugs().includes(slug)) isNew = false;
|
|
3226
|
-
groups.set(slug, { slug, isNew, facts: [f] });
|
|
3227
|
-
}
|
|
3228
|
-
}
|
|
3229
|
-
const knownTitles = new Set(pages.map((p) => p.title));
|
|
3230
|
-
for (const group of groups.values()) {
|
|
3231
|
-
knownTitles.add(readPage(group.slug)?.title ?? deriveTitle(group.facts[0].content, group.slug));
|
|
3232
|
-
}
|
|
3233
|
-
const changed = [];
|
|
3234
|
-
for (const group of groups.values()) {
|
|
3235
|
-
const existing = readPage(group.slug);
|
|
3236
|
-
const title = existing?.title ?? deriveTitle(group.facts[0].content, group.slug);
|
|
3237
|
-
const prompt = buildPagePrompt(
|
|
3238
|
-
title,
|
|
3239
|
-
existing?.body ?? "",
|
|
3240
|
-
group.facts.map((f) => f.content),
|
|
3241
|
-
[...knownTitles].filter((t) => t !== title)
|
|
3242
|
-
);
|
|
3243
|
-
const res = await runMaintainer(maintainer, cfg, prompt);
|
|
3244
|
-
if (!res.ok || !res.text.trim()) {
|
|
3245
|
-
continue;
|
|
3246
|
-
}
|
|
3247
|
-
const body = stripFrontmatterEcho(res.text);
|
|
3248
|
-
const prevSources = existing?.sources ?? [];
|
|
3249
|
-
const page = {
|
|
3250
|
-
slug: group.slug,
|
|
3251
|
-
title,
|
|
3252
|
-
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
3253
|
-
sources: dedupe([...prevSources, ...group.facts.map((f) => f.id)]),
|
|
3254
|
-
body
|
|
3255
|
-
};
|
|
3256
|
-
writePage(page);
|
|
3257
|
-
changed.push(page);
|
|
3258
|
-
if (existing) summary.updated++;
|
|
3259
|
-
else summary.created++;
|
|
3260
|
-
summary.factsIntegrated += group.facts.length;
|
|
3261
|
-
}
|
|
3262
|
-
pages = loadPages();
|
|
3263
|
-
writeIndex(pages);
|
|
3264
|
-
await indexPagesForRecall(changed);
|
|
3265
|
-
return summary;
|
|
3266
|
-
}
|
|
3267
|
-
function dedupe(a) {
|
|
3268
|
-
return [...new Set(a)];
|
|
3269
|
-
}
|
|
3270
|
-
function deriveTitle(content, slug) {
|
|
3271
|
-
const fromSlug = slug.split("-").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
3272
|
-
return fromSlug || content.slice(0, 40);
|
|
3273
|
-
}
|
|
3274
|
-
function stripFrontmatterEcho(text) {
|
|
3275
|
-
let s = text.trim();
|
|
3276
|
-
s = s.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
3277
|
-
if (s.startsWith("---")) {
|
|
3278
|
-
const end = s.indexOf("\n---", 3);
|
|
3279
|
-
if (end >= 0) s = s.slice(end + 4).trim();
|
|
3280
|
-
}
|
|
3281
|
-
return s;
|
|
3282
|
-
}
|
|
3283
3324
|
async function syncCmd(cfg) {
|
|
3284
|
-
const
|
|
3285
|
-
|
|
3286
|
-
const fresh = all.filter((f) => f.ts > state.lastSyncTs);
|
|
3287
|
-
if (fresh.length === 0) {
|
|
3325
|
+
const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
|
|
3326
|
+
if (res.status === "nothing") {
|
|
3288
3327
|
console.log(c.dim("\n No new facts since the last sync. Nothing to do.\n"));
|
|
3289
3328
|
return;
|
|
3290
3329
|
}
|
|
3291
|
-
|
|
3292
|
-
|
|
3330
|
+
if (res.status === "no-maintainer") {
|
|
3331
|
+
if (res.report?.note) console.log(c.dim("\n " + res.report.note));
|
|
3293
3332
|
console.log(c.red("\n No maintainer available."));
|
|
3294
3333
|
console.log(c.dim(" Configure a brain (holt init) or set wiki.maintainer to local with a pulled model.\n"));
|
|
3295
3334
|
return;
|
|
3296
3335
|
}
|
|
3297
|
-
|
|
3298
|
-
console.log("\n" +
|
|
3299
|
-
|
|
3300
|
-
const newest = Math.max(state.lastSyncTs, ...fresh.map((f) => f.ts));
|
|
3301
|
-
saveSyncMarker(newest);
|
|
3336
|
+
const maintainer = res.maintainer;
|
|
3337
|
+
if (res.report?.note) console.log(c.dim("\n " + res.report.note));
|
|
3338
|
+
console.log("\n" + c.dim(` Integrated ${res.freshFacts} new fact${res.freshFacts === 1 ? "" : "s"} via ${maintainer.label}...`));
|
|
3302
3339
|
console.log("\n" + c.accent("Wiki synced") + c.dim(" (this folder)"));
|
|
3303
|
-
console.log(` facts integrated ${
|
|
3304
|
-
console.log(` pages created ${
|
|
3305
|
-
console.log(` pages updated ${
|
|
3306
|
-
console.log(` maintainer ${maintainer.label}${report?.fellBack ? c.dim(" (fell back)") : ""}`);
|
|
3340
|
+
console.log(` facts integrated ${res.factsIntegrated}`);
|
|
3341
|
+
console.log(` pages created ${res.created}`);
|
|
3342
|
+
console.log(` pages updated ${res.updated}`);
|
|
3343
|
+
console.log(` maintainer ${maintainer.label}${res.report?.fellBack ? c.dim(" (fell back)") : ""}`);
|
|
3307
3344
|
console.log(c.dim(`
|
|
3308
3345
|
Wiki: ${wikiDir()} (open in Obsidian to browse [[links]])
|
|
3309
3346
|
`));
|
|
@@ -3330,7 +3367,7 @@ async function rebuildCmd(cfg, ask) {
|
|
|
3330
3367
|
clearWikiRecallRows();
|
|
3331
3368
|
console.log(c.dim(`
|
|
3332
3369
|
Rebuilding from ${facts.length} facts via ${maintainer.label}...`));
|
|
3333
|
-
const summary = await foldFacts(cfg, maintainer, facts);
|
|
3370
|
+
const { summary } = await foldFacts(cfg, maintainer, facts);
|
|
3334
3371
|
saveSyncMarker(Math.max(0, ...facts.map((f) => f.ts)));
|
|
3335
3372
|
console.log("\n" + c.accent("Wiki rebuilt"));
|
|
3336
3373
|
console.log(` pages ${summary.created}`);
|
|
@@ -3353,7 +3390,51 @@ function buildLintPrompt(pages) {
|
|
|
3353
3390
|
for (const pg of pages) {
|
|
3354
3391
|
lines.push("", `### ${pg.title} (${pg.slug})`, pg.body.slice(0, 1200));
|
|
3355
3392
|
}
|
|
3356
|
-
return lines.join("\n");
|
|
3393
|
+
return lines.join("\n");
|
|
3394
|
+
}
|
|
3395
|
+
var FIX_BEGIN = "<<<HOLT-PAGE ";
|
|
3396
|
+
var FIX_END = "<<<HOLT-END>>>";
|
|
3397
|
+
function buildFixPrompt(pages) {
|
|
3398
|
+
const lines = [
|
|
3399
|
+
"You maintain a personal knowledge wiki. Fix quality issues across the pages",
|
|
3400
|
+
"below: resolve CONTRADICTIONS (keep the most recent/consistent statement),",
|
|
3401
|
+
"merge DUPLICATE passages, and add obvious missing [[wikilinks]]. Do not invent",
|
|
3402
|
+
"new facts; only reconcile and de-duplicate what is already written.",
|
|
3403
|
+
"",
|
|
3404
|
+
"Return EVERY page, even unchanged ones, using EXACTLY this format and nothing",
|
|
3405
|
+
"else (no prose, no code fences, no frontmatter):",
|
|
3406
|
+
"",
|
|
3407
|
+
`${FIX_BEGIN}<slug>>>>`,
|
|
3408
|
+
'<corrected page body in Markdown, ending with a "## Related" section of [[links]]>',
|
|
3409
|
+
FIX_END,
|
|
3410
|
+
"",
|
|
3411
|
+
"Pages:"
|
|
3412
|
+
];
|
|
3413
|
+
for (const pg of pages) {
|
|
3414
|
+
lines.push("", `${FIX_BEGIN}${pg.slug}>>>`, `### ${pg.title}`, pg.body.trim(), FIX_END);
|
|
3415
|
+
}
|
|
3416
|
+
return lines.join("\n");
|
|
3417
|
+
}
|
|
3418
|
+
function parseFixReply(raw) {
|
|
3419
|
+
const out = /* @__PURE__ */ new Map();
|
|
3420
|
+
const text = raw || "";
|
|
3421
|
+
let i = 0;
|
|
3422
|
+
while (true) {
|
|
3423
|
+
const begin = text.indexOf(FIX_BEGIN, i);
|
|
3424
|
+
if (begin < 0) break;
|
|
3425
|
+
const slugStart = begin + FIX_BEGIN.length;
|
|
3426
|
+
const slugEnd = text.indexOf(">>>", slugStart);
|
|
3427
|
+
if (slugEnd < 0) break;
|
|
3428
|
+
const slug = text.slice(slugStart, slugEnd).trim();
|
|
3429
|
+
const bodyStart = slugEnd + 3;
|
|
3430
|
+
const end = text.indexOf(FIX_END, bodyStart);
|
|
3431
|
+
if (end < 0) break;
|
|
3432
|
+
let body = text.slice(bodyStart, end).trim();
|
|
3433
|
+
body = body.replace(/^###[^\n]*\n+/, "").trim();
|
|
3434
|
+
if (slug && body) out.set(slug, body);
|
|
3435
|
+
i = end + FIX_END.length;
|
|
3436
|
+
}
|
|
3437
|
+
return out;
|
|
3357
3438
|
}
|
|
3358
3439
|
async function lintCmd(cfg, fix) {
|
|
3359
3440
|
const pages = loadPages();
|
|
@@ -3367,19 +3448,56 @@ async function lintCmd(cfg, fix) {
|
|
|
3367
3448
|
return;
|
|
3368
3449
|
}
|
|
3369
3450
|
if (report?.note) console.log(c.dim("\n " + report.note));
|
|
3370
|
-
if (fix) {
|
|
3371
|
-
console.log(c.dim(
|
|
3372
|
-
|
|
3451
|
+
if (!fix) {
|
|
3452
|
+
console.log(c.dim(`
|
|
3453
|
+
Auditing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
|
|
3454
|
+
`));
|
|
3455
|
+
const res2 = await runMaintainer(maintainer, cfg, buildLintPrompt(pages));
|
|
3456
|
+
if (!res2.ok || !res2.text.trim()) {
|
|
3457
|
+
console.log(c.dim(" Lint could not produce a report (maintainer returned nothing).\n"));
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
console.log(res2.text.trim() + "\n");
|
|
3461
|
+
return;
|
|
3373
3462
|
}
|
|
3463
|
+
console.log(c.dim("\n --fix will rewrite affected pages. Put .holt/wiki under git or back it up first;"));
|
|
3464
|
+
console.log(c.dim(' a bad fix is fully recoverable with "holt wiki rebuild".'));
|
|
3374
3465
|
console.log(c.dim(`
|
|
3375
|
-
Auditing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
|
|
3466
|
+
Auditing + fixing ${pages.length} page${pages.length === 1 ? "" : "s"} via ${maintainer.label}...
|
|
3376
3467
|
`));
|
|
3377
|
-
const res = await runMaintainer(maintainer, cfg,
|
|
3468
|
+
const res = await runMaintainer(maintainer, cfg, buildFixPrompt(pages));
|
|
3378
3469
|
if (!res.ok || !res.text.trim()) {
|
|
3379
|
-
console.log(c.dim(" Lint could not produce
|
|
3470
|
+
console.log(c.dim(" Lint --fix could not produce corrections (maintainer returned nothing). Files untouched.\n"));
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
const corrected = parseFixReply(res.text);
|
|
3474
|
+
if (corrected.size === 0) {
|
|
3475
|
+
console.log(c.dim(" No parseable corrections returned. Files untouched.\n"));
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
const byPage = new Map(pages.map((p) => [p.slug, p]));
|
|
3479
|
+
const applied = [];
|
|
3480
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3481
|
+
for (const [slug, body] of corrected) {
|
|
3482
|
+
const page = byPage.get(slug);
|
|
3483
|
+
if (!page) continue;
|
|
3484
|
+
if (body.trim() === page.body.trim()) continue;
|
|
3485
|
+
try {
|
|
3486
|
+
writePage({ ...page, body: body.trim(), updated: today });
|
|
3487
|
+
applied.push(page.title);
|
|
3488
|
+
} catch {
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
if (applied.length === 0) {
|
|
3492
|
+
console.log(c.dim(" Lint found nothing to change. Pages already clean.\n"));
|
|
3380
3493
|
return;
|
|
3381
3494
|
}
|
|
3382
|
-
console.log(
|
|
3495
|
+
console.log("\n" + c.accent("Wiki lint applied") + c.dim(" (this folder)"));
|
|
3496
|
+
console.log(` pages rewritten ${applied.length}`);
|
|
3497
|
+
for (const t of applied) console.log(c.dim(` - ${t}`));
|
|
3498
|
+
console.log(c.dim(`
|
|
3499
|
+
Recover any page with "holt wiki rebuild". Wiki: ${wikiDir()}
|
|
3500
|
+
`));
|
|
3383
3501
|
}
|
|
3384
3502
|
function listCmd() {
|
|
3385
3503
|
const slugs = listPageSlugs();
|
|
@@ -3420,6 +3538,7 @@ async function statusCmd(cfg) {
|
|
|
3420
3538
|
console.log("\n" + c.accent("Holt wiki") + c.dim(" (this folder)"));
|
|
3421
3539
|
console.log(` maintainer ${cfg.wiki.maintainer}`);
|
|
3422
3540
|
console.log(` localModel ${cfg.wiki.localModel}`);
|
|
3541
|
+
console.log(` auto-sync ${cfg.wiki.autoSync ? c.green("on") : c.dim("off")}${cfg.wiki.autoSync ? "" : c.dim(" (holt wiki auto on)")}`);
|
|
3423
3542
|
console.log(` pages ${stats.pages}`);
|
|
3424
3543
|
console.log(` size ${(stats.bytes / 1024).toFixed(1)} KB (./.holt/wiki/)`);
|
|
3425
3544
|
console.log(` last sync ${lastSync}`);
|
|
@@ -3434,11 +3553,33 @@ async function statusCmd(cfg) {
|
|
|
3434
3553
|
if (rec.discourageLocal) console.log(c.dim(" Local maintenance is discouraged at this RAM; the brain maintainer is recommended."));
|
|
3435
3554
|
console.log(c.dim(` Pull it with: ${rec.pull}`));
|
|
3436
3555
|
console.log(c.dim("\n holt wiki sync fold new facts into pages"));
|
|
3556
|
+
console.log(c.dim(" holt wiki auto [on|off] auto-sync the wiki when a session ends"));
|
|
3437
3557
|
console.log(c.dim(" holt wiki rebuild regenerate the whole wiki from facts"));
|
|
3438
|
-
console.log(c.dim(" holt wiki lint
|
|
3558
|
+
console.log(c.dim(" holt wiki lint [--fix] audit (and with --fix, apply) contradiction/duplicate fixes"));
|
|
3439
3559
|
console.log(c.dim(" holt wiki list / show <p> browse pages"));
|
|
3440
3560
|
console.log(c.dim(" holt wiki open open in your default app (Obsidian reads it natively)\n"));
|
|
3441
3561
|
}
|
|
3562
|
+
function autoCmd(cfg, arg) {
|
|
3563
|
+
const want = (arg || "").toLowerCase();
|
|
3564
|
+
if (want === "") {
|
|
3565
|
+
const on2 = cfg.wiki.autoSync === true;
|
|
3566
|
+
console.log("\n" + c.accent("Wiki auto-sync") + c.dim(" (this folder)"));
|
|
3567
|
+
console.log(` ${on2 ? c.green("on") : c.dim("off")}`);
|
|
3568
|
+
console.log(c.dim(" usage: holt wiki auto on | off"));
|
|
3569
|
+
console.log(c.dim(" When on, the wiki folds new facts in automatically after a chat session"));
|
|
3570
|
+
console.log(c.dim(' or when the Claude Code Stop hook captures facts. No manual "holt wiki sync".\n'));
|
|
3571
|
+
return;
|
|
3572
|
+
}
|
|
3573
|
+
if (want !== "on" && want !== "off") {
|
|
3574
|
+
console.log(c.dim("\n usage: holt wiki auto on | off\n"));
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
cfg.wiki.autoSync = want === "on";
|
|
3578
|
+
saveConfig(cfg);
|
|
3579
|
+
console.log(
|
|
3580
|
+
want === "on" ? c.green("\n wiki auto-sync: on") + c.dim(" (syncs at session end)\n") : c.dim("\n wiki auto-sync: off\n")
|
|
3581
|
+
);
|
|
3582
|
+
}
|
|
3442
3583
|
function setupCmd(cfg) {
|
|
3443
3584
|
const rec = recommendLocalModel();
|
|
3444
3585
|
console.log("\n" + c.accent("Local wiki maintainer: model recommendation"));
|
|
@@ -3505,6 +3646,9 @@ async function wikiCmd(sub, rest = []) {
|
|
|
3505
3646
|
case "sync":
|
|
3506
3647
|
await syncCmd(cfg);
|
|
3507
3648
|
break;
|
|
3649
|
+
case "auto":
|
|
3650
|
+
autoCmd(cfg, rest[0] ?? "");
|
|
3651
|
+
break;
|
|
3508
3652
|
case "rebuild":
|
|
3509
3653
|
await rebuildCmd(cfg, ask);
|
|
3510
3654
|
break;
|
|
@@ -3530,7 +3674,7 @@ async function wikiCmd(sub, rest = []) {
|
|
|
3530
3674
|
break;
|
|
3531
3675
|
default:
|
|
3532
3676
|
console.error(`
|
|
3533
|
-
Unknown wiki subcommand: "${sub}". Use: sync | rebuild | lint | list | show <page> | open | status | setup
|
|
3677
|
+
Unknown wiki subcommand: "${sub}". Use: sync | auto | rebuild | lint | list | show <page> | open | status | setup
|
|
3534
3678
|
`);
|
|
3535
3679
|
process.exitCode = 1;
|
|
3536
3680
|
}
|
|
@@ -3541,19 +3685,171 @@ async function wikiCmd(sub, rest = []) {
|
|
|
3541
3685
|
|
|
3542
3686
|
// src/commands/skill.ts
|
|
3543
3687
|
import {
|
|
3544
|
-
mkdirSync as
|
|
3545
|
-
writeFileSync as
|
|
3546
|
-
readFileSync as
|
|
3547
|
-
existsSync as
|
|
3688
|
+
mkdirSync as mkdirSync9,
|
|
3689
|
+
writeFileSync as writeFileSync10,
|
|
3690
|
+
readFileSync as readFileSync13,
|
|
3691
|
+
existsSync as existsSync12,
|
|
3548
3692
|
readdirSync as readdirSync3,
|
|
3549
|
-
statSync as
|
|
3693
|
+
statSync as statSync5,
|
|
3550
3694
|
rmSync as rmSync5,
|
|
3551
3695
|
cpSync,
|
|
3552
3696
|
mkdtempSync
|
|
3553
3697
|
} from "fs";
|
|
3554
|
-
import { join as
|
|
3698
|
+
import { join as join12, resolve as resolve3 } from "path";
|
|
3555
3699
|
import { tmpdir } from "os";
|
|
3556
3700
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
3701
|
+
|
|
3702
|
+
// src/registry.ts
|
|
3703
|
+
import { readFileSync as readFileSync12, writeFileSync as writeFileSync9, existsSync as existsSync11, mkdirSync as mkdirSync8, statSync as statSync4 } from "fs";
|
|
3704
|
+
import { join as join11, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
3705
|
+
import { pathToFileURL, fileURLToPath } from "url";
|
|
3706
|
+
var DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/holt-os/registry/main/registry.json";
|
|
3707
|
+
var REGISTRY_REPO_URL = "https://github.com/holt-os/registry";
|
|
3708
|
+
var CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
3709
|
+
function registryCachePath() {
|
|
3710
|
+
return join11(GLOBAL_DIR, "registry-cache.json");
|
|
3711
|
+
}
|
|
3712
|
+
function registryUrl() {
|
|
3713
|
+
const env = (process.env.HOLT_REGISTRY_URL || "").trim();
|
|
3714
|
+
return env || DEFAULT_REGISTRY_URL;
|
|
3715
|
+
}
|
|
3716
|
+
function coerceRegistry(value) {
|
|
3717
|
+
if (!value || typeof value !== "object") return null;
|
|
3718
|
+
const obj = value;
|
|
3719
|
+
const skillsRaw = Array.isArray(obj.skills) ? obj.skills : [];
|
|
3720
|
+
const skills = [];
|
|
3721
|
+
for (const row2 of skillsRaw) {
|
|
3722
|
+
if (!row2 || typeof row2 !== "object") continue;
|
|
3723
|
+
const r = row2;
|
|
3724
|
+
const name = typeof r.name === "string" ? r.name.trim() : "";
|
|
3725
|
+
const source = typeof r.source === "string" ? r.source.trim() : "";
|
|
3726
|
+
if (!name || !source) continue;
|
|
3727
|
+
const description = typeof r.description === "string" ? r.description.trim() : "";
|
|
3728
|
+
const author = typeof r.author === "string" ? r.author.trim() : "";
|
|
3729
|
+
const tags = Array.isArray(r.tags) ? r.tags.filter((t) => typeof t === "string").map((t) => t.trim()).filter(Boolean) : void 0;
|
|
3730
|
+
const entry = { name, description, source, author };
|
|
3731
|
+
if (tags && tags.length) entry.tags = tags;
|
|
3732
|
+
skills.push(entry);
|
|
3733
|
+
}
|
|
3734
|
+
const version = typeof obj.version === "number" ? obj.version : 1;
|
|
3735
|
+
return { version, skills };
|
|
3736
|
+
}
|
|
3737
|
+
function parseRegistry(text) {
|
|
3738
|
+
let parsed;
|
|
3739
|
+
try {
|
|
3740
|
+
parsed = JSON.parse(text);
|
|
3741
|
+
} catch {
|
|
3742
|
+
return null;
|
|
3743
|
+
}
|
|
3744
|
+
return coerceRegistry(parsed);
|
|
3745
|
+
}
|
|
3746
|
+
function readCache() {
|
|
3747
|
+
try {
|
|
3748
|
+
const path = registryCachePath();
|
|
3749
|
+
if (!existsSync11(path)) return null;
|
|
3750
|
+
const raw = JSON.parse(readFileSync12(path, "utf8"));
|
|
3751
|
+
if (!raw || typeof raw.fetchedAt !== "number" || typeof raw.url !== "string") return null;
|
|
3752
|
+
const registry = coerceRegistry(raw.registry);
|
|
3753
|
+
if (!registry) return null;
|
|
3754
|
+
return { fetchedAt: raw.fetchedAt, url: raw.url, registry };
|
|
3755
|
+
} catch {
|
|
3756
|
+
return null;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
function writeCache(url, registry) {
|
|
3760
|
+
try {
|
|
3761
|
+
mkdirSync8(GLOBAL_DIR, { recursive: true });
|
|
3762
|
+
const body = { fetchedAt: Date.now(), url, registry };
|
|
3763
|
+
writeFileSync9(registryCachePath(), JSON.stringify(body, null, 2) + "\n", "utf8");
|
|
3764
|
+
} catch {
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
function localPathFor(loc) {
|
|
3768
|
+
if (loc.startsWith("file://")) {
|
|
3769
|
+
try {
|
|
3770
|
+
return fileURLToPath(loc);
|
|
3771
|
+
} catch {
|
|
3772
|
+
return null;
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
if (/^https?:\/\//i.test(loc)) return null;
|
|
3776
|
+
return isAbsolute2(loc) ? loc : resolve2(loc);
|
|
3777
|
+
}
|
|
3778
|
+
var UNREACHABLE_MSG = "no registry reachable; set HOLT_REGISTRY_URL or the community registry is not live yet";
|
|
3779
|
+
function loadLocal(path) {
|
|
3780
|
+
try {
|
|
3781
|
+
if (!existsSync11(path)) {
|
|
3782
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3783
|
+
}
|
|
3784
|
+
const registry = parseRegistry(readFileSync12(path, "utf8"));
|
|
3785
|
+
if (!registry) {
|
|
3786
|
+
return { registry: null, fromCache: false, error: "registry file is not valid JSON" };
|
|
3787
|
+
}
|
|
3788
|
+
return { registry, fromCache: false };
|
|
3789
|
+
} catch {
|
|
3790
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
async function loadRemote(url) {
|
|
3794
|
+
try {
|
|
3795
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
|
|
3796
|
+
if (!res.ok) {
|
|
3797
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3798
|
+
}
|
|
3799
|
+
const registry = parseRegistry(await res.text());
|
|
3800
|
+
if (!registry) {
|
|
3801
|
+
return { registry: null, fromCache: false, error: "registry response is not valid JSON" };
|
|
3802
|
+
}
|
|
3803
|
+
return { registry, fromCache: false };
|
|
3804
|
+
} catch {
|
|
3805
|
+
return { registry: null, fromCache: false, error: UNREACHABLE_MSG };
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
async function loadRegistry(opts = {}) {
|
|
3809
|
+
const url = registryUrl();
|
|
3810
|
+
const cached = readCache();
|
|
3811
|
+
const fresh = cached && cached.url === url && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
|
|
3812
|
+
if (!opts.refresh && fresh && cached) {
|
|
3813
|
+
return { registry: cached.registry, fromCache: true };
|
|
3814
|
+
}
|
|
3815
|
+
const local = localPathFor(url);
|
|
3816
|
+
const result = local ? loadLocal(local) : await loadRemote(url);
|
|
3817
|
+
if (result.registry) {
|
|
3818
|
+
writeCache(url, result.registry);
|
|
3819
|
+
return result;
|
|
3820
|
+
}
|
|
3821
|
+
if (cached && cached.url === url) {
|
|
3822
|
+
return { registry: cached.registry, fromCache: true, error: result.error };
|
|
3823
|
+
}
|
|
3824
|
+
return result;
|
|
3825
|
+
}
|
|
3826
|
+
function searchSkills(skills, query) {
|
|
3827
|
+
const q = query.trim().toLowerCase();
|
|
3828
|
+
if (!q) return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
|
3829
|
+
const rank = (s) => {
|
|
3830
|
+
if (s.name.toLowerCase().includes(q)) return 0;
|
|
3831
|
+
if (s.description.toLowerCase().includes(q)) return 1;
|
|
3832
|
+
if ((s.tags || []).some((t) => t.toLowerCase().includes(q))) return 2;
|
|
3833
|
+
return 3;
|
|
3834
|
+
};
|
|
3835
|
+
return skills.map((s) => ({ s, r: rank(s) })).filter((x) => x.r < 3).sort((a, b) => a.r !== b.r ? a.r - b.r : a.s.name.localeCompare(b.s.name)).map((x) => x.s);
|
|
3836
|
+
}
|
|
3837
|
+
function resolveByName(skills, name) {
|
|
3838
|
+
const want = name.trim().toLowerCase();
|
|
3839
|
+
return skills.find((s) => s.name.toLowerCase() === want) || null;
|
|
3840
|
+
}
|
|
3841
|
+
function buildPublishEntry(input) {
|
|
3842
|
+
const entry = {
|
|
3843
|
+
name: input.name,
|
|
3844
|
+
description: input.description,
|
|
3845
|
+
source: input.source,
|
|
3846
|
+
author: input.author
|
|
3847
|
+
};
|
|
3848
|
+
if (input.tags && input.tags.length) entry.tags = input.tags;
|
|
3849
|
+
return JSON.stringify(entry, null, 2).split("\n").map((l) => " " + l).join("\n");
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
// src/commands/skill.ts
|
|
3557
3853
|
function usage() {
|
|
3558
3854
|
console.log(c.dim([
|
|
3559
3855
|
"",
|
|
@@ -3562,11 +3858,14 @@ function usage() {
|
|
|
3562
3858
|
" holt skill list list installed skills",
|
|
3563
3859
|
" holt skill show <name> print a skill",
|
|
3564
3860
|
" holt skill create <name> [--global] scaffold a new skill",
|
|
3565
|
-
" holt skill
|
|
3861
|
+
" holt skill search <query> find skills in the registry",
|
|
3862
|
+
" holt skill add <src|name> [--global] install from a git URL, path, or registry name",
|
|
3863
|
+
" holt skill publish [<name>] prepare a skill for the registry (prints a PR entry)",
|
|
3566
3864
|
" holt skill remove <name> delete a skill",
|
|
3567
3865
|
"",
|
|
3568
3866
|
" --global installs into ~/.holt/skills (available in every folder).",
|
|
3569
3867
|
" Without it, skills live in this folder at ./.holt/skills.",
|
|
3868
|
+
" search/add-by-name use the registry (HOLT_REGISTRY_URL or the community index).",
|
|
3570
3869
|
""
|
|
3571
3870
|
].join("\n")));
|
|
3572
3871
|
}
|
|
@@ -3597,12 +3896,12 @@ function isGitUrl(s) {
|
|
|
3597
3896
|
return s.includes("://") || s.endsWith(".git") || s.startsWith("git@");
|
|
3598
3897
|
}
|
|
3599
3898
|
function findSkillDir(root) {
|
|
3600
|
-
if (
|
|
3899
|
+
if (existsSync12(join12(root, "SKILL.md"))) return root;
|
|
3601
3900
|
let subs = [];
|
|
3602
3901
|
try {
|
|
3603
3902
|
subs = readdirSync3(root).filter((f) => {
|
|
3604
3903
|
try {
|
|
3605
|
-
return
|
|
3904
|
+
return statSync5(join12(root, f)).isDirectory();
|
|
3606
3905
|
} catch {
|
|
3607
3906
|
return false;
|
|
3608
3907
|
}
|
|
@@ -3610,8 +3909,8 @@ function findSkillDir(root) {
|
|
|
3610
3909
|
} catch {
|
|
3611
3910
|
return null;
|
|
3612
3911
|
}
|
|
3613
|
-
const withSkill = subs.filter((f) =>
|
|
3614
|
-
if (withSkill.length === 1) return
|
|
3912
|
+
const withSkill = subs.filter((f) => existsSync12(join12(root, f, "SKILL.md")));
|
|
3913
|
+
if (withSkill.length === 1) return join12(root, withSkill[0]);
|
|
3615
3914
|
return null;
|
|
3616
3915
|
}
|
|
3617
3916
|
function cmdList() {
|
|
@@ -3651,7 +3950,7 @@ function cmdShow(name) {
|
|
|
3651
3950
|
const { skill, body } = loaded;
|
|
3652
3951
|
console.log("\n" + c.accent(skill.name) + c.dim(` (${skill.scope})`));
|
|
3653
3952
|
if (skill.description) console.log(" " + skill.description);
|
|
3654
|
-
console.log(c.dim(" " +
|
|
3953
|
+
console.log(c.dim(" " + join12(skill.dir, "SKILL.md")));
|
|
3655
3954
|
console.log("");
|
|
3656
3955
|
console.log(c.dim(body));
|
|
3657
3956
|
console.log("");
|
|
@@ -3663,60 +3962,55 @@ function cmdCreate(name, global) {
|
|
|
3663
3962
|
return;
|
|
3664
3963
|
}
|
|
3665
3964
|
const scope = global ? "global" : "workspace";
|
|
3666
|
-
const dir =
|
|
3667
|
-
if (
|
|
3965
|
+
const dir = join12(skillsRoot(scope), clean);
|
|
3966
|
+
if (existsSync12(dir)) {
|
|
3668
3967
|
console.error(c.red(`
|
|
3669
3968
|
A ${scope} skill named "${clean}" already exists.`));
|
|
3670
3969
|
console.error(c.dim(" " + dir + "\n"));
|
|
3671
3970
|
process.exitCode = 1;
|
|
3672
3971
|
return;
|
|
3673
3972
|
}
|
|
3674
|
-
|
|
3675
|
-
|
|
3973
|
+
mkdirSync9(dir, { recursive: true });
|
|
3974
|
+
writeFileSync10(join12(dir, "SKILL.md"), SKILL_TEMPLATE(clean), "utf8");
|
|
3676
3975
|
console.log(c.green(`
|
|
3677
3976
|
Created ${scope} skill "${clean}".`));
|
|
3678
|
-
console.log(c.dim(" " +
|
|
3977
|
+
console.log(c.dim(" " + join12(dir, "SKILL.md")));
|
|
3679
3978
|
console.log(c.dim(' Edit it, then run it in chat with "/skill ' + clean + '".\n'));
|
|
3680
3979
|
}
|
|
3681
|
-
function
|
|
3682
|
-
if (!source) {
|
|
3683
|
-
console.log(c.dim("\n Usage: holt skill add <git-url|path> [--global]\n"));
|
|
3684
|
-
return;
|
|
3685
|
-
}
|
|
3686
|
-
const scope = global ? "global" : "workspace";
|
|
3980
|
+
function installFromSource(source, scope) {
|
|
3687
3981
|
let fetched = "";
|
|
3688
3982
|
let tempDir = "";
|
|
3689
3983
|
try {
|
|
3690
3984
|
if (isGitUrl(source)) {
|
|
3691
|
-
tempDir = mkdtempSync(
|
|
3985
|
+
tempDir = mkdtempSync(join12(tmpdir(), "holt-skill-"));
|
|
3692
3986
|
console.log(c.dim(`
|
|
3693
3987
|
Cloning ${source} ...`));
|
|
3694
3988
|
const res = spawnSync2("git", ["clone", "--depth", "1", source, tempDir], { stdio: "ignore" });
|
|
3695
3989
|
if (res.status !== 0) {
|
|
3696
3990
|
console.error(c.red(" Clone failed. Check the URL and that git is installed.\n"));
|
|
3697
3991
|
process.exitCode = 1;
|
|
3698
|
-
return;
|
|
3992
|
+
return false;
|
|
3699
3993
|
}
|
|
3700
3994
|
fetched = tempDir;
|
|
3701
3995
|
} else {
|
|
3702
|
-
fetched =
|
|
3703
|
-
if (!
|
|
3996
|
+
fetched = resolve3(source);
|
|
3997
|
+
if (!existsSync12(fetched)) {
|
|
3704
3998
|
console.error(c.red(`
|
|
3705
3999
|
No such path: ${fetched}
|
|
3706
4000
|
`));
|
|
3707
4001
|
process.exitCode = 1;
|
|
3708
|
-
return;
|
|
4002
|
+
return false;
|
|
3709
4003
|
}
|
|
3710
4004
|
}
|
|
3711
4005
|
const skillDir = findSkillDir(fetched);
|
|
3712
4006
|
if (!skillDir) {
|
|
3713
4007
|
console.error(c.red("\n Could not find a SKILL.md at the source root or in a single subfolder.\n"));
|
|
3714
4008
|
process.exitCode = 1;
|
|
3715
|
-
return;
|
|
4009
|
+
return false;
|
|
3716
4010
|
}
|
|
3717
4011
|
let skillName = "";
|
|
3718
4012
|
try {
|
|
3719
|
-
const { data } = parseFrontmatter(
|
|
4013
|
+
const { data } = parseFrontmatter(readFileSync13(join12(skillDir, "SKILL.md"), "utf8"));
|
|
3720
4014
|
skillName = sanitizeName(data.name || "");
|
|
3721
4015
|
} catch {
|
|
3722
4016
|
skillName = "";
|
|
@@ -3725,25 +4019,26 @@ function cmdAdd(source, global) {
|
|
|
3725
4019
|
if (!skillName) {
|
|
3726
4020
|
console.error(c.red("\n Could not determine a valid skill name.\n"));
|
|
3727
4021
|
process.exitCode = 1;
|
|
3728
|
-
return;
|
|
4022
|
+
return false;
|
|
3729
4023
|
}
|
|
3730
|
-
const dest =
|
|
3731
|
-
if (
|
|
4024
|
+
const dest = join12(skillsRoot(scope), skillName);
|
|
4025
|
+
if (existsSync12(dest)) {
|
|
3732
4026
|
console.error(c.red(`
|
|
3733
4027
|
A ${scope} skill named "${skillName}" already exists.`));
|
|
3734
4028
|
console.error(c.dim(" " + dest + "\n"));
|
|
3735
4029
|
process.exitCode = 1;
|
|
3736
|
-
return;
|
|
4030
|
+
return false;
|
|
3737
4031
|
}
|
|
3738
|
-
|
|
4032
|
+
mkdirSync9(skillsRoot(scope), { recursive: true });
|
|
3739
4033
|
cpSync(skillDir, dest, { recursive: true });
|
|
3740
|
-
const nestedGit =
|
|
3741
|
-
if (
|
|
4034
|
+
const nestedGit = join12(dest, ".git");
|
|
4035
|
+
if (existsSync12(nestedGit)) rmSync5(nestedGit, { recursive: true, force: true });
|
|
3742
4036
|
console.log(c.green(`
|
|
3743
4037
|
Installed ${scope} skill "${skillName}".`));
|
|
3744
|
-
console.log(c.dim(" " +
|
|
4038
|
+
console.log(c.dim(" " + join12(dest, "SKILL.md") + "\n"));
|
|
4039
|
+
return true;
|
|
3745
4040
|
} finally {
|
|
3746
|
-
if (tempDir &&
|
|
4041
|
+
if (tempDir && existsSync12(tempDir)) {
|
|
3747
4042
|
try {
|
|
3748
4043
|
rmSync5(tempDir, { recursive: true, force: true });
|
|
3749
4044
|
} catch {
|
|
@@ -3751,15 +4046,140 @@ function cmdAdd(source, global) {
|
|
|
3751
4046
|
}
|
|
3752
4047
|
}
|
|
3753
4048
|
}
|
|
4049
|
+
async function cmdAdd(source, global, refresh) {
|
|
4050
|
+
if (!source) {
|
|
4051
|
+
console.log(c.dim("\n Usage: holt skill add <git-url|path|name> [--global]\n"));
|
|
4052
|
+
return;
|
|
4053
|
+
}
|
|
4054
|
+
const scope = global ? "global" : "workspace";
|
|
4055
|
+
if (isGitUrl(source) || existsSync12(resolve3(source))) {
|
|
4056
|
+
installFromSource(source, scope);
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
const { registry, error } = await loadRegistry({ refresh });
|
|
4060
|
+
if (!registry) {
|
|
4061
|
+
console.error(c.red(`
|
|
4062
|
+
"${source}" is not a git URL or a local path, and the registry is unavailable.`));
|
|
4063
|
+
console.error(c.dim(" " + (error || "no registry reachable") + "\n"));
|
|
4064
|
+
process.exitCode = 1;
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
4067
|
+
const hit = resolveByName(registry.skills, source);
|
|
4068
|
+
if (!hit) {
|
|
4069
|
+
console.error(c.red(`
|
|
4070
|
+
No skill named "${source}" in the registry.`));
|
|
4071
|
+
console.error(c.dim(' Try "holt skill search ' + source + '" to find one.\n'));
|
|
4072
|
+
process.exitCode = 1;
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
console.log(c.dim(`
|
|
4076
|
+
Resolved "${hit.name}" -> ${hit.source}`));
|
|
4077
|
+
installFromSource(hit.source, scope);
|
|
4078
|
+
}
|
|
4079
|
+
async function cmdSearch(query, refresh) {
|
|
4080
|
+
const { registry, error, fromCache } = await loadRegistry({ refresh });
|
|
4081
|
+
if (!registry) {
|
|
4082
|
+
console.error(c.red("\n Registry unavailable."));
|
|
4083
|
+
console.error(c.dim(" " + (error || "no registry reachable") + "\n"));
|
|
4084
|
+
process.exitCode = 1;
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
4087
|
+
const hits = searchSkills(registry.skills, query);
|
|
4088
|
+
if (hits.length === 0) {
|
|
4089
|
+
const what = query.trim() ? `matching "${query.trim()}"` : "in the registry";
|
|
4090
|
+
console.log(c.dim(`
|
|
4091
|
+
No skills ${what}.
|
|
4092
|
+
`));
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
const nameW = Math.max(4, ...hits.map((s) => s.name.length));
|
|
4096
|
+
const authorW = Math.max(6, ...hits.map((s) => (s.author || "").length));
|
|
4097
|
+
console.log(
|
|
4098
|
+
"\n" + c.accent("Registry") + c.dim(" " + registryUrl() + (fromCache ? " (cached)" : ""))
|
|
4099
|
+
);
|
|
4100
|
+
console.log(
|
|
4101
|
+
" " + c.dim("name".padEnd(nameW)) + " " + c.dim("author".padEnd(authorW)) + " " + c.dim("description")
|
|
4102
|
+
);
|
|
4103
|
+
for (const s of hits) {
|
|
4104
|
+
const desc = s.description.length > 56 ? s.description.slice(0, 55) + "\u2026" : s.description;
|
|
4105
|
+
console.log(
|
|
4106
|
+
" " + c.bold(s.name.padEnd(nameW)) + " " + c.dim((s.author || "").padEnd(authorW)) + " " + (desc || c.dim("(no description)"))
|
|
4107
|
+
);
|
|
4108
|
+
console.log(" " + " ".repeat(nameW) + " " + " ".repeat(authorW) + " " + c.dim(s.source));
|
|
4109
|
+
}
|
|
4110
|
+
console.log(c.dim('\n Install one with "holt skill add <name>".\n'));
|
|
4111
|
+
}
|
|
4112
|
+
function cmdPublish(name) {
|
|
4113
|
+
let skillName = "";
|
|
4114
|
+
let description = "";
|
|
4115
|
+
let sourceHint = "";
|
|
4116
|
+
if (name) {
|
|
4117
|
+
const loaded = loadSkill(name);
|
|
4118
|
+
if (!loaded) {
|
|
4119
|
+
console.error(c.red(`
|
|
4120
|
+
No skill named "${sanitizeName(name)}". Try "holt skill list".
|
|
4121
|
+
`));
|
|
4122
|
+
process.exitCode = 1;
|
|
4123
|
+
return;
|
|
4124
|
+
}
|
|
4125
|
+
let raw = "";
|
|
4126
|
+
try {
|
|
4127
|
+
raw = readFileSync13(join12(loaded.skill.dir, "SKILL.md"), "utf8");
|
|
4128
|
+
} catch {
|
|
4129
|
+
console.error(c.red("\n Could not read the skill's SKILL.md.\n"));
|
|
4130
|
+
process.exitCode = 1;
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
const { data } = parseFrontmatter(raw);
|
|
4134
|
+
skillName = sanitizeName(data.name || loaded.skill.name);
|
|
4135
|
+
description = (data.description || loaded.skill.description || "").trim();
|
|
4136
|
+
} else {
|
|
4137
|
+
const file = join12(process.cwd(), "SKILL.md");
|
|
4138
|
+
if (!existsSync12(file)) {
|
|
4139
|
+
console.error(c.red("\n No SKILL.md in the current directory, and no skill name given."));
|
|
4140
|
+
console.error(c.dim(" Usage: holt skill publish [<name>]\n"));
|
|
4141
|
+
process.exitCode = 1;
|
|
4142
|
+
return;
|
|
4143
|
+
}
|
|
4144
|
+
const { data } = parseFrontmatter(readFileSync13(file, "utf8"));
|
|
4145
|
+
skillName = sanitizeName(data.name || process.cwd().split(/[\\/]/).pop() || "");
|
|
4146
|
+
description = (data.description || "").trim();
|
|
4147
|
+
sourceHint = process.cwd();
|
|
4148
|
+
}
|
|
4149
|
+
if (!skillName || !description) {
|
|
4150
|
+
console.error(c.red("\n This skill is missing a name or description in its SKILL.md frontmatter."));
|
|
4151
|
+
console.error(c.dim(" Both are required to publish. Add: name: <name> and description: <one line>\n"));
|
|
4152
|
+
process.exitCode = 1;
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
const entry = buildPublishEntry({
|
|
4156
|
+
name: skillName,
|
|
4157
|
+
description,
|
|
4158
|
+
source: sourceHint || `https://github.com/<you>/${skillName}`,
|
|
4159
|
+
author: "<your name or handle>",
|
|
4160
|
+
tags: []
|
|
4161
|
+
});
|
|
4162
|
+
console.log("\n" + c.accent(`Publish "${skillName}" to the Holt skill registry`));
|
|
4163
|
+
console.log(c.dim(" The registry is a git-based JSON index. Publishing = opening a PR that adds your entry.\n"));
|
|
4164
|
+
console.log(c.dim(" 1. Push this skill to a public git repo (or note a subpath spec Holt can clone)."));
|
|
4165
|
+
console.log(c.dim(' 2. Add this entry to the "skills" array in registry.json:'));
|
|
4166
|
+
console.log("");
|
|
4167
|
+
console.log(entry);
|
|
4168
|
+
console.log("");
|
|
4169
|
+
console.log(c.dim(' Replace the "source" (your public git URL or path) and "author" fields.'));
|
|
4170
|
+
console.log(c.dim(" 3. Open a pull request against the registry repo:"));
|
|
4171
|
+
console.log(c.dim(" " + REGISTRY_REPO_URL));
|
|
4172
|
+
console.log(c.dim('\n No push happens here. Once merged, anyone can "holt skill add ' + skillName + '".\n'));
|
|
4173
|
+
}
|
|
3754
4174
|
async function cmdRemove(name, ask) {
|
|
3755
4175
|
const clean = sanitizeName(name || "");
|
|
3756
4176
|
if (!clean) {
|
|
3757
4177
|
console.log(c.dim("\n Usage: holt skill remove <name>\n"));
|
|
3758
4178
|
return;
|
|
3759
4179
|
}
|
|
3760
|
-
const wsDir =
|
|
3761
|
-
const globalDir =
|
|
3762
|
-
const target =
|
|
4180
|
+
const wsDir = join12(skillsRoot("workspace"), clean);
|
|
4181
|
+
const globalDir = join12(skillsRoot("global"), clean);
|
|
4182
|
+
const target = existsSync12(wsDir) ? wsDir : existsSync12(globalDir) ? globalDir : "";
|
|
3763
4183
|
if (!target) {
|
|
3764
4184
|
console.error(c.dim(`
|
|
3765
4185
|
No skill named "${clean}" found.
|
|
@@ -3784,7 +4204,8 @@ async function skillCmd(sub, rest = []) {
|
|
|
3784
4204
|
return;
|
|
3785
4205
|
}
|
|
3786
4206
|
const global = rest.includes("--global");
|
|
3787
|
-
const
|
|
4207
|
+
const refresh = rest.includes("--refresh");
|
|
4208
|
+
const args = rest.filter((a) => a !== "--global" && a !== "--refresh");
|
|
3788
4209
|
const action = (sub || "").toLowerCase();
|
|
3789
4210
|
try {
|
|
3790
4211
|
switch (action) {
|
|
@@ -3800,9 +4221,16 @@ async function skillCmd(sub, rest = []) {
|
|
|
3800
4221
|
case "new":
|
|
3801
4222
|
cmdCreate(args[0], global);
|
|
3802
4223
|
break;
|
|
4224
|
+
case "search":
|
|
4225
|
+
case "find":
|
|
4226
|
+
await cmdSearch(args.join(" "), refresh);
|
|
4227
|
+
break;
|
|
3803
4228
|
case "add":
|
|
3804
4229
|
case "install":
|
|
3805
|
-
cmdAdd(args[0], global);
|
|
4230
|
+
await cmdAdd(args[0], global, refresh);
|
|
4231
|
+
break;
|
|
4232
|
+
case "publish":
|
|
4233
|
+
cmdPublish(args[0]);
|
|
3806
4234
|
break;
|
|
3807
4235
|
case "remove":
|
|
3808
4236
|
case "rm":
|
|
@@ -3819,10 +4247,74 @@ async function skillCmd(sub, rest = []) {
|
|
|
3819
4247
|
}
|
|
3820
4248
|
|
|
3821
4249
|
// src/commands/graph.ts
|
|
3822
|
-
import { writeFileSync as
|
|
3823
|
-
import { join as
|
|
4250
|
+
import { writeFileSync as writeFileSync11, mkdirSync as mkdirSync10, existsSync as existsSync13 } from "fs";
|
|
4251
|
+
import { join as join14, dirname as dirname3, resolve as resolve4 } from "path";
|
|
3824
4252
|
import { spawn as spawn4 } from "child_process";
|
|
3825
4253
|
|
|
4254
|
+
// src/communities.ts
|
|
4255
|
+
var MAX_ITERS = 30;
|
|
4256
|
+
function detectCommunities(input) {
|
|
4257
|
+
const { nodeIds, edges } = input;
|
|
4258
|
+
const order = nodeIds.slice();
|
|
4259
|
+
const idx = /* @__PURE__ */ new Map();
|
|
4260
|
+
order.forEach((id, i) => idx.set(id, i));
|
|
4261
|
+
const adj = order.map(() => /* @__PURE__ */ new Map());
|
|
4262
|
+
for (const e of edges) {
|
|
4263
|
+
const a = idx.get(e.source);
|
|
4264
|
+
const b = idx.get(e.target);
|
|
4265
|
+
if (a === void 0 || b === void 0 || a === b) continue;
|
|
4266
|
+
const am = adj[a];
|
|
4267
|
+
const bm = adj[b];
|
|
4268
|
+
am.set(b, (am.get(b) || 0) + 1);
|
|
4269
|
+
bm.set(a, (bm.get(a) || 0) + 1);
|
|
4270
|
+
}
|
|
4271
|
+
const label = order.map((_, i) => i);
|
|
4272
|
+
for (let iter = 0; iter < MAX_ITERS; iter++) {
|
|
4273
|
+
let changed = false;
|
|
4274
|
+
for (let i = 0; i < order.length; i++) {
|
|
4275
|
+
const neigh = adj[i];
|
|
4276
|
+
if (neigh.size === 0) continue;
|
|
4277
|
+
const tally = /* @__PURE__ */ new Map();
|
|
4278
|
+
for (const [j, w] of neigh) {
|
|
4279
|
+
const lj = label[j];
|
|
4280
|
+
tally.set(lj, (tally.get(lj) || 0) + w);
|
|
4281
|
+
}
|
|
4282
|
+
let best = label[i];
|
|
4283
|
+
let bestW = -1;
|
|
4284
|
+
for (const [lab, w] of tally) {
|
|
4285
|
+
if (w > bestW || w === bestW && lab < best) {
|
|
4286
|
+
best = lab;
|
|
4287
|
+
bestW = w;
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
if (best !== label[i]) {
|
|
4291
|
+
label[i] = best;
|
|
4292
|
+
changed = true;
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
if (!changed) break;
|
|
4296
|
+
}
|
|
4297
|
+
const remap = /* @__PURE__ */ new Map();
|
|
4298
|
+
const community = /* @__PURE__ */ new Map();
|
|
4299
|
+
const members = /* @__PURE__ */ new Map();
|
|
4300
|
+
for (let i = 0; i < order.length; i++) {
|
|
4301
|
+
const raw = label[i];
|
|
4302
|
+
let cid = remap.get(raw);
|
|
4303
|
+
if (cid === void 0) {
|
|
4304
|
+
cid = String(remap.size);
|
|
4305
|
+
remap.set(raw, cid);
|
|
4306
|
+
}
|
|
4307
|
+
const id = order[i];
|
|
4308
|
+
community.set(id, cid);
|
|
4309
|
+
const arr = members.get(cid);
|
|
4310
|
+
if (arr) arr.push(id);
|
|
4311
|
+
else members.set(cid, [id]);
|
|
4312
|
+
}
|
|
4313
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
4314
|
+
for (const [cid, arr] of members) sizes.set(cid, arr.length);
|
|
4315
|
+
return { community, members, sizes, count: members.size };
|
|
4316
|
+
}
|
|
4317
|
+
|
|
3826
4318
|
// src/graphview.ts
|
|
3827
4319
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
3828
4320
|
"the",
|
|
@@ -4017,6 +4509,49 @@ function mergeGraphs(a, b) {
|
|
|
4017
4509
|
}
|
|
4018
4510
|
return { nodes, edges: [...a.edges, ...b.edges] };
|
|
4019
4511
|
}
|
|
4512
|
+
function buildCodeGraph(files, edges) {
|
|
4513
|
+
const nodes = [];
|
|
4514
|
+
const ids = new Set(files.map((f) => f.id));
|
|
4515
|
+
for (const f of files) {
|
|
4516
|
+
const snippet = f.content.replace(/\s+/g, " ").trim().slice(0, 180);
|
|
4517
|
+
nodes.push({
|
|
4518
|
+
id: f.id,
|
|
4519
|
+
kind: f.kind === "code" ? "file" : "doc",
|
|
4520
|
+
label: shortPath(f.id),
|
|
4521
|
+
path: f.id,
|
|
4522
|
+
ext: f.ext,
|
|
4523
|
+
topDir: f.topDir,
|
|
4524
|
+
snippet
|
|
4525
|
+
});
|
|
4526
|
+
}
|
|
4527
|
+
const out = [];
|
|
4528
|
+
for (const e of edges) {
|
|
4529
|
+
if (!ids.has(e.source) || !ids.has(e.target)) continue;
|
|
4530
|
+
out.push({ source: e.source, target: e.target, kind: e.kind, width: e.kind === "dep" ? 1.5 : 1.5 });
|
|
4531
|
+
}
|
|
4532
|
+
return { nodes, edges: out };
|
|
4533
|
+
}
|
|
4534
|
+
function shortPath(p) {
|
|
4535
|
+
const parts = p.split("/");
|
|
4536
|
+
return parts.length <= 2 ? p : parts.slice(-2).join("/");
|
|
4537
|
+
}
|
|
4538
|
+
function assignCommunities(graph2) {
|
|
4539
|
+
const nodeIds = graph2.nodes.map((n) => n.id);
|
|
4540
|
+
const idSet = new Set(nodeIds);
|
|
4541
|
+
const edges = graph2.edges.filter((e) => idSet.has(e.source) && idSet.has(e.target)).map((e) => ({ source: e.source, target: e.target }));
|
|
4542
|
+
const { community } = detectCommunities({ nodeIds, edges });
|
|
4543
|
+
const degree = /* @__PURE__ */ new Map();
|
|
4544
|
+
for (const id of nodeIds) degree.set(id, 0);
|
|
4545
|
+
for (const e of edges) {
|
|
4546
|
+
degree.set(e.source, (degree.get(e.source) || 0) + 1);
|
|
4547
|
+
degree.set(e.target, (degree.get(e.target) || 0) + 1);
|
|
4548
|
+
}
|
|
4549
|
+
for (const n of graph2.nodes) {
|
|
4550
|
+
n.community = community.get(n.id);
|
|
4551
|
+
n.degree = degree.get(n.id) || 0;
|
|
4552
|
+
}
|
|
4553
|
+
return graph2;
|
|
4554
|
+
}
|
|
4020
4555
|
function escapeForScript(json) {
|
|
4021
4556
|
return json.replace(/<\/(script)/gi, "<\\/$1").replace(/<!--/g, "<\\u0021--");
|
|
4022
4557
|
}
|
|
@@ -4123,7 +4658,9 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4123
4658
|
<div class="row"><span class="swatch" style="background:var(--cyan)"></span>assistant</div>
|
|
4124
4659
|
<div class="row"><span class="swatch" style="background:var(--violet)"></span>concept</div>
|
|
4125
4660
|
<div class="row"><span class="swatch" style="background:#5bd66f"></span>wiki page</div>
|
|
4126
|
-
<div class="row"><span class="swatch" style="background
|
|
4661
|
+
<div class="row"><span class="swatch" style="background:#e2704a"></span>code file</div>
|
|
4662
|
+
<div class="row"><span class="swatch" style="background:#4a90d9"></span>doc file</div>
|
|
4663
|
+
<div class="row"><span class="swatch" style="background:var(--line);border:1px solid var(--muted)"></span>ring = session / community</div>
|
|
4127
4664
|
</div>
|
|
4128
4665
|
<div id="hint">drag to pan · wheel to zoom · click a node · Esc to clear</div>
|
|
4129
4666
|
<div id="empty">This graph has no nodes yet.</div>
|
|
@@ -4173,10 +4710,17 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4173
4710
|
});
|
|
4174
4711
|
|
|
4175
4712
|
function radiusOf(n) {
|
|
4176
|
-
var base = n.kind === "concept" ? 3 : n.kind === "wiki" ? 6 : 5;
|
|
4713
|
+
var base = n.kind === "concept" ? 3 : n.kind === "wiki" ? 6 : (n.kind === "file" || n.kind === "doc") ? 5 : 5;
|
|
4177
4714
|
return base + Math.min(9, Math.sqrt(n._deg) * 1.6);
|
|
4178
4715
|
}
|
|
4179
4716
|
|
|
4717
|
+
// ---- community tint: a stable hue per community id ----
|
|
4718
|
+
function communityHue(cid) {
|
|
4719
|
+
var k = parseInt(cid, 10);
|
|
4720
|
+
if (isNaN(k)) k = 0;
|
|
4721
|
+
return (k * 137.508) % 360; // golden-angle spread, same trick as sessions
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4180
4724
|
// ---- initial layout: spread on a spiral so physics has somewhere to start ----
|
|
4181
4725
|
nodes.forEach(function (n, i) {
|
|
4182
4726
|
var ang = i * 2.399963;
|
|
@@ -4188,7 +4732,7 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4188
4732
|
|
|
4189
4733
|
// ---- physics ----
|
|
4190
4734
|
var REPULSION = 5200, SPRING = 0.02, GRAVITY = 0.015, DAMP = 0.86;
|
|
4191
|
-
var restLen = { sequential: 60, semantic: 45, concept: 70 };
|
|
4735
|
+
var restLen = { sequential: 60, semantic: 45, concept: 70, dep: 55, link: 55, wikilink: 55 };
|
|
4192
4736
|
|
|
4193
4737
|
function step() {
|
|
4194
4738
|
var i, j, n, m, dx, dy, d2, d, f;
|
|
@@ -4254,12 +4798,14 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4254
4798
|
function roleColor(n) {
|
|
4255
4799
|
if (n.kind === "wiki") return "#5bd66f";
|
|
4256
4800
|
if (n.kind === "concept") return "#9a8cff";
|
|
4801
|
+
if (n.kind === "file") return "#e2704a";
|
|
4802
|
+
if (n.kind === "doc") return "#4a90d9";
|
|
4257
4803
|
return n.role === "user" ? "#f0b91e" : "#35d0d6";
|
|
4258
4804
|
}
|
|
4259
4805
|
|
|
4260
4806
|
function matchesQuery(n) {
|
|
4261
4807
|
if (!query) return false;
|
|
4262
|
-
var hay = (n.label || "") + " " + (n.content || "");
|
|
4808
|
+
var hay = (n.label || "") + " " + (n.content || "") + " " + (n.path || "") + " " + (n.snippet || "");
|
|
4263
4809
|
return hay.toLowerCase().indexOf(query) >= 0;
|
|
4264
4810
|
}
|
|
4265
4811
|
|
|
@@ -4279,7 +4825,7 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4279
4825
|
}
|
|
4280
4826
|
ctx.globalAlpha = dim;
|
|
4281
4827
|
ctx.lineWidth = Math.max(0.4, lk.width * view.scale * 0.5);
|
|
4282
|
-
ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : lk.kind === "wikilink" ? "#5bd66f" : "#4a5262";
|
|
4828
|
+
ctx.strokeStyle = lk.kind === "semantic" ? "#35d0d6" : lk.kind === "concept" ? "#9a8cff" : lk.kind === "wikilink" ? "#5bd66f" : lk.kind === "dep" ? "#e2704a" : lk.kind === "link" ? "#4a90d9" : "#4a5262";
|
|
4283
4829
|
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
4284
4830
|
}
|
|
4285
4831
|
ctx.globalAlpha = 1;
|
|
@@ -4293,12 +4839,17 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4293
4839
|
var faded = (highlightSet && !highlightSet.has(n.id)) || (query && !matchesQuery(n));
|
|
4294
4840
|
ctx.globalAlpha = faded ? 0.18 : 1;
|
|
4295
4841
|
|
|
4296
|
-
// session ring
|
|
4842
|
+
// session ring (turns) or community ring (files/docs)
|
|
4297
4843
|
if (n.kind === "turn") {
|
|
4298
4844
|
ctx.beginPath();
|
|
4299
4845
|
ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
|
|
4300
4846
|
ctx.strokeStyle = "hsl(" + sessionHue(n.session) + ",70%,58%)";
|
|
4301
4847
|
ctx.lineWidth = 1.6; ctx.stroke();
|
|
4848
|
+
} else if ((n.kind === "file" || n.kind === "doc") && n.community != null) {
|
|
4849
|
+
ctx.beginPath();
|
|
4850
|
+
ctx.arc(p.x, p.y, r + 2.4, 0, Math.PI * 2);
|
|
4851
|
+
ctx.strokeStyle = "hsl(" + communityHue(n.community) + ",70%,58%)";
|
|
4852
|
+
ctx.lineWidth = 2; ctx.stroke();
|
|
4302
4853
|
}
|
|
4303
4854
|
|
|
4304
4855
|
ctx.beginPath();
|
|
@@ -4310,10 +4861,13 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4310
4861
|
ctx.lineWidth = 2; ctx.strokeStyle = "#ffffff"; ctx.stroke();
|
|
4311
4862
|
}
|
|
4312
4863
|
|
|
4313
|
-
// concept + wiki labels always; turn labels when zoomed in
|
|
4314
|
-
|
|
4864
|
+
// concept + wiki labels always; file/doc when zoomed a little; turn labels when zoomed in
|
|
4865
|
+
var showLabel = n.kind === "concept" || n.kind === "wiki"
|
|
4866
|
+
|| ((n.kind === "file" || n.kind === "doc") && view.scale > 0.9)
|
|
4867
|
+
|| view.scale > 1.4;
|
|
4868
|
+
if (showLabel) {
|
|
4315
4869
|
ctx.globalAlpha = faded ? 0.25 : 0.9;
|
|
4316
|
-
ctx.fillStyle = n.kind === "wiki" ? "#5bd66f" : "#e7e9ee";
|
|
4870
|
+
ctx.fillStyle = n.kind === "wiki" ? "#5bd66f" : n.kind === "file" ? "#e2704a" : n.kind === "doc" ? "#4a90d9" : "#e7e9ee";
|
|
4317
4871
|
ctx.font = (n.kind === "wiki" ? 12 : n.kind === "concept" ? 11 : 10) + "px ui-monospace, monospace";
|
|
4318
4872
|
ctx.fillText(n.label, p.x + r + 4, p.y + 3);
|
|
4319
4873
|
}
|
|
@@ -4380,6 +4934,8 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4380
4934
|
? ("concept · in " + (hov.freq || 0) + " turns")
|
|
4381
4935
|
: hov.kind === "wiki"
|
|
4382
4936
|
? "wiki page"
|
|
4937
|
+
: (hov.kind === "file" || hov.kind === "doc")
|
|
4938
|
+
? (esc(hov.path || "") + " · " + hov._deg + " links" + (hov.community != null ? " · community " + esc(String(hov.community)) : ""))
|
|
4383
4939
|
: (esc(hov.session || "") + (when ? " · " + when : ""));
|
|
4384
4940
|
tooltip.innerHTML = "<div>" + esc(hov.label) + "</div><div class='t-meta'>" + meta + "</div>";
|
|
4385
4941
|
tooltip.style.display = "block";
|
|
@@ -4426,6 +4982,9 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4426
4982
|
if (n.kind === "concept" || n.kind === "wiki") {
|
|
4427
4983
|
highlightSet = neighbors(n.id); // light up its connected nodes
|
|
4428
4984
|
closePanel();
|
|
4985
|
+
} else if (n.kind === "file" || n.kind === "doc") {
|
|
4986
|
+
highlightSet = neighbors(n.id); // light up dependencies/links
|
|
4987
|
+
openPanel(n);
|
|
4429
4988
|
} else {
|
|
4430
4989
|
highlightSet = null;
|
|
4431
4990
|
openPanel(n);
|
|
@@ -4439,6 +4998,18 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4439
4998
|
|
|
4440
4999
|
// ---- side panel ----
|
|
4441
5000
|
function openPanel(n) {
|
|
5001
|
+
if (n.kind === "file" || n.kind === "doc") {
|
|
5002
|
+
document.getElementById("p-session").textContent = (n.kind === "file" ? "code" : "doc") + (n.ext ? " (." + n.ext + ")" : "");
|
|
5003
|
+
document.getElementById("p-dot").style.background = roleColor(n);
|
|
5004
|
+
document.getElementById("p-role").textContent = n.path || n.label || "";
|
|
5005
|
+
document.getElementById("p-role").style.color = roleColor(n);
|
|
5006
|
+
// textContent keeps any <script>/<img onerror> in the path or snippet inert.
|
|
5007
|
+
document.getElementById("p-content").textContent = n.snippet || "";
|
|
5008
|
+
document.getElementById("p-time").textContent =
|
|
5009
|
+
(n.community != null ? "community " + n.community + " | " : "") + (n._deg || 0) + " links";
|
|
5010
|
+
panel.classList.add("open");
|
|
5011
|
+
return;
|
|
5012
|
+
}
|
|
4442
5013
|
document.getElementById("p-session").textContent = n.session || "";
|
|
4443
5014
|
document.getElementById("p-dot").style.background = "hsl(" + sessionHue(n.session) + ",70%,58%)";
|
|
4444
5015
|
document.getElementById("p-role").textContent = n.role || "";
|
|
@@ -4473,14 +5044,376 @@ function renderGraphHtml(graph2, meta) {
|
|
|
4473
5044
|
`;
|
|
4474
5045
|
}
|
|
4475
5046
|
|
|
5047
|
+
// src/ingest.ts
|
|
5048
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync14, statSync as statSync6 } from "fs";
|
|
5049
|
+
import { join as join13, relative, sep, extname, dirname as dirname2, basename as basename2 } from "path";
|
|
5050
|
+
var CODE_EXT = /* @__PURE__ */ new Set([
|
|
5051
|
+
"js",
|
|
5052
|
+
"jsx",
|
|
5053
|
+
"ts",
|
|
5054
|
+
"tsx",
|
|
5055
|
+
"mjs",
|
|
5056
|
+
"cjs",
|
|
5057
|
+
"py",
|
|
5058
|
+
"go",
|
|
5059
|
+
"rb",
|
|
5060
|
+
"rs",
|
|
5061
|
+
"java",
|
|
5062
|
+
"kt",
|
|
5063
|
+
"kts",
|
|
5064
|
+
"swift",
|
|
5065
|
+
"c",
|
|
5066
|
+
"h",
|
|
5067
|
+
"cc",
|
|
5068
|
+
"cpp",
|
|
5069
|
+
"hpp",
|
|
5070
|
+
"cs",
|
|
5071
|
+
"php",
|
|
5072
|
+
"scala",
|
|
5073
|
+
"sh",
|
|
5074
|
+
"bash"
|
|
5075
|
+
]);
|
|
5076
|
+
var DOC_EXT = /* @__PURE__ */ new Set(["md", "mdx", "txt", "rst"]);
|
|
5077
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
5078
|
+
"node_modules",
|
|
5079
|
+
".git",
|
|
5080
|
+
".holt",
|
|
5081
|
+
"dist",
|
|
5082
|
+
"build",
|
|
5083
|
+
".next",
|
|
5084
|
+
"coverage",
|
|
5085
|
+
"out",
|
|
5086
|
+
"target",
|
|
5087
|
+
"vendor",
|
|
5088
|
+
"__pycache__",
|
|
5089
|
+
".venv",
|
|
5090
|
+
"venv",
|
|
5091
|
+
".cache",
|
|
5092
|
+
"tmp",
|
|
5093
|
+
".idea",
|
|
5094
|
+
".vscode"
|
|
5095
|
+
]);
|
|
5096
|
+
var MAX_FILES = 2e3;
|
|
5097
|
+
var MAX_FILE_BYTES = 400 * 1024;
|
|
5098
|
+
var MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
5099
|
+
function toPosix(p) {
|
|
5100
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
5101
|
+
}
|
|
5102
|
+
function ingestFolder(root, want = { code: true, docs: true }) {
|
|
5103
|
+
const files = [];
|
|
5104
|
+
const skipped = { dirs: 0, tooBig: 0, unreadable: 0, hitFileCap: false, hitByteCap: false };
|
|
5105
|
+
let totalBytes = 0;
|
|
5106
|
+
const stack = [root];
|
|
5107
|
+
while (stack.length) {
|
|
5108
|
+
const dir = stack.pop();
|
|
5109
|
+
let entries;
|
|
5110
|
+
try {
|
|
5111
|
+
entries = readdirSync4(dir).sort((a, b) => a.localeCompare(b));
|
|
5112
|
+
} catch {
|
|
5113
|
+
skipped.unreadable++;
|
|
5114
|
+
continue;
|
|
5115
|
+
}
|
|
5116
|
+
const subdirs = [];
|
|
5117
|
+
for (const name of entries) {
|
|
5118
|
+
if (files.length >= MAX_FILES) {
|
|
5119
|
+
skipped.hitFileCap = true;
|
|
5120
|
+
break;
|
|
5121
|
+
}
|
|
5122
|
+
const full = join13(dir, name);
|
|
5123
|
+
let st;
|
|
5124
|
+
try {
|
|
5125
|
+
st = statSync6(full);
|
|
5126
|
+
} catch {
|
|
5127
|
+
skipped.unreadable++;
|
|
5128
|
+
continue;
|
|
5129
|
+
}
|
|
5130
|
+
if (st.isDirectory()) {
|
|
5131
|
+
if (name.startsWith(".") || SKIP_DIRS.has(name)) {
|
|
5132
|
+
skipped.dirs++;
|
|
5133
|
+
continue;
|
|
5134
|
+
}
|
|
5135
|
+
subdirs.push(full);
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
if (!st.isFile()) continue;
|
|
5139
|
+
const ext = extname(name).slice(1).toLowerCase();
|
|
5140
|
+
const isCode = CODE_EXT.has(ext);
|
|
5141
|
+
const isDoc = DOC_EXT.has(ext);
|
|
5142
|
+
if (!isCode && !isDoc) continue;
|
|
5143
|
+
if (isCode && !want.code) continue;
|
|
5144
|
+
if (isDoc && !want.docs) continue;
|
|
5145
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
5146
|
+
skipped.tooBig++;
|
|
5147
|
+
continue;
|
|
5148
|
+
}
|
|
5149
|
+
if (totalBytes + st.size > MAX_TOTAL_BYTES) {
|
|
5150
|
+
skipped.hitByteCap = true;
|
|
5151
|
+
continue;
|
|
5152
|
+
}
|
|
5153
|
+
let content;
|
|
5154
|
+
try {
|
|
5155
|
+
content = readFileSync14(full, "utf8");
|
|
5156
|
+
} catch {
|
|
5157
|
+
skipped.unreadable++;
|
|
5158
|
+
continue;
|
|
5159
|
+
}
|
|
5160
|
+
totalBytes += st.size;
|
|
5161
|
+
const rel = toPosix(relative(root, full)) || name;
|
|
5162
|
+
const firstSlash = rel.indexOf("/");
|
|
5163
|
+
const topDir = firstSlash < 0 ? "." : rel.slice(0, firstSlash);
|
|
5164
|
+
files.push({
|
|
5165
|
+
id: rel,
|
|
5166
|
+
path: rel,
|
|
5167
|
+
kind: isCode ? "code" : "doc",
|
|
5168
|
+
ext,
|
|
5169
|
+
topDir,
|
|
5170
|
+
content
|
|
5171
|
+
});
|
|
5172
|
+
}
|
|
5173
|
+
if (files.length >= MAX_FILES) {
|
|
5174
|
+
skipped.hitFileCap = true;
|
|
5175
|
+
}
|
|
5176
|
+
for (let i = subdirs.length - 1; i >= 0; i--) stack.push(subdirs[i]);
|
|
5177
|
+
}
|
|
5178
|
+
const byId = new Set(files.map((f) => f.id));
|
|
5179
|
+
const edges = extractEdges(files, byId);
|
|
5180
|
+
return { files, edges, skipped };
|
|
5181
|
+
}
|
|
5182
|
+
function extractEdges(files, byId) {
|
|
5183
|
+
const edges = [];
|
|
5184
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5185
|
+
const push = (source, target, kind) => {
|
|
5186
|
+
if (source === target) return;
|
|
5187
|
+
const key = source + "\0" + target + "\0" + kind;
|
|
5188
|
+
if (seen.has(key)) return;
|
|
5189
|
+
seen.add(key);
|
|
5190
|
+
edges.push({ source, target, kind });
|
|
5191
|
+
};
|
|
5192
|
+
for (const f of files) {
|
|
5193
|
+
if (f.kind === "code") {
|
|
5194
|
+
for (const spec of codeSpecifiers(f)) {
|
|
5195
|
+
const resolved = resolveCodeSpec(f, spec, byId);
|
|
5196
|
+
if (resolved) push(f.id, resolved, "dep");
|
|
5197
|
+
}
|
|
5198
|
+
} else {
|
|
5199
|
+
for (const spec of docLinks(f.content)) {
|
|
5200
|
+
const resolved = resolveDocLink(f, spec, byId, files);
|
|
5201
|
+
if (resolved) push(f.id, resolved, "link");
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
return edges;
|
|
5206
|
+
}
|
|
5207
|
+
var JS_FROM_RE = /(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
|
|
5208
|
+
var JS_BARE_IMPORT_RE = /\bimport\s*['"]([^'"]+)['"]/g;
|
|
5209
|
+
var JS_REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
5210
|
+
var JS_DYN_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
5211
|
+
var PY_IMPORT_RE = /^[ \t]*import\s+([A-Za-z0-9_.]+)/gm;
|
|
5212
|
+
var PY_FROM_RE = /^[ \t]*from\s+(\.*[A-Za-z0-9_.]*)\s+import\b/gm;
|
|
5213
|
+
function matchAll(re, text) {
|
|
5214
|
+
const out = [];
|
|
5215
|
+
re.lastIndex = 0;
|
|
5216
|
+
let m;
|
|
5217
|
+
while ((m = re.exec(text)) !== null) {
|
|
5218
|
+
if (m[1]) out.push(m[1]);
|
|
5219
|
+
}
|
|
5220
|
+
return out;
|
|
5221
|
+
}
|
|
5222
|
+
function codeSpecifiers(f) {
|
|
5223
|
+
if (f.ext === "py") {
|
|
5224
|
+
return [...matchAll(PY_IMPORT_RE, f.content), ...matchAll(PY_FROM_RE, f.content)];
|
|
5225
|
+
}
|
|
5226
|
+
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(f.ext)) {
|
|
5227
|
+
return [
|
|
5228
|
+
...matchAll(JS_FROM_RE, f.content),
|
|
5229
|
+
...matchAll(JS_BARE_IMPORT_RE, f.content),
|
|
5230
|
+
...matchAll(JS_REQUIRE_RE, f.content),
|
|
5231
|
+
...matchAll(JS_DYN_IMPORT_RE, f.content)
|
|
5232
|
+
];
|
|
5233
|
+
}
|
|
5234
|
+
return [];
|
|
5235
|
+
}
|
|
5236
|
+
var JS_TRY_EXT = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts"];
|
|
5237
|
+
var JS_INDEX = ["/index.ts", "/index.tsx", "/index.js", "/index.jsx", "/index.mjs"];
|
|
5238
|
+
function joinPosix(fromDir, spec) {
|
|
5239
|
+
const parts = (fromDir === "." ? [] : fromDir.split("/")).concat(spec.split("/"));
|
|
5240
|
+
const out = [];
|
|
5241
|
+
for (const part of parts) {
|
|
5242
|
+
if (part === "" || part === ".") continue;
|
|
5243
|
+
if (part === "..") {
|
|
5244
|
+
out.pop();
|
|
5245
|
+
continue;
|
|
5246
|
+
}
|
|
5247
|
+
out.push(part);
|
|
5248
|
+
}
|
|
5249
|
+
return out.join("/");
|
|
5250
|
+
}
|
|
5251
|
+
function resolveCodeSpec(f, spec, byId) {
|
|
5252
|
+
if (f.ext === "py") return resolvePySpec(f, spec, byId);
|
|
5253
|
+
if (!(spec.startsWith("./") || spec.startsWith("../"))) return null;
|
|
5254
|
+
const fromDir = toPosixDir(f.id);
|
|
5255
|
+
const base = joinPosix(fromDir, spec);
|
|
5256
|
+
for (const ext of JS_TRY_EXT) {
|
|
5257
|
+
const cand = base + ext;
|
|
5258
|
+
if (cand && byId.has(cand)) return cand;
|
|
5259
|
+
}
|
|
5260
|
+
for (const idx of JS_INDEX) {
|
|
5261
|
+
const cand = base + idx;
|
|
5262
|
+
if (byId.has(cand)) return cand;
|
|
5263
|
+
}
|
|
5264
|
+
return null;
|
|
5265
|
+
}
|
|
5266
|
+
function resolvePySpec(f, spec, byId) {
|
|
5267
|
+
let mod = spec;
|
|
5268
|
+
let up = 0;
|
|
5269
|
+
while (mod.startsWith(".")) {
|
|
5270
|
+
up++;
|
|
5271
|
+
mod = mod.slice(1);
|
|
5272
|
+
}
|
|
5273
|
+
const segs = mod ? mod.split(".") : [];
|
|
5274
|
+
let baseDir = toPosixDir(f.id);
|
|
5275
|
+
if (up > 0) {
|
|
5276
|
+
for (let i = 1; i < up; i++) baseDir = toPosixDir(baseDir + "/x");
|
|
5277
|
+
}
|
|
5278
|
+
const relBase = up > 0 ? joinPosix(baseDir, segs.join("/")) : segs.join("/");
|
|
5279
|
+
const candidates = up > 0 ? [relBase + ".py", relBase + "/__init__.py"] : [
|
|
5280
|
+
relBase + ".py",
|
|
5281
|
+
relBase + "/__init__.py",
|
|
5282
|
+
// absolute import may still be relative to the file's own dir
|
|
5283
|
+
joinPosix(toPosixDir(f.id), segs.join("/")) + ".py"
|
|
5284
|
+
];
|
|
5285
|
+
for (const cand of candidates) if (cand && byId.has(cand)) return cand;
|
|
5286
|
+
return null;
|
|
5287
|
+
}
|
|
5288
|
+
function toPosixDir(id) {
|
|
5289
|
+
const d = toPosix(dirname2(id));
|
|
5290
|
+
return d === "" || d === "." ? "." : d;
|
|
5291
|
+
}
|
|
5292
|
+
var MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
|
5293
|
+
var WIKILINK_RE2 = /\[\[([^\]]+)\]\]/g;
|
|
5294
|
+
function docLinks(content) {
|
|
5295
|
+
const out = [];
|
|
5296
|
+
for (const raw of matchAll(MD_LINK_RE, content)) out.push(raw);
|
|
5297
|
+
WIKILINK_RE2.lastIndex = 0;
|
|
5298
|
+
let m;
|
|
5299
|
+
while ((m = WIKILINK_RE2.exec(content)) !== null) {
|
|
5300
|
+
const target = (m[1] || "").split("|")[0];
|
|
5301
|
+
if (target && target.trim()) out.push(target.trim());
|
|
5302
|
+
}
|
|
5303
|
+
return out;
|
|
5304
|
+
}
|
|
5305
|
+
function resolveDocLink(f, spec, byId, files) {
|
|
5306
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec) || spec.startsWith("#") || spec.startsWith("mailto:")) return null;
|
|
5307
|
+
const clean = spec.split("#")[0].split("?")[0].trim();
|
|
5308
|
+
if (!clean) return null;
|
|
5309
|
+
if (clean.includes("/") || clean.includes(".")) {
|
|
5310
|
+
const fromDir = toPosixDir(f.id);
|
|
5311
|
+
const base = joinPosix(fromDir, clean);
|
|
5312
|
+
for (const ext of ["", ".md", ".mdx", ".txt", ".rst"]) {
|
|
5313
|
+
const cand = base + ext;
|
|
5314
|
+
if (cand && byId.has(cand)) return cand;
|
|
5315
|
+
}
|
|
5316
|
+
}
|
|
5317
|
+
const wantName = clean.toLowerCase();
|
|
5318
|
+
for (const cand of files) {
|
|
5319
|
+
if (cand.kind !== "doc") continue;
|
|
5320
|
+
const stem = basename2(cand.id).replace(/\.[^.]+$/, "").toLowerCase();
|
|
5321
|
+
if (stem === wantName) return cand.id;
|
|
5322
|
+
}
|
|
5323
|
+
return null;
|
|
5324
|
+
}
|
|
5325
|
+
|
|
5326
|
+
// src/graphreport.ts
|
|
5327
|
+
var TOP_GOD_NODES = 12;
|
|
5328
|
+
var REP_PER_COMMUNITY = 6;
|
|
5329
|
+
function degrees(graph2) {
|
|
5330
|
+
const idSet = new Set(graph2.nodes.map((n) => n.id));
|
|
5331
|
+
const deg = /* @__PURE__ */ new Map();
|
|
5332
|
+
for (const n of graph2.nodes) deg.set(n.id, 0);
|
|
5333
|
+
for (const e of graph2.edges) {
|
|
5334
|
+
if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
|
|
5335
|
+
deg.set(e.source, (deg.get(e.source) || 0) + 1);
|
|
5336
|
+
deg.set(e.target, (deg.get(e.target) || 0) + 1);
|
|
5337
|
+
}
|
|
5338
|
+
return deg;
|
|
5339
|
+
}
|
|
5340
|
+
function reportLabel(n) {
|
|
5341
|
+
return n.path || n.label || n.id;
|
|
5342
|
+
}
|
|
5343
|
+
function renderReport(graph2, meta) {
|
|
5344
|
+
const date = new Date(meta.generatedAt ?? Date.now()).toISOString().slice(0, 10);
|
|
5345
|
+
const lines = [];
|
|
5346
|
+
lines.push(`# Graph Report - ${meta.workspace} (${date})`);
|
|
5347
|
+
lines.push("");
|
|
5348
|
+
if (graph2.nodes.length === 0) {
|
|
5349
|
+
lines.push("## Summary");
|
|
5350
|
+
lines.push("- 0 nodes, 0 edges, 0 communities.");
|
|
5351
|
+
lines.push("- Nothing to ingest here yet. Run `holt graph --code` or `holt graph --docs`");
|
|
5352
|
+
lines.push(" in a folder with source or docs, then `holt graph report`.");
|
|
5353
|
+
lines.push("");
|
|
5354
|
+
return lines.join("\n") + "\n";
|
|
5355
|
+
}
|
|
5356
|
+
const deg = degrees(graph2);
|
|
5357
|
+
const idSet = new Set(graph2.nodes.map((n) => n.id));
|
|
5358
|
+
const validEdges = graph2.edges.filter((e) => idSet.has(e.source) && idSet.has(e.target));
|
|
5359
|
+
const commMembers = /* @__PURE__ */ new Map();
|
|
5360
|
+
for (const n of graph2.nodes) {
|
|
5361
|
+
const cid = n.community ?? "0";
|
|
5362
|
+
const arr = commMembers.get(cid);
|
|
5363
|
+
if (arr) arr.push(n);
|
|
5364
|
+
else commMembers.set(cid, [n]);
|
|
5365
|
+
}
|
|
5366
|
+
const communityCount = commMembers.size;
|
|
5367
|
+
const fileCount = graph2.nodes.filter((n) => n.kind === "file").length;
|
|
5368
|
+
const docCount = graph2.nodes.filter((n) => n.kind === "doc").length;
|
|
5369
|
+
lines.push("## Summary");
|
|
5370
|
+
lines.push(`- ${graph2.nodes.length} nodes, ${validEdges.length} edges, ${communityCount} communities detected`);
|
|
5371
|
+
lines.push(`- Ingested: ${fileCount} code files, ${docCount} docs`);
|
|
5372
|
+
lines.push("- Community algorithm: label propagation (deterministic, zero dependency)");
|
|
5373
|
+
lines.push("");
|
|
5374
|
+
lines.push("## God Nodes (most connected - your core files)");
|
|
5375
|
+
const ranked = graph2.nodes.slice().sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || reportLabel(a).localeCompare(reportLabel(b))).filter((n) => (deg.get(n.id) || 0) > 0).slice(0, TOP_GOD_NODES);
|
|
5376
|
+
if (ranked.length === 0) {
|
|
5377
|
+
lines.push("- None: no nodes have any edges yet (nothing links to anything).");
|
|
5378
|
+
} else {
|
|
5379
|
+
ranked.forEach((n, i) => {
|
|
5380
|
+
const d = deg.get(n.id) || 0;
|
|
5381
|
+
const cid = n.community ?? "0";
|
|
5382
|
+
lines.push(`${i + 1}. \`${reportLabel(n)}\` - ${d} edges (community ${cid})`);
|
|
5383
|
+
});
|
|
5384
|
+
}
|
|
5385
|
+
lines.push("");
|
|
5386
|
+
lines.push("## Communities");
|
|
5387
|
+
const sortedComms = [...commMembers.entries()].sort(
|
|
5388
|
+
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])
|
|
5389
|
+
);
|
|
5390
|
+
for (const [cid, members] of sortedComms) {
|
|
5391
|
+
lines.push("");
|
|
5392
|
+
lines.push(`### Community ${cid} (${members.length} nodes)`);
|
|
5393
|
+
const reps = members.slice().sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0) || reportLabel(a).localeCompare(reportLabel(b))).slice(0, REP_PER_COMMUNITY).map((n) => reportLabel(n));
|
|
5394
|
+
const extra = members.length - reps.length;
|
|
5395
|
+
const shown = reps.join(", ");
|
|
5396
|
+
lines.push(`Representative: ${shown}${extra > 0 ? ` (+${extra} more)` : ""}`);
|
|
5397
|
+
}
|
|
5398
|
+
lines.push("");
|
|
5399
|
+
return lines.join("\n") + "\n";
|
|
5400
|
+
}
|
|
5401
|
+
|
|
4476
5402
|
// src/commands/graph.ts
|
|
4477
5403
|
function parseArgs(args) {
|
|
4478
|
-
const opts = { open: true };
|
|
5404
|
+
const opts = { open: true, code: false, docs: false, report: false };
|
|
4479
5405
|
for (let i = 0; i < args.length; i++) {
|
|
4480
5406
|
const a = args[i];
|
|
4481
|
-
if (a === "
|
|
5407
|
+
if (a === "report") opts.report = true;
|
|
5408
|
+
else if (a === "--no-open") opts.open = false;
|
|
4482
5409
|
else if (a === "--wiki") opts.wiki = true;
|
|
4483
5410
|
else if (a === "--no-wiki") opts.wiki = false;
|
|
5411
|
+
else if (a === "--code") opts.code = true;
|
|
5412
|
+
else if (a === "--docs") opts.docs = true;
|
|
5413
|
+
else if (a === "--all") {
|
|
5414
|
+
opts.code = true;
|
|
5415
|
+
opts.docs = true;
|
|
5416
|
+
} else if (a === "--report") opts.report = true;
|
|
4484
5417
|
else if (a === "--out") opts.out = args[++i];
|
|
4485
5418
|
else if (a && a.startsWith("--out=")) opts.out = a.slice("--out=".length);
|
|
4486
5419
|
}
|
|
@@ -4518,15 +5451,55 @@ async function graph(args = []) {
|
|
|
4518
5451
|
}
|
|
4519
5452
|
close();
|
|
4520
5453
|
const opts = parseArgs(args);
|
|
5454
|
+
if (opts.report && !opts.code && !opts.docs) {
|
|
5455
|
+
opts.code = true;
|
|
5456
|
+
opts.docs = true;
|
|
5457
|
+
}
|
|
5458
|
+
const ingestCode = opts.code;
|
|
5459
|
+
const ingestDocs = opts.docs;
|
|
4521
5460
|
const turns = loadTurns();
|
|
4522
|
-
const includeWiki = opts.wiki !== void 0 ? opts.wiki :
|
|
5461
|
+
const includeWiki = opts.wiki !== void 0 ? opts.wiki : existsSync13(wikiDir());
|
|
4523
5462
|
const wikiPages = includeWiki ? wikiGraphPages() : [];
|
|
4524
|
-
|
|
5463
|
+
let fileNodeCount = 0;
|
|
5464
|
+
let docNodeCount = 0;
|
|
5465
|
+
let codeGraph = null;
|
|
5466
|
+
if (ingestCode || ingestDocs) {
|
|
5467
|
+
const res = ingestFolder(workspace(), { code: ingestCode, docs: ingestDocs });
|
|
5468
|
+
codeGraph = buildCodeGraph(res.files, res.edges);
|
|
5469
|
+
fileNodeCount = codeGraph.nodes.filter((n) => n.kind === "file").length;
|
|
5470
|
+
docNodeCount = codeGraph.nodes.filter((n) => n.kind === "doc").length;
|
|
5471
|
+
const skipNotes = [];
|
|
5472
|
+
if (res.skipped.dirs) skipNotes.push(`${res.skipped.dirs} dirs`);
|
|
5473
|
+
if (res.skipped.tooBig) skipNotes.push(`${res.skipped.tooBig} too-big`);
|
|
5474
|
+
if (res.skipped.unreadable) skipNotes.push(`${res.skipped.unreadable} unreadable`);
|
|
5475
|
+
if (res.skipped.hitFileCap) skipNotes.push("file cap hit");
|
|
5476
|
+
if (res.skipped.hitByteCap) skipNotes.push("byte cap hit");
|
|
5477
|
+
if (skipNotes.length) console.log(c.dim(` ingest skipped: ${skipNotes.join(", ")}`));
|
|
5478
|
+
}
|
|
5479
|
+
if (turns.length === 0 && wikiPages.length === 0 && !codeGraph) {
|
|
4525
5480
|
console.log(c.dim('\n No memory in this folder yet. Have a chat first with "holt chat", then come back.\n'));
|
|
5481
|
+
console.log(c.dim(` Or ingest this folder's code/docs: "holt graph --code" / "holt graph --docs".
|
|
5482
|
+
`));
|
|
4526
5483
|
return;
|
|
4527
5484
|
}
|
|
4528
5485
|
let g = buildGraph(turns);
|
|
4529
5486
|
if (wikiPages.length) g = mergeGraphs(g, buildWikiGraph(wikiPages));
|
|
5487
|
+
if (codeGraph) g = mergeGraphs(g, codeGraph);
|
|
5488
|
+
if (codeGraph) assignCommunities(g);
|
|
5489
|
+
if (opts.report) {
|
|
5490
|
+
const md = renderReport(g, { workspace: workspace() });
|
|
5491
|
+
const reportPath = opts.out ? resolve4(opts.out) : join14(workspace(), "GRAPH_REPORT.md");
|
|
5492
|
+
mkdirSync10(dirname3(reportPath), { recursive: true });
|
|
5493
|
+
writeFileSync11(reportPath, md, "utf8");
|
|
5494
|
+
const communities = new Set(g.nodes.map((n) => n.community ?? "0")).size;
|
|
5495
|
+
console.log("\n" + c.accent("Graph report written") + c.dim(" (this folder)"));
|
|
5496
|
+
console.log(` nodes ${g.nodes.length} (${fileNodeCount} files, ${docNodeCount} docs)`);
|
|
5497
|
+
console.log(` edges ${g.edges.length}`);
|
|
5498
|
+
console.log(` communities ${g.nodes.length ? communities : 0}`);
|
|
5499
|
+
console.log(` file ${reportPath}
|
|
5500
|
+
`);
|
|
5501
|
+
return;
|
|
5502
|
+
}
|
|
4530
5503
|
const conceptCount = g.nodes.filter((n) => n.kind === "concept").length;
|
|
4531
5504
|
const wikiCount = g.nodes.filter((n) => n.kind === "wiki").length;
|
|
4532
5505
|
const sessions = new Set(turns.map((t) => t.session)).size;
|
|
@@ -4537,12 +5510,14 @@ async function graph(args = []) {
|
|
|
4537
5510
|
concepts: conceptCount,
|
|
4538
5511
|
edges: g.edges.length
|
|
4539
5512
|
});
|
|
4540
|
-
const outPath = opts.out ?
|
|
4541
|
-
|
|
4542
|
-
|
|
5513
|
+
const outPath = opts.out ? resolve4(opts.out) : join14(wsHoltDir(), "graph.html");
|
|
5514
|
+
mkdirSync10(dirname3(outPath), { recursive: true });
|
|
5515
|
+
writeFileSync11(outPath, html, "utf8");
|
|
4543
5516
|
console.log("\n" + c.accent("Memory graph built") + c.dim(" (this folder)"));
|
|
4544
5517
|
const parts = [`${turns.length} turns`, `${conceptCount} concepts`];
|
|
4545
5518
|
if (wikiCount) parts.push(`${wikiCount} wiki pages`);
|
|
5519
|
+
if (fileNodeCount) parts.push(`${fileNodeCount} code files`);
|
|
5520
|
+
if (docNodeCount) parts.push(`${docNodeCount} docs`);
|
|
4546
5521
|
console.log(` nodes ${g.nodes.length} (${parts.join(", ")})`);
|
|
4547
5522
|
console.log(` edges ${g.edges.length}`);
|
|
4548
5523
|
console.log(` file ${outPath}`);
|
|
@@ -4649,7 +5624,7 @@ function recoverId(line) {
|
|
|
4649
5624
|
return null;
|
|
4650
5625
|
}
|
|
4651
5626
|
function runMcpServer(opts) {
|
|
4652
|
-
return new Promise((
|
|
5627
|
+
return new Promise((resolve8) => {
|
|
4653
5628
|
const stdin = process.stdin;
|
|
4654
5629
|
let buffer = "";
|
|
4655
5630
|
let chain = Promise.resolve();
|
|
@@ -4701,11 +5676,11 @@ function runMcpServer(opts) {
|
|
|
4701
5676
|
processLine(buffer);
|
|
4702
5677
|
buffer = "";
|
|
4703
5678
|
}
|
|
4704
|
-
chain.then(() =>
|
|
5679
|
+
chain.then(() => resolve8()).catch(() => resolve8());
|
|
4705
5680
|
});
|
|
4706
5681
|
stdin.on("error", (e) => {
|
|
4707
5682
|
console.error(`[mcp] stdin error: ${e.message}`);
|
|
4708
|
-
chain.then(() =>
|
|
5683
|
+
chain.then(() => resolve8()).catch(() => resolve8());
|
|
4709
5684
|
});
|
|
4710
5685
|
stdin.resume();
|
|
4711
5686
|
});
|
|
@@ -4825,7 +5800,7 @@ function holtTools() {
|
|
|
4825
5800
|
}
|
|
4826
5801
|
|
|
4827
5802
|
// src/version.ts
|
|
4828
|
-
var VERSION = "0.
|
|
5803
|
+
var VERSION = "0.10.0";
|
|
4829
5804
|
|
|
4830
5805
|
// src/commands/mcp.ts
|
|
4831
5806
|
function printSetup() {
|
|
@@ -4871,8 +5846,8 @@ async function mcp(sub, rest = []) {
|
|
|
4871
5846
|
}
|
|
4872
5847
|
|
|
4873
5848
|
// src/commands/hook.ts
|
|
4874
|
-
import { readFileSync as
|
|
4875
|
-
import { join as
|
|
5849
|
+
import { readFileSync as readFileSync15, writeFileSync as writeFileSync12, existsSync as existsSync14, mkdirSync as mkdirSync11, copyFileSync, realpathSync as realpathSync2 } from "fs";
|
|
5850
|
+
import { join as join15, dirname as dirname4 } from "path";
|
|
4876
5851
|
import { homedir as homedir3 } from "os";
|
|
4877
5852
|
var EVENT = {
|
|
4878
5853
|
inject: "UserPromptSubmit",
|
|
@@ -4883,26 +5858,26 @@ var HOOK_CMD = {
|
|
|
4883
5858
|
capture: "holt hook capture"
|
|
4884
5859
|
};
|
|
4885
5860
|
function globalSettingsPath() {
|
|
4886
|
-
return
|
|
5861
|
+
return join15(homedir3(), ".claude", "settings.json");
|
|
4887
5862
|
}
|
|
4888
5863
|
function projectSettingsPath() {
|
|
4889
|
-
return
|
|
5864
|
+
return join15(process.cwd(), ".claude", "settings.json");
|
|
4890
5865
|
}
|
|
4891
5866
|
function readSettings(path) {
|
|
4892
|
-
if (!
|
|
5867
|
+
if (!existsSync14(path)) return {};
|
|
4893
5868
|
try {
|
|
4894
|
-
const parsed = JSON.parse(
|
|
5869
|
+
const parsed = JSON.parse(readFileSync15(path, "utf8"));
|
|
4895
5870
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
4896
5871
|
} catch {
|
|
4897
5872
|
throw new Error(`could not parse ${path} (not valid JSON) - leaving it untouched`);
|
|
4898
5873
|
}
|
|
4899
5874
|
}
|
|
4900
5875
|
function writeSettings(path, settings) {
|
|
4901
|
-
|
|
4902
|
-
|
|
5876
|
+
mkdirSync11(dirname4(path), { recursive: true });
|
|
5877
|
+
writeFileSync12(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
4903
5878
|
}
|
|
4904
5879
|
function backup(path) {
|
|
4905
|
-
if (!
|
|
5880
|
+
if (!existsSync14(path)) return null;
|
|
4906
5881
|
const dest = `${path}.holt-bak`;
|
|
4907
5882
|
try {
|
|
4908
5883
|
copyFileSync(path, dest);
|
|
@@ -4992,7 +5967,7 @@ function remove(rest) {
|
|
|
4992
5967
|
const paths = project ? [projectSettingsPath()] : [globalSettingsPath(), projectSettingsPath()];
|
|
4993
5968
|
let touchedAny = false;
|
|
4994
5969
|
for (const path of paths) {
|
|
4995
|
-
if (!
|
|
5970
|
+
if (!existsSync14(path)) continue;
|
|
4996
5971
|
let settings;
|
|
4997
5972
|
try {
|
|
4998
5973
|
settings = readSettings(path);
|
|
@@ -5022,7 +5997,7 @@ function status(rest) {
|
|
|
5022
5997
|
];
|
|
5023
5998
|
console.log(c.accent(" Holt ambient hooks"));
|
|
5024
5999
|
for (const e of entries) {
|
|
5025
|
-
if (!
|
|
6000
|
+
if (!existsSync14(e.path)) {
|
|
5026
6001
|
console.log(c.dim(` ${e.label.padEnd(8)} ${e.path} (no settings file)`));
|
|
5027
6002
|
continue;
|
|
5028
6003
|
}
|
|
@@ -5042,13 +6017,13 @@ function status(rest) {
|
|
|
5042
6017
|
console.log(c.dim(" Runtime hooks only act in trusted Holt folders that already have .holt/memory."));
|
|
5043
6018
|
}
|
|
5044
6019
|
function readStdin(timeoutMs) {
|
|
5045
|
-
return new Promise((
|
|
6020
|
+
return new Promise((resolve8) => {
|
|
5046
6021
|
let input = "";
|
|
5047
6022
|
let done = false;
|
|
5048
6023
|
const finish = () => {
|
|
5049
6024
|
if (done) return;
|
|
5050
6025
|
done = true;
|
|
5051
|
-
|
|
6026
|
+
resolve8(input);
|
|
5052
6027
|
};
|
|
5053
6028
|
try {
|
|
5054
6029
|
process.stdin.setEncoding("utf8");
|
|
@@ -5072,7 +6047,7 @@ function parseHookInput(raw) {
|
|
|
5072
6047
|
}
|
|
5073
6048
|
}
|
|
5074
6049
|
function enterFolder(cwd) {
|
|
5075
|
-
if (!cwd || !
|
|
6050
|
+
if (!cwd || !existsSync14(cwd)) return null;
|
|
5076
6051
|
try {
|
|
5077
6052
|
const real = realpathSync2(cwd);
|
|
5078
6053
|
process.chdir(real);
|
|
@@ -5083,7 +6058,7 @@ function enterFolder(cwd) {
|
|
|
5083
6058
|
}
|
|
5084
6059
|
function guardTrusted(real) {
|
|
5085
6060
|
if (!isTrusted(real)) return false;
|
|
5086
|
-
if (!
|
|
6061
|
+
if (!existsSync14(memDir())) return false;
|
|
5087
6062
|
return true;
|
|
5088
6063
|
}
|
|
5089
6064
|
async function inject() {
|
|
@@ -5110,7 +6085,7 @@ function readTranscript(path) {
|
|
|
5110
6085
|
const out = [];
|
|
5111
6086
|
let raw;
|
|
5112
6087
|
try {
|
|
5113
|
-
raw =
|
|
6088
|
+
raw = readFileSync15(path, "utf8");
|
|
5114
6089
|
} catch {
|
|
5115
6090
|
return out;
|
|
5116
6091
|
}
|
|
@@ -5174,6 +6149,15 @@ async function capture() {
|
|
|
5174
6149
|
const n = await extractAndSaveFacts(brain, cfg, history, session);
|
|
5175
6150
|
if (n > 0) process.stderr.write(`holt hook capture: saved ${n} fact${n === 1 ? "" : "s"} to ${memDir()}
|
|
5176
6151
|
`);
|
|
6152
|
+
if (cfg.wiki?.autoSync) {
|
|
6153
|
+
const res = await syncWiki(cfg, () => resolveBrainMaintainer(cfg));
|
|
6154
|
+
if (res.status === "ok" && res.changed) {
|
|
6155
|
+
process.stderr.write(
|
|
6156
|
+
`holt hook capture: updated the wiki (${res.created} created, ${res.updated} updated) in ${wikiDir()}
|
|
6157
|
+
`
|
|
6158
|
+
);
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
5177
6161
|
} catch (e) {
|
|
5178
6162
|
process.stderr.write("holt hook capture: " + (e instanceof Error ? e.message : String(e)) + "\n");
|
|
5179
6163
|
}
|
|
@@ -5231,8 +6215,8 @@ async function hook(sub, rest = []) {
|
|
|
5231
6215
|
}
|
|
5232
6216
|
|
|
5233
6217
|
// src/commands/run.ts
|
|
5234
|
-
import { writeFileSync as
|
|
5235
|
-
import { resolve as
|
|
6218
|
+
import { writeFileSync as writeFileSync13 } from "fs";
|
|
6219
|
+
import { resolve as resolve5 } from "path";
|
|
5236
6220
|
var USAGE3 = [
|
|
5237
6221
|
" holt run <task> [options]",
|
|
5238
6222
|
"",
|
|
@@ -5321,9 +6305,9 @@ async function run(args) {
|
|
|
5321
6305
|
process.stdout.write("\n");
|
|
5322
6306
|
}
|
|
5323
6307
|
if (p.out) {
|
|
5324
|
-
const target =
|
|
6308
|
+
const target = resolve5(process.cwd(), p.out);
|
|
5325
6309
|
try {
|
|
5326
|
-
|
|
6310
|
+
writeFileSync13(target, result.text, "utf8");
|
|
5327
6311
|
process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
5328
6312
|
} catch (e) {
|
|
5329
6313
|
process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
|
|
@@ -5334,38 +6318,38 @@ async function run(args) {
|
|
|
5334
6318
|
|
|
5335
6319
|
// src/commands/schedule.ts
|
|
5336
6320
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
5337
|
-
import { writeFileSync as
|
|
5338
|
-
import { dirname as
|
|
6321
|
+
import { writeFileSync as writeFileSync15, mkdirSync as mkdirSync13, rmSync as rmSync6, existsSync as existsSync15 } from "fs";
|
|
6322
|
+
import { dirname as dirname5 } from "path";
|
|
5339
6323
|
|
|
5340
6324
|
// src/scheduler.ts
|
|
5341
6325
|
import { homedir as homedir4 } from "os";
|
|
5342
|
-
import { join as
|
|
5343
|
-
import { readFileSync as
|
|
6326
|
+
import { join as join16 } from "path";
|
|
6327
|
+
import { readFileSync as readFileSync16, writeFileSync as writeFileSync14, mkdirSync as mkdirSync12 } from "fs";
|
|
5344
6328
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
5345
6329
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5346
6330
|
function schedulesPath() {
|
|
5347
|
-
return
|
|
6331
|
+
return join16(GLOBAL_DIR, "schedules.json");
|
|
5348
6332
|
}
|
|
5349
6333
|
function logsDir() {
|
|
5350
|
-
return
|
|
6334
|
+
return join16(GLOBAL_DIR, "logs");
|
|
5351
6335
|
}
|
|
5352
6336
|
function logPath(id) {
|
|
5353
|
-
return
|
|
6337
|
+
return join16(logsDir(), `${id}.log`);
|
|
5354
6338
|
}
|
|
5355
6339
|
function plistPath(id) {
|
|
5356
|
-
return
|
|
6340
|
+
return join16(homedir4(), "Library", "LaunchAgents", `com.holt.${id}.plist`);
|
|
5357
6341
|
}
|
|
5358
6342
|
function loadJobs() {
|
|
5359
6343
|
try {
|
|
5360
|
-
const raw = JSON.parse(
|
|
6344
|
+
const raw = JSON.parse(readFileSync16(schedulesPath(), "utf8"));
|
|
5361
6345
|
return Array.isArray(raw) ? raw : [];
|
|
5362
6346
|
} catch {
|
|
5363
6347
|
return [];
|
|
5364
6348
|
}
|
|
5365
6349
|
}
|
|
5366
6350
|
function saveJobs(jobs) {
|
|
5367
|
-
|
|
5368
|
-
|
|
6351
|
+
mkdirSync12(GLOBAL_DIR, { recursive: true });
|
|
6352
|
+
writeFileSync14(schedulesPath(), JSON.stringify(jobs, null, 2) + "\n", "utf8");
|
|
5369
6353
|
}
|
|
5370
6354
|
function addJob(job) {
|
|
5371
6355
|
const jobs = loadJobs();
|
|
@@ -5473,7 +6457,7 @@ function appendCronLine(crontab, line) {
|
|
|
5473
6457
|
return (base ? base + "\n" : "") + line + "\n";
|
|
5474
6458
|
}
|
|
5475
6459
|
function ensureLogsDir() {
|
|
5476
|
-
|
|
6460
|
+
mkdirSync12(logsDir(), { recursive: true });
|
|
5477
6461
|
}
|
|
5478
6462
|
|
|
5479
6463
|
// src/commands/schedule.ts
|
|
@@ -5499,8 +6483,8 @@ function parseAdd(rest) {
|
|
|
5499
6483
|
}
|
|
5500
6484
|
function installDarwin(job, holtPath) {
|
|
5501
6485
|
const path = plistPath(job.id);
|
|
5502
|
-
|
|
5503
|
-
|
|
6486
|
+
mkdirSync13(dirname5(path), { recursive: true });
|
|
6487
|
+
writeFileSync15(path, buildLaunchdPlist(job, holtPath), "utf8");
|
|
5504
6488
|
const res = spawnSync4("launchctl", ["load", "-w", path], { encoding: "utf8" });
|
|
5505
6489
|
if (res.status === 0) {
|
|
5506
6490
|
console.log(c.green(" Loaded into launchd."));
|
|
@@ -5588,7 +6572,7 @@ function listCmd2() {
|
|
|
5588
6572
|
}
|
|
5589
6573
|
function removeDarwin(id) {
|
|
5590
6574
|
const path = plistPath(id);
|
|
5591
|
-
if (
|
|
6575
|
+
if (existsSync15(path)) {
|
|
5592
6576
|
spawnSync4("launchctl", ["unload", path], { encoding: "utf8" });
|
|
5593
6577
|
try {
|
|
5594
6578
|
rmSync6(path);
|
|
@@ -5651,29 +6635,29 @@ async function schedule(sub, rest = []) {
|
|
|
5651
6635
|
|
|
5652
6636
|
// src/commands/routine.ts
|
|
5653
6637
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
5654
|
-
import { writeFileSync as
|
|
5655
|
-
import { dirname as
|
|
6638
|
+
import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync15, rmSync as rmSync7, existsSync as existsSync16 } from "fs";
|
|
6639
|
+
import { dirname as dirname6, resolve as resolve6 } from "path";
|
|
5656
6640
|
|
|
5657
6641
|
// src/routines.ts
|
|
5658
|
-
import { readFileSync as
|
|
5659
|
-
import { join as
|
|
6642
|
+
import { readFileSync as readFileSync17, writeFileSync as writeFileSync16, mkdirSync as mkdirSync14 } from "fs";
|
|
6643
|
+
import { join as join17 } from "path";
|
|
5660
6644
|
function sanitizeRoutineName(name) {
|
|
5661
6645
|
return (name || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
5662
6646
|
}
|
|
5663
6647
|
function routinesPath() {
|
|
5664
|
-
return
|
|
6648
|
+
return join17(GLOBAL_DIR, "routines.json");
|
|
5665
6649
|
}
|
|
5666
6650
|
function loadRoutines() {
|
|
5667
6651
|
try {
|
|
5668
|
-
const raw = JSON.parse(
|
|
6652
|
+
const raw = JSON.parse(readFileSync17(routinesPath(), "utf8"));
|
|
5669
6653
|
return Array.isArray(raw) ? raw : [];
|
|
5670
6654
|
} catch {
|
|
5671
6655
|
return [];
|
|
5672
6656
|
}
|
|
5673
6657
|
}
|
|
5674
6658
|
function saveRoutines(routines) {
|
|
5675
|
-
|
|
5676
|
-
|
|
6659
|
+
mkdirSync14(GLOBAL_DIR, { recursive: true });
|
|
6660
|
+
writeFileSync16(routinesPath(), JSON.stringify(routines, null, 2) + "\n", "utf8");
|
|
5677
6661
|
}
|
|
5678
6662
|
function findRoutine(name) {
|
|
5679
6663
|
const want = sanitizeRoutineName(name);
|
|
@@ -5733,8 +6717,8 @@ function routineJobId(name) {
|
|
|
5733
6717
|
}
|
|
5734
6718
|
function installDarwin2(job, holtPath) {
|
|
5735
6719
|
const path = plistPath(job.id);
|
|
5736
|
-
|
|
5737
|
-
|
|
6720
|
+
mkdirSync15(dirname6(path), { recursive: true });
|
|
6721
|
+
writeFileSync17(path, buildLaunchdPlist(job, holtPath), "utf8");
|
|
5738
6722
|
const res = spawnSync5("launchctl", ["load", "-w", path], { encoding: "utf8" });
|
|
5739
6723
|
if (res.status === 0) {
|
|
5740
6724
|
console.log(c.green(" Loaded into launchd."));
|
|
@@ -5778,7 +6762,7 @@ function installSchedule(r) {
|
|
|
5778
6762
|
}
|
|
5779
6763
|
function removeScheduleDarwin(id) {
|
|
5780
6764
|
const path = plistPath(id);
|
|
5781
|
-
if (
|
|
6765
|
+
if (existsSync16(path)) {
|
|
5782
6766
|
spawnSync5("launchctl", ["unload", path], { encoding: "utf8" });
|
|
5783
6767
|
try {
|
|
5784
6768
|
rmSync7(path);
|
|
@@ -5972,9 +6956,9 @@ async function runCmd(rest) {
|
|
|
5972
6956
|
if (!quiet && !streamed) process.stdout.write(result.text);
|
|
5973
6957
|
if (!quiet && (streamed || result.text) && !result.text.endsWith("\n")) process.stdout.write("\n");
|
|
5974
6958
|
if (outTarget) {
|
|
5975
|
-
const target =
|
|
6959
|
+
const target = resolve6(routine2.workspace, outTarget);
|
|
5976
6960
|
try {
|
|
5977
|
-
|
|
6961
|
+
writeFileSync17(target, result.text, "utf8");
|
|
5978
6962
|
if (!quiet) process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
5979
6963
|
} catch (e) {
|
|
5980
6964
|
process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
|
|
@@ -6112,7 +7096,7 @@ async function routine(sub, rest = []) {
|
|
|
6112
7096
|
}
|
|
6113
7097
|
|
|
6114
7098
|
// src/commands/notify.ts
|
|
6115
|
-
import { readFileSync as
|
|
7099
|
+
import { readFileSync as readFileSync18 } from "fs";
|
|
6116
7100
|
async function notify(args) {
|
|
6117
7101
|
const nonFlag = args.filter((a) => a !== "-" && !a.startsWith("--"));
|
|
6118
7102
|
const wantsStdin = args.includes("-") || !process.stdin.isTTY;
|
|
@@ -6145,7 +7129,7 @@ async function notify(args) {
|
|
|
6145
7129
|
}
|
|
6146
7130
|
function readStdin2() {
|
|
6147
7131
|
try {
|
|
6148
|
-
return
|
|
7132
|
+
return readFileSync18(0, "utf8");
|
|
6149
7133
|
} catch {
|
|
6150
7134
|
return "";
|
|
6151
7135
|
}
|
|
@@ -6356,8 +7340,8 @@ async function doctor() {
|
|
|
6356
7340
|
}
|
|
6357
7341
|
|
|
6358
7342
|
// src/commands/write.ts
|
|
6359
|
-
import { writeFileSync as
|
|
6360
|
-
import { resolve as
|
|
7343
|
+
import { writeFileSync as writeFileSync18 } from "fs";
|
|
7344
|
+
import { resolve as resolve7 } from "path";
|
|
6361
7345
|
|
|
6362
7346
|
// src/antiai.ts
|
|
6363
7347
|
var EM_DASH = String.fromCharCode(8212);
|
|
@@ -6535,9 +7519,9 @@ async function write(args) {
|
|
|
6535
7519
|
draft = stripEmDash(draft).trimEnd();
|
|
6536
7520
|
process.stdout.write(draft + "\n");
|
|
6537
7521
|
if (p.out) {
|
|
6538
|
-
const target =
|
|
7522
|
+
const target = resolve7(process.cwd(), p.out);
|
|
6539
7523
|
try {
|
|
6540
|
-
|
|
7524
|
+
writeFileSync18(target, draft + "\n", "utf8");
|
|
6541
7525
|
process.stderr.write(c.dim(`saved to ${target}`) + "\n");
|
|
6542
7526
|
} catch (e) {
|
|
6543
7527
|
process.stderr.write(c.red(`Could not write ${target}: ${e.message}`) + "\n");
|