@gamaze/hicortex 0.19.6 → 0.20.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 +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 +37 -0
- 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 +3 -1
- package/dist/init.js +93 -1
- 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 +87 -3
- 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/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/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Your agents learn from every session — successes and mistakes. Hicortex captures experiences, distills lessons, and applies them automatically. Connect multiple agents to shared memory and they improve together, overnight.
|
|
6
6
|
|
|
7
|
-
Works with **Hermes**, **OpenClaw**, **Claude Code**, **Pi**, and any MCP-compatible agent.
|
|
7
|
+
Works with **Hermes**, **OpenClaw**, **Claude Code**, **Pi**, **opencode**, and any MCP-compatible agent.
|
|
8
8
|
|
|
9
9
|
**Website:** [hicortex.gamaze.com](https://hicortex.gamaze.com) · **Docs:** [hicortex.gamaze.com/docs](https://hicortex.gamaze.com/docs/)
|
|
10
10
|
|
|
@@ -87,7 +87,7 @@ Bare top-level keys (`"serverUrl": …` at the root of `openclaw.json`) still wo
|
|
|
87
87
|
| When | What | How |
|
|
88
88
|
|------|------|-----|
|
|
89
89
|
| Agent start | Standing identity (`## Identity`) + recent lessons fetched fresh and injected | CC SessionStart hook (calls `hicortex learnings-identity`; `lessons-context` kept as an alias so existing installed hooks don't break) / Hermes plugin `system_prompt_block` / OC `before_agent_start` hook |
|
|
90
|
-
| Every prompt (0.14) | A compact **recall index** of relevant memories is injected — one line per memory; the agent lazy-loads full content with `hicortex_get` only when needed | All
|
|
90
|
+
| Every prompt (0.14) | A compact **recall index** of relevant memories is injected — one line per memory; the agent lazy-loads full content with `hicortex_get` only when needed | All five harnesses call server `POST /recall-index` per turn: CC UserPromptSubmit hook (`hicortex recall-hook`), Hermes plugin `prefetch` (0.7.0; falls back to `/search` injection against a pre-0.14 server), OC `before_agent_start` hook (fires per inbound message), Pi extension `before_agent_start` (0.20), opencode plugin messages-transform hook (0.21). Turn-based dedup per session; resets on new session/compaction. Fail-soft |
|
|
91
91
|
| Nightly | Denoise sessions → POST /distill → server distills + embeds + stores → consolidate (score, reflect, link, decay) | Automatic pipeline — no manual steps |
|
|
92
92
|
|
|
93
93
|
**Exposure vs use (0.14):** appearing in the recall index only marks a memory as *shown* (it stops decaying while topically active); fetching it with `hicortex_get` marks it as *used* (durable strengthening). Memory importance is driven by what agents actually use, not by what was pushed at them.
|
|
@@ -158,7 +158,7 @@ Beyond auto-distilled memories and lessons, Hicortex holds a **hand-edited ident
|
|
|
158
158
|
|
|
159
159
|
- **Storage:** plain files on the server at `~/.hicortex/identity/*.md` — one file per section (recommended starter sections `user.md` + `rules.md`, which you create — nothing is pre-populated; add more by dropping in a file). It lives outside the memories table; consolidation never touches it.
|
|
160
160
|
- **Edit:** the web editor at `http://localhost:8787/identity/ui` (one tab per section, Save), or the CLI `hicortex identity show [name]` / `hicortex identity edit <name>`.
|
|
161
|
-
- **Delivery:** injected into the harnesses listed in `identityClients` (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc` —
|
|
161
|
+
- **Delivery:** injected into the harnesses listed in `identityClients` (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc`/`pi`/`opencode` — CC/Hermes/OpenClaw since 0.13, Pi since 0.20, opencode since 0.21).
|
|
162
162
|
- **Deletion** is filesystem-only — remove the file on the server (as the daemon user).
|
|
163
163
|
|
|
164
164
|
### Per-agent identity (0.13)
|
|
@@ -183,6 +183,7 @@ npx @gamaze/hicortex init # Set up server mode
|
|
|
183
183
|
npx @gamaze/hicortex init --server <url> # Set up client mode
|
|
184
184
|
npx @gamaze/hicortex nightly # Run distill + consolidate (full nightly)
|
|
185
185
|
npx @gamaze/hicortex nightly --capture-only # Capture only, skip consolidation (safe for sub-daily runs)
|
|
186
|
+
npx @gamaze/hicortex nightly --evict-only # Memory-cap eviction only — pure DB, no capture, no LLM (idempotent; honors --dry-run)
|
|
186
187
|
npx @gamaze/hicortex nightly --dry-run # Preview without changes
|
|
187
188
|
npx @gamaze/hicortex classify-domains # Backfill domain tags over the corpus (see Memory Domains & Tags)
|
|
188
189
|
npx @gamaze/hicortex classify-types # Reclassify memory types (episode/fact/decision)
|
|
@@ -239,16 +240,19 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
|
|
|
239
240
|
| `llmBreakerCooldownMs` | How long the breaker stays open before one half-open trial call goes out (default 600000 = 10 min). A failing trial re-opens it; a succeeding one resets the counter. |
|
|
240
241
|
| `llmProbeTimeoutMs` | Patience of the readiness probe — one minimal 1-token generation request the nightly sends before consolidating and the daemon sends before distilling (default 60000 = 1 min). Catches a gateway that answers health/model-list queries while generation is dead; a failed probe skips consolidation (`endpoint_down`, retried next run) and answers `/distill` with a 503 so capture holds its cursor. |
|
|
241
242
|
| `llmProbeTtlMs` | How long the daemon caches a `/distill` probe outcome (default 300000 = 5 min). A healthy capture cadence pays at most one probe per window; a dead endpoint turns into fast cached 503s instead of every request paying the probe timeout. |
|
|
243
|
+
| `llmSingleFlight` | **Serialized LLM calls — default `true`.** At most ONE request in flight per endpoint at any moment, across every process (the daemon distilling concurrent captures, the nightly consolidating, CLI backfills take turns via a per-endpoint lock file). One Hicortex server is several callers at once, and local single-user model servers (a Mac mini or laptop serving one big-context model) can stall or crash — taking the machine with them — under two concurrent large requests. Queued calls wait their turn; batches run back-to-back. **Set `false` if your endpoint is a beefy multi-tenant service that parallelizes well and you want faster consolidation** — you opt into responsibility for the endpoint's concurrency safety. |
|
|
244
|
+
| `llmSingleFlightWaitMs` | How long a queued call waits for the in-flight call before failing as endpoint-down and retrying per the normal ladder/breaker rules (default: `max(900000, llmTimeoutMs)` — a waiter never gives up before a legitimate in-flight call's own, possibly raised, ceiling expires). Only meaningful with serialization on. Note under contention: the readiness probe queues too, but its wait is capped by its own probe budget — during a long consolidation batch, `/distill` answers 503 quickly (captures cursor-hold and retry, lossless) rather than holding the socket for the full queue budget. |
|
|
242
245
|
| `authToken` | Bearer token for endpoint auth. Generated on first `init` in server mode. Find the active token with `hicortex status` or in `~/.hicortex/config.json`. |
|
|
243
246
|
| `corsAllowedOrigins` | Browser origins allowed to read cross-origin responses, e.g. `["https://ui.example.com"]`. **Empty by default** — the server sends no `Access-Control-Allow-Origin` and never `Allow-Credentials`, so no external web page can read its data. The bundled `/viz` and `/identity/ui` pages are same-origin and need no entry. |
|
|
244
247
|
| `licenseKey` | Commercial license key (optional; for display in `hicortex status`) |
|
|
245
248
|
| `backupRetention` | How many of the newest backup artifacts (`hicortex-*.tar.gz`) the backup dir keeps after each successful write (default: 7; `0` keeps all). See [Backups](#backups) |
|
|
246
249
|
| _env_ `HICORTEX_DISTILL_BODY_LIMIT_MB` | Environment override for the `/distill` body limit — **wins over the `distillBodyLimitMb` config key in every mode** (that is the point: a deployment operator pins it so tenant-writable config cannot raise it). Unset = config/default applies. |
|
|
250
|
+
| _env_ `HICORTEX_MEMORY_CAP` | Environment override for the memory soft cap — a **positive** value wins over the `memorySoftCap` config key in every mode; `0`/negative/malformed fall through to the config key, then the 10000 default (an env can pin a cap, never disable one — config `memorySoftCap: 0` still disables when the env is unset). Mode-agnostic operator knob: nightly eviction, the dashboard-snapshot capacity stamp, and the live dashboard gauge all resolve through the same resolver, so the enforced and displayed caps can never disagree. Drives `nightly --evict-only` the same way. |
|
|
247
251
|
| `domains` | Your memory domain list (`[{name, description}]`). Scaffolded by `init`; edit freely — see [Memory Domains & Tags](#memory-domains--tags) |
|
|
248
252
|
| `weakPrimaryFloor` | Minimum similarity for a no-fit memory to keep a weak domain association (default: 0.45) |
|
|
249
253
|
| `moduleIndexTokenBudget` | Max tokens for domain index in lessons context (default: 500) |
|
|
250
254
|
| `lessonsLimit` | Max lessons injected into an agent's session-start context (default: 10). Lessons are ranked per session by project/domain affinity + recency + strength + access, so each session sees its most-relevant slice. Lower = leaner system prompts. |
|
|
251
|
-
| `identityClients` | Which harnesses inject the [identity layer](#identity-layer) at session start (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc`) |
|
|
255
|
+
| `identityClients` | Which harnesses inject the [identity layer](#identity-layer) at session start (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc`/`pi`/`opencode`) |
|
|
252
256
|
| `identityAgents` | Per-agent identity modes (0.13): `{ "<id>": "override" \| "global" \| "off" }`. Absent + no `agents/<id>/` dir → every agent gets the global set. Boot-time (restart to apply) — see [Per-agent identity](#per-agent-identity-013) |
|
|
253
257
|
| `agentName` | This install's per-agent identity id sent as `?agent=`. **Unset by default** (CC shares the global identity — no `?agent=` sent). Explicit opt-in via `init --agent-name <name>`; `init --agent-name ""` clears it. An empty/whitespace value equals unset |
|
|
254
258
|
| `captureCooldownHours` | Success-cooldown (hours) for the **capture watchdog** (0.17). The capture timer polls every ~20 min; the watchdog captures only if more than this has elapsed since the last *successful* capture (`state.lastNightly`). Default `6` (≈4 captures/day). A failed preflight retries on the next poll (~20 min) — so a transient fire-instant network miss costs minutes, not a day (#239) |
|
package/dist/cli.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* nightly Run capture + consolidate (manual trigger)
|
|
9
9
|
* nightly --capture-only Capture only, skip consolidation
|
|
10
10
|
* nightly --consolidate-only Consolidate only, skip capture (hosted service)
|
|
11
|
+
* nightly --evict-only Memory-cap eviction only — pure DB, no LLM (#317)
|
|
11
12
|
* nightly --status Show nightly pipeline health check
|
|
12
13
|
* relink Resumable link-discovery pass over the entire corpus (issue #143)
|
|
13
14
|
* dedup Cluster + merge near-duplicate memories (issue #100)
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* nightly Run capture + consolidate (manual trigger)
|
|
10
10
|
* nightly --capture-only Capture only, skip consolidation
|
|
11
11
|
* nightly --consolidate-only Consolidate only, skip capture (hosted service)
|
|
12
|
+
* nightly --evict-only Memory-cap eviction only — pure DB, no LLM (#317)
|
|
12
13
|
* nightly --status Show nightly pipeline health check
|
|
13
14
|
* relink Resumable link-discovery pass over the entire corpus (issue #143)
|
|
14
15
|
* dedup Cluster + merge near-duplicate memories (issue #100)
|
|
@@ -71,11 +72,16 @@ switch (command) {
|
|
|
71
72
|
const dryRun = args.includes("--dry-run");
|
|
72
73
|
const captureOnly = args.includes("--capture-only");
|
|
73
74
|
const consolidateOnly = args.includes("--consolidate-only");
|
|
75
|
+
const evictOnly = args.includes("--evict-only");
|
|
74
76
|
const watchdog = args.includes("--watchdog");
|
|
75
77
|
if (captureOnly && consolidateOnly) {
|
|
76
78
|
console.error("[hicortex] nightly: --capture-only and --consolidate-only are mutually exclusive");
|
|
77
79
|
process.exit(1);
|
|
78
80
|
}
|
|
81
|
+
if (evictOnly && (captureOnly || consolidateOnly)) {
|
|
82
|
+
console.error("[hicortex] nightly: --evict-only is mutually exclusive with --capture-only and --consolidate-only");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
79
85
|
// Timestamp every log line. The nightly writes to a file (launchd /
|
|
80
86
|
// systemd StandardOutput append) with NO per-line timestamp, which made
|
|
81
87
|
// diagnosing capture gaps impossible (the #239 investigation couldn't
|
|
@@ -98,7 +104,7 @@ switch (command) {
|
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
import("./nightly.js").then(({ runNightly }) => {
|
|
101
|
-
runNightly({ dryRun, captureOnly, consolidateOnly, watchdog, recaptureWindowDays }).catch((err) => {
|
|
107
|
+
runNightly({ dryRun, captureOnly, consolidateOnly, evictOnly, watchdog, recaptureWindowDays }).catch((err) => {
|
|
102
108
|
console.error("[hicortex] Nightly pipeline failed:", err);
|
|
103
109
|
process.exit(1);
|
|
104
110
|
});
|
|
@@ -355,6 +361,7 @@ Options:
|
|
|
355
361
|
nightly --dry-run Preview without changes
|
|
356
362
|
nightly --capture-only Capture only, skip consolidation (safe to run multiple times/day)
|
|
357
363
|
nightly --consolidate-only Consolidate only, skip capture (hosted-service per-tenant runs)
|
|
364
|
+
nightly --evict-only Memory-cap eviction only — pure DB, no capture, no LLM (#317)
|
|
358
365
|
nightly --recapture-window <days> Re-discover sessions quiet since <days> ago (one-shot #189 recovery)
|
|
359
366
|
nightly --status Show nightly pipeline health
|
|
360
367
|
relink --dry-run Discovery + counts only, zero writes, cursor untouched
|
package/dist/consolidate.d.ts
CHANGED
|
@@ -285,6 +285,27 @@ export declare function stageDecayPrune(db: Database.Database, dryRun: boolean):
|
|
|
285
285
|
* (recall top-k competes against the long tail). Override via `memorySoftCap`.
|
|
286
286
|
*/
|
|
287
287
|
export declare const DEFAULT_MEMORY_SOFT_CAP = 10000;
|
|
288
|
+
/**
|
|
289
|
+
* Resolve the effective soft cap (#317): a positive finite HICORTEX_MEMORY_CAP
|
|
290
|
+
* env wins over the `memorySoftCap` config key, which wins over
|
|
291
|
+
* DEFAULT_MEMORY_SOFT_CAP. The env is the PRICING boundary — in the hosted
|
|
292
|
+
* stack the cap is a per-plan parameter the provider pins into the tenant .env
|
|
293
|
+
* (docker exec inherits container env, so the eviction path sees it), and the
|
|
294
|
+
* tenant's config.json is bind-mounted writable at /data, so config-only
|
|
295
|
+
* resolution would let a tenant raise or disable its own cap. Same
|
|
296
|
+
* env-wins-precedence pattern as resolveTokenCap (token-budget.ts) and
|
|
297
|
+
* resolveBodyLimitMb (mcp-server.ts).
|
|
298
|
+
*
|
|
299
|
+
* Deliberate asymmetry with resolveTokenCap: a MALFORMED/0/negative env falls
|
|
300
|
+
* through (an env can pin a cap but never disable one), while config 0 is
|
|
301
|
+
* still honored as the #245 opt-out (indefinite growth) when the env is unset
|
|
302
|
+
* — env unset → config → default is exactly the pre-#317 behavior, so a
|
|
303
|
+
* self-hosted install that never hears about the env sees no change. Pure —
|
|
304
|
+
* exported for tests; every consumer (nightly eviction + snapshot stamp,
|
|
305
|
+
* dashboard live headline) must route through this ONE function so the
|
|
306
|
+
* enforced cap and the displayed cap cannot disagree.
|
|
307
|
+
*/
|
|
308
|
+
export declare function resolveMemorySoftCap(configVal: unknown): number;
|
|
288
309
|
export declare function stageMemoryCapEviction(db: Database.Database, dryRun: boolean, cap: number): {
|
|
289
310
|
cap: number;
|
|
290
311
|
evicted: number;
|
package/dist/consolidate.js
CHANGED
|
@@ -50,12 +50,14 @@ exports.buildSupersessionPrompt = buildSupersessionPrompt;
|
|
|
50
50
|
exports.parseSupersessionReply = parseSupersessionReply;
|
|
51
51
|
exports.stageSupersession = stageSupersession;
|
|
52
52
|
exports.stageDecayPrune = stageDecayPrune;
|
|
53
|
+
exports.resolveMemorySoftCap = resolveMemorySoftCap;
|
|
53
54
|
exports.stageMemoryCapEviction = stageMemoryCapEviction;
|
|
54
55
|
exports.runConsolidation = runConsolidation;
|
|
55
56
|
exports.msUntilHour = msUntilHour;
|
|
56
57
|
exports.scheduleConsolidation = scheduleConsolidation;
|
|
57
58
|
const retrieval_js_1 = require("./retrieval.js");
|
|
58
59
|
const storage = __importStar(require("./storage.js"));
|
|
60
|
+
const config_read_js_1 = require("./config-read.js");
|
|
59
61
|
const prompts_js_1 = require("./prompts.js");
|
|
60
62
|
const node_crypto_1 = require("node:crypto");
|
|
61
63
|
const features_js_1 = require("./features.js");
|
|
@@ -1261,6 +1263,41 @@ function stageDecayPrune(db, dryRun) {
|
|
|
1261
1263
|
* (recall top-k competes against the long tail). Override via `memorySoftCap`.
|
|
1262
1264
|
*/
|
|
1263
1265
|
exports.DEFAULT_MEMORY_SOFT_CAP = 10000;
|
|
1266
|
+
/** Env override for the soft cap (#317). Hosted: provider-set via the tenant
|
|
1267
|
+
* .env, tenant-immutable at runtime (same posture as HICORTEX_TOKEN_CAP).
|
|
1268
|
+
* Self-hosted: an operator knob — mode-agnostic, never branches on
|
|
1269
|
+
* hostedMode. */
|
|
1270
|
+
const MEMORY_CAP_ENV = "HICORTEX_MEMORY_CAP";
|
|
1271
|
+
/**
|
|
1272
|
+
* Resolve the effective soft cap (#317): a positive finite HICORTEX_MEMORY_CAP
|
|
1273
|
+
* env wins over the `memorySoftCap` config key, which wins over
|
|
1274
|
+
* DEFAULT_MEMORY_SOFT_CAP. The env is the PRICING boundary — in the hosted
|
|
1275
|
+
* stack the cap is a per-plan parameter the provider pins into the tenant .env
|
|
1276
|
+
* (docker exec inherits container env, so the eviction path sees it), and the
|
|
1277
|
+
* tenant's config.json is bind-mounted writable at /data, so config-only
|
|
1278
|
+
* resolution would let a tenant raise or disable its own cap. Same
|
|
1279
|
+
* env-wins-precedence pattern as resolveTokenCap (token-budget.ts) and
|
|
1280
|
+
* resolveBodyLimitMb (mcp-server.ts).
|
|
1281
|
+
*
|
|
1282
|
+
* Deliberate asymmetry with resolveTokenCap: a MALFORMED/0/negative env falls
|
|
1283
|
+
* through (an env can pin a cap but never disable one), while config 0 is
|
|
1284
|
+
* still honored as the #245 opt-out (indefinite growth) when the env is unset
|
|
1285
|
+
* — env unset → config → default is exactly the pre-#317 behavior, so a
|
|
1286
|
+
* self-hosted install that never hears about the env sees no change. Pure —
|
|
1287
|
+
* exported for tests; every consumer (nightly eviction + snapshot stamp,
|
|
1288
|
+
* dashboard live headline) must route through this ONE function so the
|
|
1289
|
+
* enforced cap and the displayed cap cannot disagree.
|
|
1290
|
+
*/
|
|
1291
|
+
function resolveMemorySoftCap(configVal) {
|
|
1292
|
+
const envCap = Number(process.env[MEMORY_CAP_ENV]);
|
|
1293
|
+
if (Number.isFinite(envCap) && envCap > 0)
|
|
1294
|
+
return envCap;
|
|
1295
|
+
// Reuse the disk→runtime boundary validator (warn-on-rejected-value) so the
|
|
1296
|
+
// config half behaves byte-for-byte like the pre-#317 readNonNegativeConfig
|
|
1297
|
+
// call sites: absent → default, valid non-negative (incl. 0 = disabled)
|
|
1298
|
+
// passes through, invalid → warn + default.
|
|
1299
|
+
return (0, config_read_js_1.readNonNegativeConfig)({ memorySoftCap: configVal }, "memorySoftCap", exports.DEFAULT_MEMORY_SOFT_CAP);
|
|
1300
|
+
}
|
|
1264
1301
|
function stageMemoryCapEviction(db, dryRun, cap) {
|
|
1265
1302
|
// `0` = explicitly disabled (current/legacy behaviour). The guard is on `<=`
|
|
1266
1303
|
// not `===` to also absorb a stray negative (readNonNegativeConfig already
|
package/dist/dashboard.js
CHANGED
|
@@ -361,7 +361,10 @@ function handleDashboardData(db, query, config) {
|
|
|
361
361
|
// Headline = live corpus (the chart shows history; the headline shows now).
|
|
362
362
|
// `memory_soft_cap` (#245): resolve from the live config (the source of
|
|
363
363
|
// truth for "what cap is in force right now"), defaulting to the production
|
|
364
|
-
// default. The page renders no gauge when it's 0 (disabled).
|
|
364
|
+
// default. The page renders no gauge when it's 0 (disabled). #317: routes
|
|
365
|
+
// through resolveMemorySoftCap so a HICORTEX_MEMORY_CAP env pin is honored
|
|
366
|
+
// here too — the DISPLAYED cap can never disagree with the ENFORCED cap
|
|
367
|
+
// (both consumers share the one resolver).
|
|
365
368
|
//
|
|
366
369
|
// `tokens` (#246): period accrual from state.json (the same single source
|
|
367
370
|
// the throttle check reads, so the dashboard always agrees with the runtime
|
|
@@ -372,7 +375,7 @@ function handleDashboardData(db, query, config) {
|
|
|
372
375
|
total_memories: live.totals.mem,
|
|
373
376
|
uses_per_showing: live.adoption?.uses_per_showing ?? null,
|
|
374
377
|
cold_count: live.adoption?.cold_count ?? 0,
|
|
375
|
-
memory_soft_cap: (0,
|
|
378
|
+
memory_soft_cap: (0, consolidate_js_1.resolveMemorySoftCap)(config?.memorySoftCap),
|
|
376
379
|
tokens: {
|
|
377
380
|
used: tokenState?.total ?? 0,
|
|
378
381
|
cap: (0, config_read_js_1.readNonNegativeConfig)(config ?? {}, "llmTokensPerMonth", 0),
|
package/dist/identity-store.d.ts
CHANGED
|
@@ -227,9 +227,9 @@ export declare const IDENTITY_SIZE_WARN_BYTES = 16384;
|
|
|
227
227
|
/** Backcompat alias for the pre-rename name (#264). */
|
|
228
228
|
export declare const CONTEXT_SIZE_WARN_BYTES = 16384;
|
|
229
229
|
/** Harness names that may inject the identity layer. */
|
|
230
|
-
export declare const KNOWN_IDENTITY_CLIENTS: readonly ["cc", "hermes", "oc"];
|
|
230
|
+
export declare const KNOWN_IDENTITY_CLIENTS: readonly ["cc", "hermes", "oc", "pi", "opencode"];
|
|
231
231
|
/** Backcompat alias for the pre-rename name (#264). */
|
|
232
|
-
export declare const KNOWN_CONTEXT_CLIENTS: readonly ["cc", "hermes", "oc"];
|
|
232
|
+
export declare const KNOWN_CONTEXT_CLIENTS: readonly ["cc", "hermes", "oc", "pi", "opencode"];
|
|
233
233
|
export interface ResolvedIdentityClients {
|
|
234
234
|
/** The resolved, de-duped list of known client names. */
|
|
235
235
|
clients: string[];
|
|
@@ -240,7 +240,7 @@ export interface ResolvedIdentityClients {
|
|
|
240
240
|
export type ResolvedContextClients = ResolvedIdentityClients;
|
|
241
241
|
/**
|
|
242
242
|
* Normalize the raw `identityClients` config value (spec §2):
|
|
243
|
-
* - `"all"` (any case) → ["cc","hermes","oc"]
|
|
243
|
+
* - `"all"` (any case) → ["cc","hermes","oc","pi","opencode"]
|
|
244
244
|
* - array → lowercase, keep known names (de-duped), collect dropped unknowns
|
|
245
245
|
* - missing / non-array-non-"all" → default ["cc"]
|
|
246
246
|
* The resolved list is echoed by GET /identity as `clients` so each harness's
|
package/dist/identity-store.js
CHANGED
|
@@ -467,12 +467,12 @@ exports.CONTEXT_SIZE_WARN_BYTES = exports.IDENTITY_SIZE_WARN_BYTES;
|
|
|
467
467
|
// Config — identityClients normalization
|
|
468
468
|
// ---------------------------------------------------------------------------
|
|
469
469
|
/** Harness names that may inject the identity layer. */
|
|
470
|
-
exports.KNOWN_IDENTITY_CLIENTS = ["cc", "hermes", "oc"];
|
|
470
|
+
exports.KNOWN_IDENTITY_CLIENTS = ["cc", "hermes", "oc", "pi", "opencode"];
|
|
471
471
|
/** Backcompat alias for the pre-rename name (#264). */
|
|
472
472
|
exports.KNOWN_CONTEXT_CLIENTS = exports.KNOWN_IDENTITY_CLIENTS;
|
|
473
473
|
/**
|
|
474
474
|
* Normalize the raw `identityClients` config value (spec §2):
|
|
475
|
-
* - `"all"` (any case) → ["cc","hermes","oc"]
|
|
475
|
+
* - `"all"` (any case) → ["cc","hermes","oc","pi","opencode"]
|
|
476
476
|
* - array → lowercase, keep known names (de-duped), collect dropped unknowns
|
|
477
477
|
* - missing / non-array-non-"all" → default ["cc"]
|
|
478
478
|
* The resolved list is echoed by GET /identity as `clients` so each harness's
|
package/dist/init.d.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* 2. Remote HC server (HICORTEX_SERVER_URL — any reachable host:port)
|
|
7
7
|
* 3. OC plugin installed (~/.openclaw/openclaw.json)
|
|
8
8
|
* 4. CC MCP already registered (~/.claude/settings.json)
|
|
9
|
-
* 5.
|
|
9
|
+
* 5. Hermes present (~/.hermes) / Pi present (~/.pi/agent) /
|
|
10
|
+
* opencode present (~/.config/opencode or ~/.local/share/opencode)
|
|
11
|
+
* 6. Existing DB (~/.hicortex/ or ~/.openclaw/data/)
|
|
10
12
|
*
|
|
11
13
|
* Actions:
|
|
12
14
|
* - Install persistent daemon (launchd/systemd)
|
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)
|
|
@@ -80,6 +82,11 @@ const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "
|
|
|
80
82
|
const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
|
|
81
83
|
const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
|
|
82
84
|
const HERMES_HOME = process.env.HERMES_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), ".hermes");
|
|
85
|
+
/** Pi's agent dir — its presence means Pi is installed and will load extensions. */
|
|
86
|
+
const PI_AGENT_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent");
|
|
87
|
+
/** opencode's config/data dirs — either present means opencode is installed. */
|
|
88
|
+
const OPENCODE_CONFIG_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "opencode");
|
|
89
|
+
const OPENCODE_DATA_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".local", "share", "opencode");
|
|
83
90
|
const DEFAULT_PORT = 8787;
|
|
84
91
|
async function detect() {
|
|
85
92
|
const result = {
|
|
@@ -88,6 +95,8 @@ async function detect() {
|
|
|
88
95
|
ocPlugin: false,
|
|
89
96
|
ccMcpRegistered: false,
|
|
90
97
|
hermesFound: false,
|
|
98
|
+
piFound: false,
|
|
99
|
+
opencodeFound: false,
|
|
91
100
|
existingDb: false,
|
|
92
101
|
};
|
|
93
102
|
// Check local server. /health/detail carries the diagnostics (memories,
|
|
@@ -127,6 +136,11 @@ async function detect() {
|
|
|
127
136
|
}
|
|
128
137
|
// Check Hermes
|
|
129
138
|
result.hermesFound = (0, node_fs_1.existsSync)(HERMES_HOME);
|
|
139
|
+
// Check Pi (~/.pi/agent — the dir Pi loads extensions from)
|
|
140
|
+
result.piFound = (0, node_fs_1.existsSync)(PI_AGENT_DIR);
|
|
141
|
+
// Check opencode (~/.config/opencode or ~/.local/share/opencode — its
|
|
142
|
+
// global plugins dir / session store; it auto-loads ~/.config/opencode/plugins/)
|
|
143
|
+
result.opencodeFound = (0, node_fs_1.existsSync)(OPENCODE_CONFIG_DIR) || (0, node_fs_1.existsSync)(OPENCODE_DATA_DIR);
|
|
130
144
|
// Check OC plugin
|
|
131
145
|
try {
|
|
132
146
|
const raw = (0, node_fs_1.readFileSync)(OC_CONFIG, "utf-8");
|
|
@@ -331,6 +345,58 @@ function cleanupLegacyCcCommands() {
|
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
347
|
// ---------------------------------------------------------------------------
|
|
348
|
+
// Pi setup
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
/**
|
|
351
|
+
* Install the bundled Pi extension (#348): one dependency-free .ts file Pi
|
|
352
|
+
* loads from ~/.pi/agent/extensions/. Deliberately NO config write — the
|
|
353
|
+
* extension self-resolves the server from ~/.hicortex/config.json (the file
|
|
354
|
+
* init has already written by this point), so there is nothing to keep in
|
|
355
|
+
* sync and no secret to route elsewhere. Overwrites on re-init so upgrades
|
|
356
|
+
* land; skips gracefully when the bundled source is absent (e.g. a dev
|
|
357
|
+
* checkout without the packaged copy).
|
|
358
|
+
*/
|
|
359
|
+
function setupPi() {
|
|
360
|
+
const extensionSource = (0, node_path_1.join)(__dirname, "..", "pi-extension", "hicortex", "index.ts");
|
|
361
|
+
if (!(0, node_fs_1.existsSync)(extensionSource)) {
|
|
362
|
+
console.log(" ⚠ Pi extension not found in package — skipping Pi setup");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const extensionsDir = (0, node_path_1.join)(PI_AGENT_DIR, "extensions");
|
|
366
|
+
(0, node_fs_1.mkdirSync)(extensionsDir, { recursive: true });
|
|
367
|
+
const target = (0, node_path_1.join)(extensionsDir, "hicortex.ts");
|
|
368
|
+
(0, node_fs_1.copyFileSync)(extensionSource, target);
|
|
369
|
+
console.log(` ✓ Copied Pi extension to ${target}`);
|
|
370
|
+
console.log(" → Restart Pi sessions to load the extension (recall, identity, lessons, 9 tools)");
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// opencode setup
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
/**
|
|
376
|
+
* Install the bundled opencode plugin (#347): one dependency-free .ts file
|
|
377
|
+
* opencode auto-loads from ~/.config/opencode/plugins/. Deliberately NO
|
|
378
|
+
* write into opencode's own configuration — the plugin self-resolves the
|
|
379
|
+
* server from ~/.hicortex/config.json (the file init has already written by
|
|
380
|
+
* this point), so there is nothing to keep in sync and no secret to route
|
|
381
|
+
* elsewhere. Overwrites on re-init so upgrades land; skips gracefully when
|
|
382
|
+
* the bundled source is absent (e.g. a dev checkout without the packaged
|
|
383
|
+
* copy). The plugins directory may hold third-party files — the copy only
|
|
384
|
+
* ever touches our own hicortex.ts name.
|
|
385
|
+
*/
|
|
386
|
+
function setupOpencode() {
|
|
387
|
+
const pluginSource = (0, node_path_1.join)(__dirname, "..", "opencode-plugin", "hicortex", "index.ts");
|
|
388
|
+
if (!(0, node_fs_1.existsSync)(pluginSource)) {
|
|
389
|
+
console.log(" ⚠ opencode plugin not found in package — skipping opencode setup");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const pluginsDir = (0, node_path_1.join)(OPENCODE_CONFIG_DIR, "plugins");
|
|
393
|
+
(0, node_fs_1.mkdirSync)(pluginsDir, { recursive: true });
|
|
394
|
+
const target = (0, node_path_1.join)(pluginsDir, "hicortex.ts");
|
|
395
|
+
(0, node_fs_1.copyFileSync)(pluginSource, target);
|
|
396
|
+
console.log(` ✓ Copied opencode plugin to ${target}`);
|
|
397
|
+
console.log(" → Restart opencode sessions to load the plugin (recall, identity, lessons, 9 tools)");
|
|
398
|
+
}
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
334
400
|
// Hermes setup
|
|
335
401
|
// ---------------------------------------------------------------------------
|
|
336
402
|
function setupHermes(serverUrl, authToken) {
|
|
@@ -1501,6 +1567,10 @@ async function runInit(options = {}) {
|
|
|
1501
1567
|
console.log(" • OpenClaw plugin installed");
|
|
1502
1568
|
if (d.hermesFound)
|
|
1503
1569
|
console.log(` • Hermes found at ${HERMES_HOME}`);
|
|
1570
|
+
if (d.piFound)
|
|
1571
|
+
console.log(` • Pi found at ${PI_AGENT_DIR}`);
|
|
1572
|
+
if (d.opencodeFound)
|
|
1573
|
+
console.log(` • opencode found at ${OPENCODE_CONFIG_DIR}`);
|
|
1504
1574
|
if (d.ccMcpRegistered)
|
|
1505
1575
|
console.log(" • CC MCP already registered");
|
|
1506
1576
|
if (d.existingDb)
|
|
@@ -1532,6 +1602,10 @@ async function runInit(options = {}) {
|
|
|
1532
1602
|
actions.push("Register MCP server in CC settings");
|
|
1533
1603
|
if (d.hermesFound)
|
|
1534
1604
|
actions.push("Install Hermes plugin + configure");
|
|
1605
|
+
if (d.piFound)
|
|
1606
|
+
actions.push("Install Pi extension");
|
|
1607
|
+
if (d.opencodeFound)
|
|
1608
|
+
actions.push("Install opencode plugin");
|
|
1535
1609
|
actions.push("Install SessionStart hook (query-time lessons)");
|
|
1536
1610
|
if (actions.length === 0) {
|
|
1537
1611
|
console.log("Everything is already configured. Nothing to do.");
|
|
@@ -1649,6 +1723,14 @@ async function runInit(options = {}) {
|
|
|
1649
1723
|
const isLocal = serverUrl.includes("127.0.0.1") || serverUrl.includes("localhost");
|
|
1650
1724
|
setupHermes(serverUrl, isLocal ? "" : authToken);
|
|
1651
1725
|
}
|
|
1726
|
+
// Setup the Pi extension if detected (self-resolving — no config write)
|
|
1727
|
+
if (d.piFound) {
|
|
1728
|
+
setupPi();
|
|
1729
|
+
}
|
|
1730
|
+
// Setup the opencode plugin if detected (self-resolving — no config write)
|
|
1731
|
+
if (d.opencodeFound) {
|
|
1732
|
+
setupOpencode();
|
|
1733
|
+
}
|
|
1652
1734
|
// Install CC SessionStart hook for query-time lesson injection.
|
|
1653
1735
|
// Lessons are now fetched live at session start — no static CLAUDE.md block needed.
|
|
1654
1736
|
installSessionStartHook();
|
|
@@ -1826,6 +1908,16 @@ async function runClientInit(serverUrl, agentName) {
|
|
|
1826
1908
|
console.log("\nHermes detected — installing plugin...");
|
|
1827
1909
|
setupHermes(serverUrl, authToken);
|
|
1828
1910
|
}
|
|
1911
|
+
// Step 8b: Setup the Pi extension if detected (self-resolving — no config write)
|
|
1912
|
+
if ((0, node_fs_1.existsSync)(PI_AGENT_DIR)) {
|
|
1913
|
+
console.log("\nPi detected — installing extension...");
|
|
1914
|
+
setupPi();
|
|
1915
|
+
}
|
|
1916
|
+
// Step 8c: Setup the opencode plugin if detected (self-resolving — no config write)
|
|
1917
|
+
if ((0, node_fs_1.existsSync)(OPENCODE_CONFIG_DIR) || (0, node_fs_1.existsSync)(OPENCODE_DATA_DIR)) {
|
|
1918
|
+
console.log("\nopencode detected — installing plugin...");
|
|
1919
|
+
setupOpencode();
|
|
1920
|
+
}
|
|
1829
1921
|
console.log("\n✓ Hicortex client setup complete!\n");
|
|
1830
1922
|
// Telemetry disclosure at install time (informed consent, best practice):
|
|
1831
1923
|
// opt-out telemetry is only acceptable if the user is TOLD about it.
|
|
@@ -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
|
+
}
|