@gamaze/hicortex 0.19.6 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -1
- package/dist/consolidate.d.ts +21 -0
- package/dist/consolidate.js +68 -8
- package/dist/dashboard.js +5 -2
- package/dist/identity-store.d.ts +3 -3
- package/dist/identity-store.js +2 -2
- package/dist/init.d.ts +10 -1
- package/dist/init.js +210 -22
- package/dist/llm-flight.d.ts +30 -0
- package/dist/llm-flight.js +190 -0
- package/dist/llm.d.ts +32 -0
- package/dist/llm.js +69 -2
- package/dist/nightly.d.ts +41 -0
- package/dist/nightly.js +92 -5
- package/dist/opencode-transcript-reader.d.ts +63 -0
- package/dist/opencode-transcript-reader.js +181 -0
- package/dist/status.js +23 -0
- package/dist/telemetry.d.ts +5 -5
- package/dist/telemetry.js +5 -5
- package/dist/uninstall.js +34 -0
- package/hermes-plugin/hicortex/README.md +2 -2
- package/hermes-plugin/hicortex/config.py +17 -56
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +4 -1
- package/opencode-plugin/hicortex/index.ts +981 -0
- package/package.json +6 -4
- package/pi-extension/hicortex/README.md +56 -0
- package/pi-extension/hicortex/index.ts +888 -0
package/dist/init.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* 2. Remote HC server (HICORTEX_SERVER_URL — any reachable host:port)
|
|
8
8
|
* 3. OC plugin installed (~/.openclaw/openclaw.json)
|
|
9
9
|
* 4. CC MCP already registered (~/.claude/settings.json)
|
|
10
|
-
* 5.
|
|
10
|
+
* 5. Hermes present (~/.hermes) / Pi present (~/.pi/agent) /
|
|
11
|
+
* opencode present (~/.config/opencode or ~/.local/share/opencode)
|
|
12
|
+
* 6. Existing DB (~/.hicortex/ or ~/.openclaw/data/)
|
|
11
13
|
*
|
|
12
14
|
* Actions:
|
|
13
15
|
* - Install persistent daemon (launchd/systemd)
|
|
@@ -19,6 +21,8 @@
|
|
|
19
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
22
|
exports.GENERIC_DEFAULT_DOMAINS = void 0;
|
|
21
23
|
exports.parseMcpListStatus = parseMcpListStatus;
|
|
24
|
+
exports.readPreviousServerUrl = readPreviousServerUrl;
|
|
25
|
+
exports.setupHermes = setupHermes;
|
|
22
26
|
exports.parseEnvFile = parseEnvFile;
|
|
23
27
|
exports.isLlmConfigured = isLlmConfigured;
|
|
24
28
|
exports.persistLlmConfig = persistLlmConfig;
|
|
@@ -80,6 +84,11 @@ const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "
|
|
|
80
84
|
const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
|
|
81
85
|
const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
|
|
82
86
|
const HERMES_HOME = process.env.HERMES_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), ".hermes");
|
|
87
|
+
/** Pi's agent dir — its presence means Pi is installed and will load extensions. */
|
|
88
|
+
const PI_AGENT_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent");
|
|
89
|
+
/** opencode's config/data dirs — either present means opencode is installed. */
|
|
90
|
+
const OPENCODE_CONFIG_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "opencode");
|
|
91
|
+
const OPENCODE_DATA_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".local", "share", "opencode");
|
|
83
92
|
const DEFAULT_PORT = 8787;
|
|
84
93
|
async function detect() {
|
|
85
94
|
const result = {
|
|
@@ -88,6 +97,8 @@ async function detect() {
|
|
|
88
97
|
ocPlugin: false,
|
|
89
98
|
ccMcpRegistered: false,
|
|
90
99
|
hermesFound: false,
|
|
100
|
+
piFound: false,
|
|
101
|
+
opencodeFound: false,
|
|
91
102
|
existingDb: false,
|
|
92
103
|
};
|
|
93
104
|
// Check local server. /health/detail carries the diagnostics (memories,
|
|
@@ -127,6 +138,11 @@ async function detect() {
|
|
|
127
138
|
}
|
|
128
139
|
// Check Hermes
|
|
129
140
|
result.hermesFound = (0, node_fs_1.existsSync)(HERMES_HOME);
|
|
141
|
+
// Check Pi (~/.pi/agent — the dir Pi loads extensions from)
|
|
142
|
+
result.piFound = (0, node_fs_1.existsSync)(PI_AGENT_DIR);
|
|
143
|
+
// Check opencode (~/.config/opencode or ~/.local/share/opencode — its
|
|
144
|
+
// global plugins dir / session store; it auto-loads ~/.config/opencode/plugins/)
|
|
145
|
+
result.opencodeFound = (0, node_fs_1.existsSync)(OPENCODE_CONFIG_DIR) || (0, node_fs_1.existsSync)(OPENCODE_DATA_DIR);
|
|
130
146
|
// Check OC plugin
|
|
131
147
|
try {
|
|
132
148
|
const raw = (0, node_fs_1.readFileSync)(OC_CONFIG, "utf-8");
|
|
@@ -331,9 +347,75 @@ function cleanupLegacyCcCommands() {
|
|
|
331
347
|
}
|
|
332
348
|
}
|
|
333
349
|
// ---------------------------------------------------------------------------
|
|
350
|
+
// Pi setup
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
/**
|
|
353
|
+
* Install the bundled Pi extension (#348): one dependency-free .ts file Pi
|
|
354
|
+
* loads from ~/.pi/agent/extensions/. Deliberately NO config write — the
|
|
355
|
+
* extension self-resolves the server from ~/.hicortex/config.json (the file
|
|
356
|
+
* init has already written by this point), so there is nothing to keep in
|
|
357
|
+
* sync and no secret to route elsewhere. Overwrites on re-init so upgrades
|
|
358
|
+
* land; skips gracefully when the bundled source is absent (e.g. a dev
|
|
359
|
+
* checkout without the packaged copy).
|
|
360
|
+
*/
|
|
361
|
+
function setupPi() {
|
|
362
|
+
const extensionSource = (0, node_path_1.join)(__dirname, "..", "pi-extension", "hicortex", "index.ts");
|
|
363
|
+
if (!(0, node_fs_1.existsSync)(extensionSource)) {
|
|
364
|
+
console.log(" ⚠ Pi extension not found in package — skipping Pi setup");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const extensionsDir = (0, node_path_1.join)(PI_AGENT_DIR, "extensions");
|
|
368
|
+
(0, node_fs_1.mkdirSync)(extensionsDir, { recursive: true });
|
|
369
|
+
const target = (0, node_path_1.join)(extensionsDir, "hicortex.ts");
|
|
370
|
+
(0, node_fs_1.copyFileSync)(extensionSource, target);
|
|
371
|
+
console.log(` ✓ Copied Pi extension to ${target}`);
|
|
372
|
+
console.log(" → Restart Pi sessions to load the extension (recall, identity, lessons, 9 tools)");
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// opencode setup
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
/**
|
|
378
|
+
* Install the bundled opencode plugin (#347): one dependency-free .ts file
|
|
379
|
+
* opencode auto-loads from ~/.config/opencode/plugins/. Deliberately NO
|
|
380
|
+
* write into opencode's own configuration — the plugin self-resolves the
|
|
381
|
+
* server from ~/.hicortex/config.json (the file init has already written by
|
|
382
|
+
* this point), so there is nothing to keep in sync and no secret to route
|
|
383
|
+
* elsewhere. Overwrites on re-init so upgrades land; skips gracefully when
|
|
384
|
+
* the bundled source is absent (e.g. a dev checkout without the packaged
|
|
385
|
+
* copy). The plugins directory may hold third-party files — the copy only
|
|
386
|
+
* ever touches our own hicortex.ts name.
|
|
387
|
+
*/
|
|
388
|
+
function setupOpencode() {
|
|
389
|
+
const pluginSource = (0, node_path_1.join)(__dirname, "..", "opencode-plugin", "hicortex", "index.ts");
|
|
390
|
+
if (!(0, node_fs_1.existsSync)(pluginSource)) {
|
|
391
|
+
console.log(" ⚠ opencode plugin not found in package — skipping opencode setup");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const pluginsDir = (0, node_path_1.join)(OPENCODE_CONFIG_DIR, "plugins");
|
|
395
|
+
(0, node_fs_1.mkdirSync)(pluginsDir, { recursive: true });
|
|
396
|
+
const target = (0, node_path_1.join)(pluginsDir, "hicortex.ts");
|
|
397
|
+
(0, node_fs_1.copyFileSync)(pluginSource, target);
|
|
398
|
+
console.log(` ✓ Copied opencode plugin to ${target}`);
|
|
399
|
+
console.log(" → Restart opencode sessions to load the plugin (recall, identity, lessons, 9 tools)");
|
|
400
|
+
}
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
334
402
|
// Hermes setup
|
|
335
403
|
// ---------------------------------------------------------------------------
|
|
336
|
-
|
|
404
|
+
/**
|
|
405
|
+
* The client config's serverUrl BEFORE this run re-points it — the value init
|
|
406
|
+
* last wrote into the Hermes plugin's config (so the plugin step can tell an
|
|
407
|
+
* init-owned URL from a user-set one). Exported for tests.
|
|
408
|
+
*/
|
|
409
|
+
function readPreviousServerUrl(configPath) {
|
|
410
|
+
try {
|
|
411
|
+
const { config: prev } = loadConfigStrict(configPath);
|
|
412
|
+
if (typeof prev.serverUrl === "string" && prev.serverUrl)
|
|
413
|
+
return prev.serverUrl;
|
|
414
|
+
}
|
|
415
|
+
catch { /* no prior config, or unreadable — nothing init-owned to carry */ }
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
function setupHermes(serverUrl, authToken, previousServerUrl) {
|
|
337
419
|
const pluginSource = (0, node_path_1.join)(__dirname, "..", "hermes-plugin", "hicortex");
|
|
338
420
|
if (!(0, node_fs_1.existsSync)(pluginSource)) {
|
|
339
421
|
console.log(" ⚠ Hermes plugin not found in package — skipping Hermes setup");
|
|
@@ -341,23 +423,86 @@ function setupHermes(serverUrl, authToken) {
|
|
|
341
423
|
}
|
|
342
424
|
const pluginsDir = (0, node_path_1.join)(HERMES_HOME, "plugins", "hicortex");
|
|
343
425
|
(0, node_fs_1.mkdirSync)(pluginsDir, { recursive: true });
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
426
|
+
// If the plugin was installed via `hermes plugins install` (a git checkout
|
|
427
|
+
// that actually contains the plugin — .git alone could be an empty/partial
|
|
428
|
+
// dir), do NOT copy over it — that would mix a managed checkout with
|
|
429
|
+
// copied files and break its own update path. The config below is still
|
|
430
|
+
// written: Hermes' own `memory setup` writes config.json into that tree
|
|
431
|
+
// too, so the file is expected there regardless of install path.
|
|
432
|
+
const isGitInstall = (0, node_fs_1.existsSync)((0, node_path_1.join)(pluginsDir, ".git")) && (0, node_fs_1.existsSync)((0, node_path_1.join)(pluginsDir, "plugin.yaml"));
|
|
433
|
+
if (isGitInstall) {
|
|
434
|
+
console.log(" ✓ Hermes plugin already installed (git checkout) — leaving its files, updating config only");
|
|
351
435
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
436
|
+
else {
|
|
437
|
+
const pluginFiles = (0, node_fs_1.readdirSync)(pluginSource);
|
|
438
|
+
for (const f of pluginFiles) {
|
|
439
|
+
// NEVER copy a bundled config.json over the user's: a stray one in a
|
|
440
|
+
// developer tree ships via prepack, and clobbering here would defeat
|
|
441
|
+
// the merge logic below (it runs AFTER this loop).
|
|
442
|
+
if (f === "config.json")
|
|
443
|
+
continue;
|
|
444
|
+
const src = (0, node_path_1.join)(pluginSource, f);
|
|
445
|
+
if ((0, node_fs_1.statSync)(src).isFile()) {
|
|
446
|
+
(0, node_fs_1.copyFileSync)(src, (0, node_path_1.join)(pluginsDir, f));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
console.log(` ✓ Copied Hermes plugin to ${pluginsDir}`);
|
|
450
|
+
}
|
|
451
|
+
// Plugin config.json: MERGE, never blindly clobber. The URL is overwritten
|
|
452
|
+
// when it is INIT-OWNED — unset, a local default (either host spelling:
|
|
453
|
+
// init has written both `localhost` and `127.0.0.1` variants historically),
|
|
454
|
+
// or exactly what init last wrote (previousServerUrl — the client config's
|
|
455
|
+
// serverUrl BEFORE this run re-pointed it). A value the USER set by hand to
|
|
456
|
+
// something else is preserved WITH A LOUD DIVERGENCE WARNING — silently
|
|
457
|
+
// keeping a stale endpoint on a re-point is the failure this guards
|
|
458
|
+
// (CR: re-point must reach the plugin like it reaches CC's MCP entry).
|
|
459
|
+
// The auth token is a SECRET and is deliberately NOT written here —
|
|
460
|
+
// `hermes memory setup` routes it to $HERMES_HOME/.env, and localhost
|
|
461
|
+
// bypasses auth entirely.
|
|
462
|
+
const stripTrailingSlash = (v) => v.replace(/\/+$/, "");
|
|
463
|
+
const localDefaults = [`http://localhost:${DEFAULT_PORT}`, `http://127.0.0.1:${DEFAULT_PORT}`];
|
|
464
|
+
// Normalize before comparing (trailing-slash variants of the same URL must
|
|
465
|
+
// not read as user-set — the client flow strips, the env-var flow does not).
|
|
466
|
+
const normalizedPrevious = previousServerUrl ? stripTrailingSlash(previousServerUrl) : undefined;
|
|
467
|
+
const isInitOwnedUrl = (v) => {
|
|
468
|
+
if (typeof v !== "string" || v === "")
|
|
469
|
+
return true;
|
|
470
|
+
const nv = stripTrailingSlash(v);
|
|
471
|
+
return localDefaults.includes(nv) || nv === normalizedPrevious;
|
|
472
|
+
};
|
|
358
473
|
const configPath = (0, node_path_1.join)(pluginsDir, "config.json");
|
|
359
|
-
|
|
360
|
-
|
|
474
|
+
let config = {};
|
|
475
|
+
if ((0, node_fs_1.existsSync)(configPath)) {
|
|
476
|
+
try {
|
|
477
|
+
config = loadConfigStrict(configPath).config;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// Corrupt/unreadable plugin config: REPLACE (this file holds no secrets
|
|
481
|
+
// by design — only URL/knobs; reset to defaults) — loudly, never
|
|
482
|
+
// silently, never fatally. (The underlying error's copy speaks of
|
|
483
|
+
// ~/.hicortex config semantics that do not apply to this file, so it
|
|
484
|
+
// is deliberately not echoed.)
|
|
485
|
+
console.log(" ⚠ Plugin config.json unreadable — replacing it (URL re-prefilled; optional knobs reset to defaults; no secrets live in this file)");
|
|
486
|
+
config = {};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const normalizedServerUrl = stripTrailingSlash(serverUrl);
|
|
490
|
+
let keptExistingUrl = false;
|
|
491
|
+
if (isInitOwnedUrl(config.hicortex_url)) {
|
|
492
|
+
config.hicortex_url = normalizedServerUrl;
|
|
493
|
+
}
|
|
494
|
+
else if (stripTrailingSlash(config.hicortex_url) !== normalizedServerUrl) {
|
|
495
|
+
keptExistingUrl = true;
|
|
496
|
+
console.log(` ℹ Kept existing plugin URL ${config.hicortex_url} (differs from this install's ${normalizedServerUrl})`);
|
|
497
|
+
console.log(" Edit ~/.hermes/plugins/hicortex/config.json if that is not what you want.");
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
(0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
|
|
501
|
+
console.log(keptExistingUrl ? ` ℹ Plugin config stays on ${config.hicortex_url}` : ` ✓ Plugin config → ${config.hicortex_url}`);
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
console.log(` ⚠ Could not write plugin config (${e instanceof Error ? e.message : e}) — Hermes setup skipped config pre-fill`);
|
|
505
|
+
}
|
|
361
506
|
// For profile-based setups, symlink the shared plugin into each profile's
|
|
362
507
|
// plugin dir (non-destructive). Discovery scans $HERMES_HOME/plugins/, so
|
|
363
508
|
// this is belt-and-suspenders for profile-scoped installs.
|
|
@@ -394,14 +539,26 @@ function setupHermes(serverUrl, authToken) {
|
|
|
394
539
|
// Activation is left to Hermes' own tooling. We NEVER edit config.yaml —
|
|
395
540
|
// Hermes' `memory setup` discovers this plugin automatically and writes the
|
|
396
541
|
// config with its own YAML-aware writer (routing the token to .env).
|
|
397
|
-
|
|
398
|
-
|
|
542
|
+
// The token is PRINTED here for the remote case (the user's own terminal —
|
|
543
|
+
// same visibility as `hicortex status`); Hermes has no non-interactive
|
|
544
|
+
// setup path, so one paste is the floor. When a user-set URL was KEPT, the
|
|
545
|
+
// token hint names that server's token requirement instead (this install's
|
|
546
|
+
// token belongs to a different server).
|
|
547
|
+
console.log(" → Activate with: hermes memory setup hicortex");
|
|
399
548
|
if ((0, node_fs_1.existsSync)(profilesDir)) {
|
|
400
549
|
console.log(" Run once per profile if you use Hermes profiles.");
|
|
401
550
|
}
|
|
402
|
-
if (
|
|
403
|
-
console.log("
|
|
551
|
+
if (keptExistingUrl) {
|
|
552
|
+
console.log(" The kept URL's server needs ITS token — run `hicortex status` on that machine if you don't have it.");
|
|
553
|
+
}
|
|
554
|
+
else if (authToken) {
|
|
555
|
+
console.log(` When prompted for the token, paste: ${authToken}`);
|
|
556
|
+
console.log(" (stored by Hermes in $HERMES_HOME/.env — never in the plugin's config.json)");
|
|
404
557
|
}
|
|
558
|
+
else {
|
|
559
|
+
console.log(" Local server: leave the token blank — localhost bypasses auth.");
|
|
560
|
+
}
|
|
561
|
+
console.log(" Installed Hermes AFTER Hicortex? Re-running `npx @gamaze/hicortex init` redoes this step.");
|
|
405
562
|
}
|
|
406
563
|
/**
|
|
407
564
|
* Parse a KEY=VALUE env file (e.g. ~/.hermes/.env or ~/.claude/settings.json env block).
|
|
@@ -1501,6 +1658,10 @@ async function runInit(options = {}) {
|
|
|
1501
1658
|
console.log(" • OpenClaw plugin installed");
|
|
1502
1659
|
if (d.hermesFound)
|
|
1503
1660
|
console.log(` • Hermes found at ${HERMES_HOME}`);
|
|
1661
|
+
if (d.piFound)
|
|
1662
|
+
console.log(` • Pi found at ${PI_AGENT_DIR}`);
|
|
1663
|
+
if (d.opencodeFound)
|
|
1664
|
+
console.log(` • opencode found at ${OPENCODE_CONFIG_DIR}`);
|
|
1504
1665
|
if (d.ccMcpRegistered)
|
|
1505
1666
|
console.log(" • CC MCP already registered");
|
|
1506
1667
|
if (d.existingDb)
|
|
@@ -1532,6 +1693,10 @@ async function runInit(options = {}) {
|
|
|
1532
1693
|
actions.push("Register MCP server in CC settings");
|
|
1533
1694
|
if (d.hermesFound)
|
|
1534
1695
|
actions.push("Install Hermes plugin + configure");
|
|
1696
|
+
if (d.piFound)
|
|
1697
|
+
actions.push("Install Pi extension");
|
|
1698
|
+
if (d.opencodeFound)
|
|
1699
|
+
actions.push("Install opencode plugin");
|
|
1535
1700
|
actions.push("Install SessionStart hook (query-time lessons)");
|
|
1536
1701
|
if (actions.length === 0) {
|
|
1537
1702
|
console.log("Everything is already configured. Nothing to do.");
|
|
@@ -1649,6 +1814,14 @@ async function runInit(options = {}) {
|
|
|
1649
1814
|
const isLocal = serverUrl.includes("127.0.0.1") || serverUrl.includes("localhost");
|
|
1650
1815
|
setupHermes(serverUrl, isLocal ? "" : authToken);
|
|
1651
1816
|
}
|
|
1817
|
+
// Setup the Pi extension if detected (self-resolving — no config write)
|
|
1818
|
+
if (d.piFound) {
|
|
1819
|
+
setupPi();
|
|
1820
|
+
}
|
|
1821
|
+
// Setup the opencode plugin if detected (self-resolving — no config write)
|
|
1822
|
+
if (d.opencodeFound) {
|
|
1823
|
+
setupOpencode();
|
|
1824
|
+
}
|
|
1652
1825
|
// Install CC SessionStart hook for query-time lesson injection.
|
|
1653
1826
|
// Lessons are now fetched live at session start — no static CLAUDE.md block needed.
|
|
1654
1827
|
installSessionStartHook();
|
|
@@ -1768,6 +1941,11 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1768
1941
|
// writeClientConfig: strict-load → apply overrides → save. Throws on a
|
|
1769
1942
|
// malformed existing config (0.16.x BLOCKER — never wipe the client's
|
|
1770
1943
|
// authToken/licenseKey). ENOENT → fresh client config.
|
|
1944
|
+
// The PRE-overwrite serverUrl is captured first: it is what init last
|
|
1945
|
+
// wrote into the Hermes plugin's config, so the plugin step can tell an
|
|
1946
|
+
// init-owned URL (update it — re-point must reach Hermes like it reaches
|
|
1947
|
+
// CC's MCP entry) from a user-set one (keep + warn).
|
|
1948
|
+
const previousServerUrl = readPreviousServerUrl(configPath);
|
|
1771
1949
|
const { config } = writeClientConfig(configPath, { serverUrl, authToken }, nameDecision);
|
|
1772
1950
|
console.log(` ✓ Client config saved to ${configPath}`);
|
|
1773
1951
|
if (typeof config.agentName === "string") {
|
|
@@ -1824,7 +2002,17 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1824
2002
|
// Step 8: Setup Hermes if detected
|
|
1825
2003
|
if ((0, node_fs_1.existsSync)(HERMES_HOME)) {
|
|
1826
2004
|
console.log("\nHermes detected — installing plugin...");
|
|
1827
|
-
setupHermes(serverUrl, authToken);
|
|
2005
|
+
setupHermes(serverUrl, authToken, previousServerUrl);
|
|
2006
|
+
}
|
|
2007
|
+
// Step 8b: Setup the Pi extension if detected (self-resolving — no config write)
|
|
2008
|
+
if ((0, node_fs_1.existsSync)(PI_AGENT_DIR)) {
|
|
2009
|
+
console.log("\nPi detected — installing extension...");
|
|
2010
|
+
setupPi();
|
|
2011
|
+
}
|
|
2012
|
+
// Step 8c: Setup the opencode plugin if detected (self-resolving — no config write)
|
|
2013
|
+
if ((0, node_fs_1.existsSync)(OPENCODE_CONFIG_DIR) || (0, node_fs_1.existsSync)(OPENCODE_DATA_DIR)) {
|
|
2014
|
+
console.log("\nopencode detected — installing plugin...");
|
|
2015
|
+
setupOpencode();
|
|
1828
2016
|
}
|
|
1829
2017
|
console.log("\n✓ Hicortex client setup complete!\n");
|
|
1830
2018
|
// Telemetry disclosure at install time (informed consent, best practice):
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type LlmFlightGuard = {
|
|
2
|
+
kind: "acquired";
|
|
3
|
+
release: () => void;
|
|
4
|
+
}
|
|
5
|
+
/** Waited past `waitMs` for the holder — the caller treats this as a
|
|
6
|
+
* total-failure-class error (its message contains "timeout" so the retry
|
|
7
|
+
* ladder and the #337 breaker classify it as endpoint-down). */
|
|
8
|
+
| {
|
|
9
|
+
kind: "timeout";
|
|
10
|
+
}
|
|
11
|
+
/** Filesystem refused the lock — proceed unserialized (fail-open). */
|
|
12
|
+
| {
|
|
13
|
+
kind: "open";
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Default staleness floor: a lock older than this is stale REGARDLESS of the
|
|
17
|
+
* recorded pid (guards the recycled-pid case, capture A5 fix 2). The caller
|
|
18
|
+
* passes `staleMs = max(this default, 2× llmTimeoutMs)` so an operator who
|
|
19
|
+
* raises the timeout ceiling can never have a LIVE call's lock reclaimed
|
|
20
|
+
* mid-flight (CR #355 finding 3 — a reclaim-while-running is exactly the
|
|
21
|
+
* two-concurrent-calls crash class this guard exists to prevent).
|
|
22
|
+
*/
|
|
23
|
+
export declare const DEFAULT_STALE_MS: number;
|
|
24
|
+
/** Lock path for an endpoint key — exported for tests and diagnostics. */
|
|
25
|
+
export declare function llmFlightLockPath(homeDir: string, endpointKey: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Acquire the single-flight lock for `endpointKey`, waiting up to `waitMs`.
|
|
28
|
+
* Never throws: every filesystem surprise degrades to `{ kind: "open" }`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function acquireLlmFlight(homeDir: string, endpointKey: string, waitMs: number, staleMs?: number): Promise<LlmFlightGuard>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_STALE_MS = void 0;
|
|
4
|
+
exports.llmFlightLockPath = llmFlightLockPath;
|
|
5
|
+
exports.acquireLlmFlight = acquireLlmFlight;
|
|
6
|
+
/**
|
|
7
|
+
* Single-flight guard for LLM endpoint calls (#355).
|
|
8
|
+
*
|
|
9
|
+
* Local single-user model servers (one big-context model on a personal
|
|
10
|
+
* machine) have a standing failure mode: TWO concurrent large-context
|
|
11
|
+
* requests stall or OOM the server — and take the whole machine with it.
|
|
12
|
+
* One Hicortex server is several LLM callers at once: the daemon distills
|
|
13
|
+
* every inbound /distill concurrently, and the nightly's consolidation is a
|
|
14
|
+
* separate OS process on its own timers. This guard makes "never two
|
|
15
|
+
* in-flight requests to the same endpoint" STRUCTURAL instead of a hope
|
|
16
|
+
* that timers do not overlap.
|
|
17
|
+
*
|
|
18
|
+
* Same idiom as capture.ts's capture.lock (A5): O_EXCL create, dead-pid or
|
|
19
|
+
* TTL staleness with a TOCTOU re-race, and FAIL-OPEN — a filesystem refusal
|
|
20
|
+
* logs a loud warning and lets the call proceed unserialized. The guard must
|
|
21
|
+
* never be the reason recall or distillation stops on a healthy endpoint.
|
|
22
|
+
*
|
|
23
|
+
* The lock file lives in the hicortex home, one file per endpoint
|
|
24
|
+
* (sha1 of `provider@baseUrl`), so an install with multiple endpoints
|
|
25
|
+
* serializes within each endpoint, not across them.
|
|
26
|
+
*/
|
|
27
|
+
const node_crypto_1 = require("node:crypto");
|
|
28
|
+
const node_fs_1 = require("node:fs");
|
|
29
|
+
const node_path_1 = require("node:path");
|
|
30
|
+
/**
|
|
31
|
+
* Default staleness floor: a lock older than this is stale REGARDLESS of the
|
|
32
|
+
* recorded pid (guards the recycled-pid case, capture A5 fix 2). The caller
|
|
33
|
+
* passes `staleMs = max(this default, 2× llmTimeoutMs)` so an operator who
|
|
34
|
+
* raises the timeout ceiling can never have a LIVE call's lock reclaimed
|
|
35
|
+
* mid-flight (CR #355 finding 3 — a reclaim-while-running is exactly the
|
|
36
|
+
* two-concurrent-calls crash class this guard exists to prevent).
|
|
37
|
+
*/
|
|
38
|
+
exports.DEFAULT_STALE_MS = 30 * 60 * 1000;
|
|
39
|
+
/**
|
|
40
|
+
* A lock file younger than this whose holder is unreadable (empty/invalid
|
|
41
|
+
* JSON) is treated as LIVE: its creator is between the O_EXCL create and the
|
|
42
|
+
* writeSync — stealing in that window is the same crash class. Only an
|
|
43
|
+
* unreadable file OLDER than the grace period is junk to reclaim
|
|
44
|
+
* (CR #355 finding 4).
|
|
45
|
+
*/
|
|
46
|
+
const GRACE_MS = 2_000;
|
|
47
|
+
/** LLM-scale polling: waits are seconds-to-minutes, not capture-scale. */
|
|
48
|
+
const POLL_MS = 100;
|
|
49
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
/** Warn at most once per process that the guard failed open (#355 fail-open). */
|
|
51
|
+
let warnedOpen = false;
|
|
52
|
+
/** Lock path for an endpoint key — exported for tests and diagnostics. */
|
|
53
|
+
function llmFlightLockPath(homeDir, endpointKey) {
|
|
54
|
+
const hash = (0, node_crypto_1.createHash)("sha1").update(endpointKey).digest("hex").slice(0, 16);
|
|
55
|
+
return (0, node_path_1.join)(homeDir, `llm-flight-${hash}.lock`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Acquire the single-flight lock for `endpointKey`, waiting up to `waitMs`.
|
|
59
|
+
* Never throws: every filesystem surprise degrades to `{ kind: "open" }`.
|
|
60
|
+
*/
|
|
61
|
+
async function acquireLlmFlight(homeDir, endpointKey, waitMs, staleMs = exports.DEFAULT_STALE_MS) {
|
|
62
|
+
const lockPath = llmFlightLockPath(homeDir, endpointKey);
|
|
63
|
+
try {
|
|
64
|
+
(0, node_fs_1.mkdirSync)(homeDir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* best effort — the create below surfaces real problems */
|
|
68
|
+
}
|
|
69
|
+
const deadline = Date.now() + waitMs;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const attempt = tryAcquireOnce(lockPath, endpointKey, staleMs);
|
|
72
|
+
if (attempt.kind !== "busy")
|
|
73
|
+
return attempt;
|
|
74
|
+
if (Date.now() >= deadline)
|
|
75
|
+
return { kind: "timeout" };
|
|
76
|
+
await sleep(Math.max(1, Math.min(POLL_MS, deadline - Date.now())));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** One acquire attempt: create-if-free, else reclaim-if-stale. */
|
|
80
|
+
function tryAcquireOnce(lockPath, endpointKey, staleMs) {
|
|
81
|
+
const release = () => {
|
|
82
|
+
try {
|
|
83
|
+
(0, node_fs_1.unlinkSync)(lockPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* already gone */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const create = () => {
|
|
90
|
+
try {
|
|
91
|
+
const fd = (0, node_fs_1.openSync)(lockPath, "wx"); // O_CREAT | O_EXCL
|
|
92
|
+
// The lease records how long THIS holder may legitimately run
|
|
93
|
+
// (now + staleMs) — a waiter with a smaller budget judges staleness
|
|
94
|
+
// by the recorded lease, never by its own parameter (2nd-review
|
|
95
|
+
// finding 3: a short-timeout waiter must not TTL-reclaim a live
|
|
96
|
+
// long-timeout holder's lock).
|
|
97
|
+
(0, node_fs_1.writeSync)(fd, JSON.stringify({
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
endpoint: endpointKey,
|
|
100
|
+
leaseUntil: Date.now() + staleMs,
|
|
101
|
+
}));
|
|
102
|
+
(0, node_fs_1.closeSync)(fd);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
if (err.code === "EEXIST")
|
|
107
|
+
return false;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
if (create())
|
|
113
|
+
return { kind: "acquired", release };
|
|
114
|
+
const holder = readHolder(lockPath);
|
|
115
|
+
if (!isStale(lockPath, holder, staleMs))
|
|
116
|
+
return { kind: "busy" };
|
|
117
|
+
// Stale. Re-verify the holder has not changed (another reclaimer may
|
|
118
|
+
// have taken it), unlink, re-race the O_EXCL create (capture fix 12).
|
|
119
|
+
// Compare by VALUE (pid/endpoint) — readHolder returns a fresh object
|
|
120
|
+
// per call, so reference equality would always differ.
|
|
121
|
+
const recheck = readHolder(lockPath);
|
|
122
|
+
if (recheck?.pid !== holder?.pid ||
|
|
123
|
+
recheck?.endpoint !== holder?.endpoint) {
|
|
124
|
+
return { kind: "busy" };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
(0, node_fs_1.unlinkSync)(lockPath);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* raced with another reclaimer */
|
|
131
|
+
}
|
|
132
|
+
return create() ? { kind: "acquired", release } : { kind: "busy" };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Filesystem refused the lock op entirely — do not wedge LLM traffic on
|
|
136
|
+
// the guard; proceed unserialized (the behaviour before #355).
|
|
137
|
+
if (!warnedOpen) {
|
|
138
|
+
warnedOpen = true;
|
|
139
|
+
console.warn(`[hicortex] LLM single-flight lock unavailable (${lockPath}) — ` +
|
|
140
|
+
`proceeding WITHOUT serialization. This is safe for recall but ` +
|
|
141
|
+
`concurrent LLM calls can stall a single-user local model server.`);
|
|
142
|
+
}
|
|
143
|
+
return { kind: "open" };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function readHolder(lockPath) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(lockPath, "utf-8"));
|
|
149
|
+
if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid))
|
|
150
|
+
return parsed;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Stale = dead pid, OR past the HOLDER's recorded lease, OR (lease-less
|
|
158
|
+
* lock files) older than the waiter's `staleMs`. An unreadable holder
|
|
159
|
+
* (mid-write or corrupt) is live within the grace period, junk after it. */
|
|
160
|
+
function isStale(lockPath, holder, staleMs) {
|
|
161
|
+
if (!holder) {
|
|
162
|
+
try {
|
|
163
|
+
return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > GRACE_MS;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!isProcessAlive(holder.pid))
|
|
170
|
+
return true;
|
|
171
|
+
if (typeof holder.leaseUntil === "number" && Number.isFinite(holder.leaseUntil)) {
|
|
172
|
+
return Date.now() > holder.leaseUntil;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > staleMs;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isProcessAlive(pid) {
|
|
182
|
+
try {
|
|
183
|
+
process.kill(pid, 0);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// ESRCH = no such process; EPERM = exists but not ours (still alive).
|
|
188
|
+
return err.code === "EPERM";
|
|
189
|
+
}
|
|
190
|
+
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -48,6 +48,15 @@ export interface LlmConfig {
|
|
|
48
48
|
/** TTL the daemon caches a probe outcome for (#337). Default 300000.
|
|
49
49
|
* See HicortexConfig.llmProbeTtlMs. */
|
|
50
50
|
probeTtlMs?: number;
|
|
51
|
+
/** Single-flight serialization: at most ONE in-flight LLM request per
|
|
52
|
+
* endpoint, ever (#355). Default true — a correctness property for local
|
|
53
|
+
* single-user model servers (two concurrent large-context calls stall/OOM
|
|
54
|
+
* the server and the machine under it). See HicortexConfig.llmSingleFlight. */
|
|
55
|
+
singleFlight?: boolean;
|
|
56
|
+
/** How long a queued call waits for the in-flight call before failing as
|
|
57
|
+
* endpoint-down (#355). Default 900000 — the same ceiling as llmTimeoutMs.
|
|
58
|
+
* See HicortexConfig.llmSingleFlightWaitMs. */
|
|
59
|
+
singleFlightWaitMs?: number;
|
|
51
60
|
}
|
|
52
61
|
/**
|
|
53
62
|
* Resolve LLM configuration from explicit config-file overrides or
|
|
@@ -155,6 +164,14 @@ export interface LlmResult {
|
|
|
155
164
|
export declare class LlmCircuitOpenError extends Error {
|
|
156
165
|
constructor(endpoint: string, cooldownRemainingMs: number);
|
|
157
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The TOTAL-failure class the retry ladder matches (#337, unchanged strings —
|
|
169
|
+
* this is the same matcher the ladder has always used, now also the breaker's
|
|
170
|
+
* definition of "endpoint may be down"). A fast HTTP 500, a parse error, or a
|
|
171
|
+
* rate limit is NOT in this class: those prove the endpoint ANSWERS.
|
|
172
|
+
*/
|
|
173
|
+
declare function isTotalFailure(message: string): boolean;
|
|
174
|
+
export { isTotalFailure };
|
|
158
175
|
export declare class LlmClient {
|
|
159
176
|
private config;
|
|
160
177
|
private ollamaCallCount;
|
|
@@ -213,6 +230,21 @@ export declare class LlmClient {
|
|
|
213
230
|
probe(timeoutMs?: number): Promise<boolean>;
|
|
214
231
|
private complete;
|
|
215
232
|
private completeOnce;
|
|
233
|
+
/** The single-flight wait budget for a call with ceiling `timeoutMs`.
|
|
234
|
+
* An explicit `llmSingleFlightWaitMs` wins; otherwise the default is
|
|
235
|
+
* max(900 s, llmTimeoutMs) so a waiter never gives up before a legitimate
|
|
236
|
+
* in-flight call's own (possibly raised) ceiling expires (2nd-review
|
|
237
|
+
* finding 2 — a hardcoded 900 s made a raised-timeout install treat a
|
|
238
|
+
* healthy-busy endpoint as down). */
|
|
239
|
+
private flightWaitMs;
|
|
240
|
+
/** Resolve the flight guard for this call, or undefined when disabled.
|
|
241
|
+
* `staleMs` is derived from THIS call's timeout ceiling (≥ the 30-min
|
|
242
|
+
* floor, 2× timeout) so a raised `llmTimeoutMs` can never get a live
|
|
243
|
+
* call's lock reclaimed mid-flight (CR #355 finding 3). The lock file
|
|
244
|
+
* records its own lease, so reclaim is judged by the HOLDER's lease, not
|
|
245
|
+
* this waiter's parameter (2nd-review finding 3). */
|
|
246
|
+
private acquireFlightGuard;
|
|
247
|
+
private dispatchOnce;
|
|
216
248
|
/**
|
|
217
249
|
* Claude CLI: shell out to `claude -p` for subscription users.
|
|
218
250
|
* No API key needed — uses CC's authenticated session.
|