@nanhara/hara 0.67.0 → 0.89.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/CHANGELOG.md +372 -0
- package/README.md +14 -2
- package/dist/agent/compact.js +10 -0
- package/dist/agent/context-report.js +33 -0
- package/dist/agent/failover.js +53 -0
- package/dist/agent/loop.js +75 -5
- package/dist/agent/rewind.js +20 -0
- package/dist/agent/route.js +47 -0
- package/dist/checkpoints.js +91 -0
- package/dist/config.js +10 -2
- package/dist/context/mentions.js +35 -31
- package/dist/context/subdir-hints.js +76 -0
- package/dist/cron/runner.js +7 -4
- package/dist/exec/jobs.js +82 -0
- package/dist/gateway/dingtalk.js +209 -0
- package/dist/gateway/discord.js +164 -0
- package/dist/gateway/feishu.js +144 -0
- package/dist/gateway/matrix.js +188 -0
- package/dist/gateway/mattermost.js +206 -0
- package/dist/gateway/serve.js +282 -0
- package/dist/gateway/sessions.js +89 -0
- package/dist/gateway/slack.js +190 -0
- package/dist/gateway/telegram.js +115 -0
- package/dist/gateway/tts.js +100 -0
- package/dist/gateway/weixin.js +590 -0
- package/dist/index.js +261 -52
- package/dist/org/roles.js +8 -2
- package/dist/org-fleet/enroll.js +49 -0
- package/dist/sandbox.js +15 -12
- package/dist/search/semindex.js +18 -0
- package/dist/search/zvec-store.js +77 -0
- package/dist/security/external-content.js +57 -0
- package/dist/security/permissions.js +211 -0
- package/dist/tools/builtin.js +49 -2
- package/dist/tools/send.js +35 -0
- package/dist/tools/web.js +4 -3
- package/package.json +6 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { emitKeypressEvents } from "node:readline";
|
|
5
5
|
import { runTui } from "./tui/run.js";
|
|
6
|
-
import { readClipboardImage } from "./images.js";
|
|
6
|
+
import { readClipboardImage, mediaTypeFor } from "./images.js";
|
|
7
7
|
import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
|
|
8
8
|
import { setTheme } from "./tui/theme.js";
|
|
9
9
|
import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
|
|
@@ -19,7 +19,13 @@ import { notifyDone } from "./notify.js";
|
|
|
19
19
|
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
20
20
|
import { completionScript } from "./completions.js";
|
|
21
21
|
import { renderSessionMarkdown } from "./export.js";
|
|
22
|
-
import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL } from "./org-fleet/enroll.js";
|
|
22
|
+
import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL, syncOrgRoles } from "./org-fleet/enroll.js";
|
|
23
|
+
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
24
|
+
import { routingProvider } from "./agent/route.js";
|
|
25
|
+
import { shouldAutoCompact } from "./agent/compact.js";
|
|
26
|
+
import { formatContextReport } from "./agent/context-report.js";
|
|
27
|
+
import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
|
|
28
|
+
import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
|
|
23
29
|
import { mapLimit, maxParallel } from "./concurrency.js";
|
|
24
30
|
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
|
|
25
31
|
import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
|
|
@@ -58,6 +64,7 @@ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_cre
|
|
|
58
64
|
import "./tools/skill.js"; // register the skill loader tool
|
|
59
65
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
60
66
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
67
|
+
import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
|
|
61
68
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
62
69
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
63
70
|
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
@@ -94,6 +101,15 @@ async function buildProvider(cfg) {
|
|
|
94
101
|
}
|
|
95
102
|
return createOpenAIProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL, label: cfg.provider });
|
|
96
103
|
}
|
|
104
|
+
/** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
|
|
105
|
+
* turns go to the alternate (cheap/general) model, real coding/action work stays on the primary. No-op when
|
|
106
|
+
* routeModel is unset or equals the primary model. routeBaseURL/routeApiKey default to the primary's. */
|
|
107
|
+
async function withRouting(primary, cfg) {
|
|
108
|
+
if (!primary || !cfg.routeModel || cfg.routeModel === cfg.model)
|
|
109
|
+
return primary;
|
|
110
|
+
const alt = await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey });
|
|
111
|
+
return alt ? routingProvider(primary, alt) : primary;
|
|
112
|
+
}
|
|
97
113
|
function authHint(cfg) {
|
|
98
114
|
if (cfg.provider === "qwen-oauth")
|
|
99
115
|
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
@@ -486,15 +502,59 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
486
502
|
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
487
503
|
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
488
504
|
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
489
|
-
const COMPACT_SYSTEM = "Summarize the conversation so far into a
|
|
490
|
-
"
|
|
491
|
-
"
|
|
505
|
+
const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
|
|
506
|
+
"loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
|
|
507
|
+
"then output ONLY the summary under these exact headings:\n" +
|
|
508
|
+
"1. Goal — the user's overall intent, in their own framing.\n" +
|
|
509
|
+
"2. Key decisions — choices made and why (so they aren't relitigated).\n" +
|
|
510
|
+
"3. Files & code — files created/changed and the important snippets, with why each matters.\n" +
|
|
511
|
+
"4. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
|
|
512
|
+
"5. Current state — what works now / what is verified.\n" +
|
|
513
|
+
"6. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
|
|
514
|
+
"Be specific and concrete. Drop the <analysis>; output only the headed summary.";
|
|
492
515
|
const workingSetFromSummary = (s) => s
|
|
493
516
|
.split("\n")
|
|
494
517
|
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
495
518
|
.filter((l) => l.length > 3)
|
|
496
519
|
.slice(0, 12)
|
|
497
520
|
.map((l) => l.slice(0, 140));
|
|
521
|
+
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
522
|
+
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
523
|
+
async function compactConversation(provider, history, meta, stats) {
|
|
524
|
+
if (history.length < 2)
|
|
525
|
+
return null;
|
|
526
|
+
const r = await provider.turn({
|
|
527
|
+
system: COMPACT_SYSTEM,
|
|
528
|
+
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
529
|
+
tools: [],
|
|
530
|
+
onText: () => { },
|
|
531
|
+
});
|
|
532
|
+
if (r.stop === "error")
|
|
533
|
+
return null;
|
|
534
|
+
const summary = r.text.trim();
|
|
535
|
+
if (!summary)
|
|
536
|
+
return null;
|
|
537
|
+
meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects into the next turns
|
|
538
|
+
history.length = 0;
|
|
539
|
+
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
540
|
+
stats.input += r.usage?.input ?? 0;
|
|
541
|
+
stats.output += r.usage?.output ?? 0;
|
|
542
|
+
stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary, not the old full turn
|
|
543
|
+
saveSession(meta, history);
|
|
544
|
+
return summary;
|
|
545
|
+
}
|
|
546
|
+
/** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
|
|
547
|
+
* doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
|
|
548
|
+
* a one-line status. Returns true if it compacted. */
|
|
549
|
+
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
550
|
+
const pct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|
|
551
|
+
if (!shouldAutoCompact(pct, history.length, cfg.autoCompact))
|
|
552
|
+
return false;
|
|
553
|
+
notify(`✻ Auto-compacting conversation (context ${pct}% full)…`);
|
|
554
|
+
const summary = await compactConversation(provider, history, meta, stats);
|
|
555
|
+
notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
|
|
556
|
+
return !!summary;
|
|
557
|
+
}
|
|
498
558
|
/** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
|
|
499
559
|
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
|
|
500
560
|
const roles = loadRoles(cwd);
|
|
@@ -763,11 +823,50 @@ program
|
|
|
763
823
|
if (e.model)
|
|
764
824
|
writeConfigValue("model", e.model);
|
|
765
825
|
out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
|
|
826
|
+
const nRoles = await syncOrgRoles(); // pull this device's governed digital-employee bundle (B3)
|
|
827
|
+
if (nRoles > 0)
|
|
828
|
+
out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
|
|
766
829
|
}
|
|
767
830
|
catch (err) {
|
|
768
831
|
out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
769
832
|
}
|
|
770
833
|
});
|
|
834
|
+
program
|
|
835
|
+
.command("permissions")
|
|
836
|
+
.description("show or scaffold command permission rules (bash allow/ask/deny + read-only autorun)")
|
|
837
|
+
.option("--init", "write a starter permissions.json")
|
|
838
|
+
.option("--project", "with --init, write it in this project (.hara/permissions.json) instead of globally")
|
|
839
|
+
.action((opts) => {
|
|
840
|
+
if (opts.init) {
|
|
841
|
+
const p = scaffoldPermissions(process.cwd(), opts.project ? "project" : "global");
|
|
842
|
+
return void out(p ? c.green(`✓ wrote ${p}\n`) : c.dim("(permissions file already exists — edit it directly)\n"));
|
|
843
|
+
}
|
|
844
|
+
const r = loadPermissionRules(process.cwd());
|
|
845
|
+
const pp = projectPermissionsPath(process.cwd());
|
|
846
|
+
out(c.bold("Command permissions") +
|
|
847
|
+
c.dim(" (bash) — deny blocks even in full-auto; allow / read-only auto-runs even in suggest\n") +
|
|
848
|
+
` ${c.dim("global: ")} ${globalPermissionsPath()}\n` +
|
|
849
|
+
` ${c.dim("project:")} ${pp ?? "(none)"}\n` +
|
|
850
|
+
` ${c.dim("read-only autorun:")} ${r.readonlyAutorun ? c.green("on") : "off"}\n` +
|
|
851
|
+
` ${c.green("allow")}: ${r.allow.length ? r.allow.join(", ") : c.dim("(none)")}\n` +
|
|
852
|
+
` ${c.red("deny")} : ${r.deny.length ? r.deny.join(", ") : c.dim("(none)")}\n` +
|
|
853
|
+
c.dim(" edit the JSON to customize, or `hara permissions --init` for a starter.\n"));
|
|
854
|
+
});
|
|
855
|
+
program
|
|
856
|
+
.command("gateway")
|
|
857
|
+
.description("run a chat gateway (Telegram or WeChat) so you can drive your local hara from your phone — opt-in daemon")
|
|
858
|
+
.option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk", "telegram")
|
|
859
|
+
.option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
|
|
860
|
+
.option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
|
|
861
|
+
.action(async (opts) => {
|
|
862
|
+
const mod = await import("./gateway/serve.js");
|
|
863
|
+
if (opts.platform === "weixin" && opts.login) {
|
|
864
|
+
await mod.weixinLogin();
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
|
|
868
|
+
await mod.runGateway({ cwd, platform: opts.platform });
|
|
869
|
+
});
|
|
771
870
|
program
|
|
772
871
|
.command("export [session]")
|
|
773
872
|
.description("export a session to a Markdown transcript (default: the latest in this directory)")
|
|
@@ -1220,7 +1319,9 @@ program.action(async (opts) => {
|
|
|
1220
1319
|
const cfg = loadConfig({ profile: opts.profile });
|
|
1221
1320
|
if (opts.model)
|
|
1222
1321
|
cfg.model = opts.model;
|
|
1223
|
-
const provider0 = await buildProvider(cfg);
|
|
1322
|
+
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
1323
|
+
const fallbackProvider = provider0 && cfg.fallbackModel && cfg.fallbackModel !== cfg.model ? await buildProvider({ ...cfg, model: cfg.fallbackModel, baseURL: cfg.fallbackBaseURL ?? cfg.baseURL, apiKey: cfg.fallbackApiKey ?? cfg.apiKey }) : null;
|
|
1324
|
+
const fbOpt = fallbackProvider ? { provider: fallbackProvider } : undefined; // app-failover for the main chat turns
|
|
1224
1325
|
if (!provider0) {
|
|
1225
1326
|
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
|
1226
1327
|
if (stdin.isTTY && !opts.print) {
|
|
@@ -1237,8 +1338,10 @@ program.action(async (opts) => {
|
|
|
1237
1338
|
process.exit(1);
|
|
1238
1339
|
}
|
|
1239
1340
|
let provider = provider0;
|
|
1240
|
-
if (cfg.provider === "hara-gateway")
|
|
1341
|
+
if (cfg.provider === "hara-gateway") {
|
|
1241
1342
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
1343
|
+
void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
|
|
1344
|
+
}
|
|
1242
1345
|
const cwd = cfg.cwd;
|
|
1243
1346
|
let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
|
|
1244
1347
|
let currentTurn = null; // set during a running turn so Esc can abort it
|
|
@@ -1256,16 +1359,83 @@ program.action(async (opts) => {
|
|
|
1256
1359
|
// one-shot
|
|
1257
1360
|
if (opts.print) {
|
|
1258
1361
|
const projectContext = loadAgentsMd(cwd) || undefined;
|
|
1259
|
-
|
|
1362
|
+
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
1363
|
+
// "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
|
|
1364
|
+
// describeScreenshot — a configured visionModel, else the main model if it's vision-capable.
|
|
1365
|
+
const describeImage = async (path, hint) => {
|
|
1366
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1367
|
+
const vp = cfg.visionModel
|
|
1368
|
+
? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
|
|
1369
|
+
: cap === "vision"
|
|
1370
|
+
? provider
|
|
1371
|
+
: null;
|
|
1372
|
+
if (!vp)
|
|
1373
|
+
return "";
|
|
1374
|
+
try {
|
|
1375
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
1376
|
+
}
|
|
1377
|
+
catch {
|
|
1378
|
+
return "";
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
// Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
|
|
1382
|
+
// saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
|
|
1383
|
+
// Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
|
|
1384
|
+
let meta = null;
|
|
1385
|
+
const history = [];
|
|
1386
|
+
if (opts.resume || opts.continue) {
|
|
1387
|
+
const rid = opts.resume ? (resolveSessionId(opts.resume) ?? opts.resume) : latestForCwd(cwd)?.meta.id;
|
|
1388
|
+
const prior = rid ? loadSession(rid) : null;
|
|
1389
|
+
if (prior?.history)
|
|
1390
|
+
history.push(...prior.history);
|
|
1391
|
+
meta = prior?.meta ?? { id: rid ?? newSessionId(), cwd, provider: cfg.provider, model: cfg.model, title: "", createdAt: new Date().toISOString(), updatedAt: "" };
|
|
1392
|
+
}
|
|
1393
|
+
// Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
|
|
1394
|
+
// Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
|
|
1395
|
+
// visionModel sidecar and folded into the message (text-only models can't take image blocks).
|
|
1396
|
+
const userText = expandMentions(String(opts.print), cwd);
|
|
1397
|
+
const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
|
|
1398
|
+
.split("\n")
|
|
1399
|
+
.map((s) => s.trim())
|
|
1400
|
+
.filter((p) => p && existsSync(p))
|
|
1401
|
+
.map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
|
|
1402
|
+
if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
|
|
1403
|
+
history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
|
|
1404
|
+
}
|
|
1405
|
+
else if (inboundImgs.length && cfg.visionModel) {
|
|
1406
|
+
let desc = "";
|
|
1407
|
+
try {
|
|
1408
|
+
const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
1409
|
+
if (vp)
|
|
1410
|
+
desc = await describeImages(vp, inboundImgs);
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
/* describe is best-effort — fall back to the marker-only text */
|
|
1414
|
+
}
|
|
1415
|
+
const n = inboundImgs.length;
|
|
1416
|
+
history.push({
|
|
1417
|
+
role: "user",
|
|
1418
|
+
content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
history.push({ role: "user", content: userText });
|
|
1423
|
+
}
|
|
1260
1424
|
await runAgent(history, {
|
|
1261
1425
|
provider,
|
|
1262
|
-
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role) },
|
|
1426
|
+
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role), describeImage },
|
|
1263
1427
|
approval: "full-auto",
|
|
1264
1428
|
confirm: async () => true,
|
|
1265
1429
|
projectContext,
|
|
1266
1430
|
memory: memoryDigest(cwd),
|
|
1267
1431
|
stats,
|
|
1268
1432
|
});
|
|
1433
|
+
if (meta) {
|
|
1434
|
+
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
1435
|
+
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
1436
|
+
await maybeAutoCompact(provider, history, meta, stats, cfg, () => { });
|
|
1437
|
+
saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
|
|
1438
|
+
}
|
|
1269
1439
|
if (stats.input || stats.output)
|
|
1270
1440
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1271
1441
|
await closeMcp();
|
|
@@ -1533,31 +1703,52 @@ program.action(async (opts) => {
|
|
|
1533
1703
|
out(c.green(`↩ reverted: ${r.files.join(", ")}\n`));
|
|
1534
1704
|
},
|
|
1535
1705
|
},
|
|
1706
|
+
{
|
|
1707
|
+
name: "context",
|
|
1708
|
+
desc: "show what's filling the context window (token breakdown by category)",
|
|
1709
|
+
run: () => void out(formatContextReport(history, cfg.model) + "\n"),
|
|
1710
|
+
},
|
|
1711
|
+
{
|
|
1712
|
+
name: "rewind",
|
|
1713
|
+
desc: "fork the conversation back to an earlier turn: /rewind (list) · /rewind <n> (files unchanged)",
|
|
1714
|
+
run: (a) => {
|
|
1715
|
+
const arg = (a ?? "").trim();
|
|
1716
|
+
if (!arg) {
|
|
1717
|
+
const turns = userTurnPreviews(history);
|
|
1718
|
+
return void out(turns.length ? "Recent turns (newest first) — `/rewind <n>` forks from before it (files unchanged):\n" + turns.map((t) => ` ${t.n}. ${t.preview}`).join("\n") + "\n" : c.dim("(nothing to rewind)\n"));
|
|
1719
|
+
}
|
|
1720
|
+
const nh = rewindTo(history, Number(arg));
|
|
1721
|
+
if (!nh)
|
|
1722
|
+
return void out(c.dim(`(no such turn: ${arg})\n`));
|
|
1723
|
+
history.length = 0;
|
|
1724
|
+
history.push(...nh);
|
|
1725
|
+
saveSession(meta, history);
|
|
1726
|
+
out(c.green(`(rewound — dropped the last ${arg} turn(s); ${history.length} messages kept. Files are unchanged. Type your next message.)\n`));
|
|
1727
|
+
},
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
name: "checkpoint",
|
|
1731
|
+
desc: "file-state checkpoints: /checkpoint (list) · /checkpoint restore <n> (revert files to a checkpoint)",
|
|
1732
|
+
run: (a) => {
|
|
1733
|
+
const parts = (a ?? "").trim().split(/\s+/);
|
|
1734
|
+
const cps = listCheckpoints(cwd);
|
|
1735
|
+
if (parts[0] !== "restore") {
|
|
1736
|
+
return void out(cps.length ? "File checkpoints (newest first) — `/checkpoint restore <n>` reverts files to it:\n" + cps.map((cp, i) => ` ${i + 1}. ${cp.sha} ${cp.label}`).join("\n") + "\n" : c.dim("(no checkpoints yet — taken before each turn when fileCheckpoints is on)\n"));
|
|
1737
|
+
}
|
|
1738
|
+
const cp = cps[Number(parts[1]) - 1];
|
|
1739
|
+
if (!cp)
|
|
1740
|
+
return void out(c.dim(`(no checkpoint ${parts[1] ?? ""})\n`));
|
|
1741
|
+
const k = restoreCheckpoint(cwd, cp.sha);
|
|
1742
|
+
out(k == null ? c.red("(restore failed)\n") : c.green(`(restored ${k} file(s) to ${cp.sha} — '${cp.label}'; prior state snapshotted too)\n`));
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1536
1745
|
{
|
|
1537
1746
|
name: "compact",
|
|
1538
1747
|
desc: "summarize the conversation so far to free up context",
|
|
1539
1748
|
run: async () => {
|
|
1540
|
-
if (history.length < 2)
|
|
1541
|
-
return void out(c.dim("(nothing to compact)\n"));
|
|
1542
1749
|
out(c.dim("Compacting…\n"));
|
|
1543
|
-
const
|
|
1544
|
-
|
|
1545
|
-
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
1546
|
-
tools: [],
|
|
1547
|
-
onText: () => { },
|
|
1548
|
-
});
|
|
1549
|
-
if (r.stop === "error")
|
|
1550
|
-
return void out(c.red(`(compact failed: ${r.errorMsg})\n`));
|
|
1551
|
-
const summary = r.text.trim();
|
|
1552
|
-
if (!summary)
|
|
1553
|
-
return void out(c.dim("(compact produced nothing)\n"));
|
|
1554
|
-
meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects next turns
|
|
1555
|
-
history.length = 0;
|
|
1556
|
-
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
1557
|
-
stats.input += r.usage?.input ?? 0;
|
|
1558
|
-
stats.output += r.usage?.output ?? 0;
|
|
1559
|
-
saveSession(meta, history);
|
|
1560
|
-
out(c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`));
|
|
1750
|
+
const summary = await compactConversation(provider, history, meta, stats);
|
|
1751
|
+
out(summary ? c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`) : c.dim("(nothing to compact / compact failed)\n"));
|
|
1561
1752
|
},
|
|
1562
1753
|
},
|
|
1563
1754
|
{
|
|
@@ -1771,6 +1962,33 @@ program.action(async (opts) => {
|
|
|
1771
1962
|
saveSession(meta, history);
|
|
1772
1963
|
return void h.sink.notice(`(renamed → ${meta.title})`);
|
|
1773
1964
|
}
|
|
1965
|
+
if (nm === "context")
|
|
1966
|
+
return void h.sink.notice(formatContextReport(history, cfg.model));
|
|
1967
|
+
if (nm === "rewind") {
|
|
1968
|
+
if (!arg) {
|
|
1969
|
+
const turns = userTurnPreviews(history);
|
|
1970
|
+
return void h.sink.notice(turns.length ? "Recent turns (newest first) — /rewind <n> (files unchanged):\n" + turns.map((t) => ` ${t.n}. ${t.preview}`).join("\n") : "(nothing to rewind)");
|
|
1971
|
+
}
|
|
1972
|
+
const nh = rewindTo(history, Number(arg));
|
|
1973
|
+
if (!nh)
|
|
1974
|
+
return void h.sink.notice(`(no such turn: ${arg})`);
|
|
1975
|
+
history.length = 0;
|
|
1976
|
+
history.push(...nh);
|
|
1977
|
+
saveSession(meta, history);
|
|
1978
|
+
return void h.sink.notice(`(rewound — kept ${history.length} messages; files unchanged. Type your next message.)`);
|
|
1979
|
+
}
|
|
1980
|
+
if (nm === "checkpoint") {
|
|
1981
|
+
const parts = arg.split(/\s+/);
|
|
1982
|
+
const cps = listCheckpoints(cwd);
|
|
1983
|
+
if (parts[0] !== "restore") {
|
|
1984
|
+
return void h.sink.notice(cps.length ? "File checkpoints (newest first) — /checkpoint restore <n>:\n" + cps.map((cp, i) => ` ${i + 1}. ${cp.sha} ${cp.label}`).join("\n") : "(no checkpoints yet)");
|
|
1985
|
+
}
|
|
1986
|
+
const cp = cps[Number(parts[1]) - 1];
|
|
1987
|
+
if (!cp)
|
|
1988
|
+
return void h.sink.notice(`(no checkpoint ${parts[1] ?? ""})`);
|
|
1989
|
+
const k = restoreCheckpoint(cwd, cp.sha);
|
|
1990
|
+
return void h.sink.notice(k == null ? "(restore failed)" : `(restored ${k} file(s) to ${cp.sha} — '${cp.label}')`);
|
|
1991
|
+
}
|
|
1774
1992
|
if (nm === "compact") {
|
|
1775
1993
|
if (history.length < 2)
|
|
1776
1994
|
return void h.sink.notice("(nothing to compact)");
|
|
@@ -1794,25 +2012,8 @@ program.action(async (opts) => {
|
|
|
1794
2012
|
/* flush is best-effort */
|
|
1795
2013
|
}
|
|
1796
2014
|
}
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1799
|
-
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
1800
|
-
tools: [],
|
|
1801
|
-
onText: () => { },
|
|
1802
|
-
});
|
|
1803
|
-
if (cr.stop === "error")
|
|
1804
|
-
return void h.sink.notice(`(compact failed: ${cr.errorMsg})`);
|
|
1805
|
-
const summary = cr.text.trim();
|
|
1806
|
-
if (!summary)
|
|
1807
|
-
return void h.sink.notice("(compact produced nothing)");
|
|
1808
|
-
meta.workingSet = workingSetFromSummary(summary);
|
|
1809
|
-
history.length = 0;
|
|
1810
|
-
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
1811
|
-
stats.input += cr.usage?.input ?? 0;
|
|
1812
|
-
stats.output += cr.usage?.output ?? 0;
|
|
1813
|
-
h.sink.usage(cr.usage?.input ?? 0, cr.usage?.output ?? 0);
|
|
1814
|
-
saveSession(meta, history);
|
|
1815
|
-
return void h.sink.notice(`(compacted — kept ${meta.workingSet.length} working-memory notes)`);
|
|
2015
|
+
const summary = await compactConversation(provider, history, meta, stats);
|
|
2016
|
+
return void h.sink.notice(summary ? `(compacted — kept ${meta.workingSet?.length ?? 0} working-memory notes)` : "(nothing to compact / compact failed)");
|
|
1816
2017
|
}
|
|
1817
2018
|
if (nm === "sessions") {
|
|
1818
2019
|
const ms = listSessions();
|
|
@@ -1977,6 +2178,8 @@ program.action(async (opts) => {
|
|
|
1977
2178
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (ri.extraText ?? "");
|
|
1978
2179
|
recalledContext = "";
|
|
1979
2180
|
history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
|
|
2181
|
+
if (cfg.fileCheckpoints)
|
|
2182
|
+
checkpoint(cwd, line.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
1980
2183
|
const beforeIn = stats.input;
|
|
1981
2184
|
const beforeOut = stats.output;
|
|
1982
2185
|
await runAgent(history, {
|
|
@@ -1990,6 +2193,7 @@ program.action(async (opts) => {
|
|
|
1990
2193
|
stats,
|
|
1991
2194
|
signal: h.signal,
|
|
1992
2195
|
pendingInput,
|
|
2196
|
+
fallback: fbOpt,
|
|
1993
2197
|
});
|
|
1994
2198
|
if (!meta.title) {
|
|
1995
2199
|
meta.title = await nameSession(provider, history);
|
|
@@ -1998,6 +2202,7 @@ program.action(async (opts) => {
|
|
|
1998
2202
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
1999
2203
|
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
|
|
2000
2204
|
saveSession(meta, history);
|
|
2205
|
+
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
|
|
2001
2206
|
},
|
|
2002
2207
|
});
|
|
2003
2208
|
await closeMcp();
|
|
@@ -2042,10 +2247,12 @@ program.action(async (opts) => {
|
|
|
2042
2247
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
|
|
2043
2248
|
recalledContext = "";
|
|
2044
2249
|
history.push({ role: "user", content: userContent });
|
|
2250
|
+
if (cfg.fileCheckpoints)
|
|
2251
|
+
checkpoint(cwd, userContent.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
2045
2252
|
currentTurn = new AbortController();
|
|
2046
2253
|
const t0 = Date.now();
|
|
2047
2254
|
try {
|
|
2048
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
|
|
2255
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
|
|
2049
2256
|
}
|
|
2050
2257
|
catch (e) {
|
|
2051
2258
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -2068,9 +2275,11 @@ program.action(async (opts) => {
|
|
|
2068
2275
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
|
|
2069
2276
|
}
|
|
2070
2277
|
saveSession(meta, history);
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2278
|
+
if (!(await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`))))) {
|
|
2279
|
+
const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|
|
2280
|
+
if (ctxPct >= 80)
|
|
2281
|
+
out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
|
|
2282
|
+
}
|
|
2074
2283
|
}
|
|
2075
2284
|
bar.uninstall();
|
|
2076
2285
|
rl.close();
|
package/dist/org/roles.js
CHANGED
|
@@ -12,6 +12,12 @@ export function rolesDir(cwd) {
|
|
|
12
12
|
export function globalRolesDir() {
|
|
13
13
|
return join(homedir(), ".hara", "roles");
|
|
14
14
|
}
|
|
15
|
+
/** Org-pushed roles (B-end): the digital-employee bundle synced from hara-control's `/v1/roles` into
|
|
16
|
+
* `~/.hara/org-roles/*.md` (see org-fleet/enroll.ts syncOrgRoles). A managed baseline — above
|
|
17
|
+
* third-party plugins, but a dev's own global/project roles still win. */
|
|
18
|
+
export function orgRolesDir() {
|
|
19
|
+
return join(homedir(), ".hara", "org-roles");
|
|
20
|
+
}
|
|
15
21
|
/** Claude-Code subagents (`.claude/agents/*.md`) — consumed for ecosystem interop (project scope). */
|
|
16
22
|
export function claudeAgentsDir(cwd) {
|
|
17
23
|
return join(findProjectRoot(cwd), ".claude", "agents");
|
|
@@ -62,8 +68,8 @@ export function subagentToolFilter(role, isReadonly) {
|
|
|
62
68
|
}
|
|
63
69
|
export function loadRoles(cwd) {
|
|
64
70
|
const byId = new Map();
|
|
65
|
-
// lowest→highest precedence: plugins < global < .claude/agents < .hara/roles (project wins
|
|
66
|
-
for (const dir of [...pluginRoleDirs(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
|
|
71
|
+
// lowest→highest precedence: plugins < org(B-end push) < global < .claude/agents < .hara/roles (project wins)
|
|
72
|
+
for (const dir of [...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
|
|
67
73
|
if (!existsSync(dir))
|
|
68
74
|
continue;
|
|
69
75
|
for (const f of readdirSync(dir)) {
|
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
// Protocol (what `hara-control` implements on the other end):
|
|
8
8
|
// POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
|
|
9
9
|
// POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
|
|
10
|
+
// GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
|
|
10
11
|
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
11
12
|
import { homedir, hostname, platform } from "node:os";
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
|
|
15
|
+
import { orgRolesDir } from "../org/roles.js";
|
|
14
16
|
const orgPath = () => join(homedir(), ".hara", "org.json");
|
|
15
17
|
const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
|
|
16
18
|
/** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
|
|
@@ -92,3 +94,50 @@ export async function heartbeat(signal) {
|
|
|
92
94
|
return false;
|
|
93
95
|
}
|
|
94
96
|
}
|
|
97
|
+
/** Render one bundle role into the markdown frontmatter the CLI role loader expects
|
|
98
|
+
* (src/org/roles.ts parseFrontmatter): name/description/owns/rejects/model/allowTools/denyTools, body=system. */
|
|
99
|
+
function renderRoleMd(r) {
|
|
100
|
+
const fm = ["---", `name: ${r.name}`];
|
|
101
|
+
if (r.description)
|
|
102
|
+
fm.push(`description: ${r.description}`);
|
|
103
|
+
if (r.owns?.length)
|
|
104
|
+
fm.push(`owns: [${r.owns.join(", ")}]`);
|
|
105
|
+
if (r.rejects?.length)
|
|
106
|
+
fm.push(`rejects: [${r.rejects.join(", ")}]`);
|
|
107
|
+
if (r.model)
|
|
108
|
+
fm.push(`model: ${r.model}`);
|
|
109
|
+
if (r.allow_tools?.length)
|
|
110
|
+
fm.push(`allowTools: [${r.allow_tools.join(", ")}]`); // snake_case wire → camelCase fm
|
|
111
|
+
if (r.deny_tools?.length)
|
|
112
|
+
fm.push(`denyTools: [${r.deny_tools.join(", ")}]`);
|
|
113
|
+
fm.push("---", "", (r.system || "").trim(), "");
|
|
114
|
+
return fm.join("\n");
|
|
115
|
+
}
|
|
116
|
+
/** Pull this device's governed role bundle from the control plane and materialize it into
|
|
117
|
+
* `~/.hara/org-roles/*.md` — a managed precedence layer below the dev's own global/project roles
|
|
118
|
+
* (see src/org/roles.ts loadRoles). The org bundle is AUTHORITATIVE: the dir is wiped and rewritten on
|
|
119
|
+
* every sync, so a server-side revoke/rename actually removes the local role. Best-effort: never throws;
|
|
120
|
+
* returns the count of roles written (0 on any failure / not enrolled / empty bundle). */
|
|
121
|
+
export async function syncOrgRoles(signal) {
|
|
122
|
+
const e = loadEnrollment();
|
|
123
|
+
if (!e)
|
|
124
|
+
return 0;
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(`${e.gatewayUrl}/v1/roles`, { signal, headers: { authorization: `Bearer ${e.deviceToken}` } });
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
return 0;
|
|
129
|
+
const bundle = (await res.json());
|
|
130
|
+
const roles = Array.isArray(bundle.roles) ? bundle.roles.filter((r) => r && r.name && r.system) : [];
|
|
131
|
+
const dir = orgRolesDir();
|
|
132
|
+
rmSync(dir, { recursive: true, force: true }); // authoritative replace
|
|
133
|
+
mkdirSync(dir, { recursive: true });
|
|
134
|
+
for (const r of roles)
|
|
135
|
+
writeFileSync(join(dir, `${r.name}.md`), renderRoleMd(r), "utf8");
|
|
136
|
+
// org policy sidecar (model/tool/approval floors the CLI enforces; skipped by the .md-only role loader)
|
|
137
|
+
writeFileSync(join(dir, "_policy.json"), JSON.stringify({ version: bundle.version ?? 0, org_policy: bundle.org_policy ?? {} }, null, 2) + "\n", "utf8");
|
|
138
|
+
return roles.length;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/dist/sandbox.js
CHANGED
|
@@ -33,24 +33,27 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
|
|
|
33
33
|
* Streams output via `opts.onData` while capturing it for the resolved value.
|
|
34
34
|
* Resolves on exit 0; rejects (with `.stdout`/`.stderr`/`.code`) on nonzero exit or timeout.
|
|
35
35
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
/** Build the (sandboxed, when supported) argv for a shell command — shared by runShell + background jobs
|
|
37
|
+
* so the seatbelt write-confinement is identical for both. */
|
|
38
|
+
export function shellCommand(command, cwd, mode) {
|
|
39
39
|
if (mode !== "off" && platform() === "darwin") {
|
|
40
40
|
const dir = mkdtempSync(join(tmpdir(), "hara-sb-"));
|
|
41
41
|
const profileFile = join(dir, "policy.sb");
|
|
42
42
|
writeFileSync(profileFile, seatbeltProfile(cwd, mode));
|
|
43
|
-
cmd
|
|
44
|
-
args = ["-f", profileFile, "/bin/bash", "-lc", command];
|
|
43
|
+
return { cmd: "sandbox-exec", args: ["-f", profileFile, "/bin/bash", "-lc", command] };
|
|
45
44
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
maybeWarnUnsandboxed(mode);
|
|
46
|
+
return { cmd: "/bin/sh", args: ["-c", command] };
|
|
47
|
+
}
|
|
48
|
+
/** One-time-per-process notice that --sandbox is a no-op off macOS (covers every entry point). */
|
|
49
|
+
export function maybeWarnUnsandboxed(mode) {
|
|
50
|
+
if (mode !== "off" && !warnedUnsandboxed) {
|
|
51
|
+
warnedUnsandboxed = true;
|
|
52
|
+
process.stderr.write(`hara: --sandbox ${mode} is macOS-only — the shell runs UNSANDBOXED on ${platform()}.\n`);
|
|
53
53
|
}
|
|
54
|
+
}
|
|
55
|
+
export function runShell(command, cwd, mode, opts) {
|
|
56
|
+
const { cmd, args } = shellCommand(command, cwd, mode);
|
|
54
57
|
return new Promise((resolve, reject) => {
|
|
55
58
|
const child = spawn(cmd, args, { cwd });
|
|
56
59
|
let stdout = "";
|
package/dist/search/semindex.js
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
8
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
9
9
|
import { listProjectFiles, walkFiles, isProbablyBinary, fileSize } from "../fs-walk.js";
|
|
10
|
+
import { zvecBuild, zvecQueryIds } from "./zvec-store.js";
|
|
10
11
|
// Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
|
|
11
12
|
const CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cc|cpp|hpp|cs|swift|scala|sh|bash|sql|md|mdx|json|ya?ml|toml|html|css|scss|less|vue|svelte|astro|tf|proto|graphql|gql|gradle|txt)$/i;
|
|
12
13
|
/** Index location — repo index lives in the project (gitignore it); the rest are global. Derived/rebuildable. */
|
|
@@ -116,6 +117,8 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
|
|
|
116
117
|
if (!existsSync(join(dir, ".gitignore")))
|
|
117
118
|
writeFileSync(join(dir, ".gitignore"), "*\n", "utf8");
|
|
118
119
|
writeFileSync(p, JSON.stringify({ model, items }), "utf8");
|
|
120
|
+
// Build a zvec ANN index alongside the JSON cache (best-effort; queryIndex prefers it for retrieval).
|
|
121
|
+
await zvecBuild(name, items.map((it) => ({ id: it.id, vec: it.vec })), cwd);
|
|
119
122
|
return { total: items.length, embedded: toEmbed.length, reused };
|
|
120
123
|
}
|
|
121
124
|
export function indexExists(name, cwd) {
|
|
@@ -192,6 +195,21 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
|
|
|
192
195
|
const [qv] = await embed([query]);
|
|
193
196
|
if (!qv)
|
|
194
197
|
return [];
|
|
198
|
+
// Prefer the zvec ANN index for candidate retrieval; re-rank candidates by EXACT cosine from the JSON
|
|
199
|
+
// store (identical score semantics to the brute-force path). Fall back to full brute-force if zvec is
|
|
200
|
+
// unavailable / has no index / errors.
|
|
201
|
+
const ids = await zvecQueryIds(name, qv, cwd, k);
|
|
202
|
+
if (ids?.length) {
|
|
203
|
+
const byId = new Map(idx.items.map((it) => [it.id, it]));
|
|
204
|
+
const hits = ids
|
|
205
|
+
.map((id) => byId.get(id))
|
|
206
|
+
.filter((it) => Boolean(it))
|
|
207
|
+
.map((it) => ({ file: it.file, source: it.source, text: it.text, score: cosine(qv, it.vec) }))
|
|
208
|
+
.sort((a, b) => b.score - a.score)
|
|
209
|
+
.slice(0, k);
|
|
210
|
+
if (hits.length)
|
|
211
|
+
return hits;
|
|
212
|
+
}
|
|
195
213
|
return idx.items
|
|
196
214
|
.map((it) => ({ file: it.file, source: it.source, text: it.text, score: cosine(qv, it.vec) }))
|
|
197
215
|
.sort((a, b) => b.score - a.score)
|