clawmem 0.8.1 → 0.8.2

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/AGENTS.md CHANGED
@@ -94,9 +94,9 @@ curl http://host:8090/v1/models
94
94
  | `CLAWMEM_NO_LOCAL_MODELS` | `false` | Blocks `node-llama-cpp` from auto-downloading GGUF models. Set `true` for remote-only setups. |
95
95
  | `CLAWMEM_VAULTS` | (none) | JSON map of vault name → SQLite path for multi-vault mode. E.g. `{"work":"~/.cache/clawmem/work.sqlite"}` |
96
96
  | `CLAWMEM_ENABLE_AMEM` | enabled | A-MEM note construction + link generation during indexing. |
97
- | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs. Needs long-lived MCP process. |
97
+ | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs and runs Phase 2/3 consolidation + deductive synthesis. **v0.8.2:** every tick is wrapped in a DB-backed `worker_leases` row (`light-consolidation` key), so multiple host processes against the same vault cannot race on Phase 2 merge writes. Hosted by either `clawmem watch` (canonical, long-lived) or `clawmem mcp` (per-session fallback). |
98
98
  | `CLAWMEM_CONSOLIDATION_INTERVAL` | 300000 | Worker interval in ms (min 15000). |
99
- | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane; off by default. |
99
+ | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane; off by default. **v0.8.2:** canonical host is `clawmem watch` (e.g. systemd `clawmem-watcher.service`); `clawmem mcp` retains the same gate as a fallback host but emits a stderr warning advising operators to move heavy-lane hosting to the watcher because per-session stdio MCPs may never be alive during the configured quiet window. |
100
100
  | `CLAWMEM_HEAVY_LANE_INTERVAL` | 1800000 | **v0.8.0.** Heavy-lane tick interval in ms (min 30000, default 30 min). |
101
101
  | `CLAWMEM_HEAVY_LANE_WINDOW_START` | (none) | **v0.8.0.** Start hour (0-23) of the quiet window. Unset → no window. |
102
102
  | `CLAWMEM_HEAVY_LANE_WINDOW_END` | (none) | **v0.8.0.** End hour (0-23, exclusive) of the quiet window. Supports midnight wrap (22→6). |
package/CLAUDE.md CHANGED
@@ -94,9 +94,9 @@ curl http://host:8090/v1/models
94
94
  | `CLAWMEM_NO_LOCAL_MODELS` | `false` | Blocks `node-llama-cpp` from auto-downloading GGUF models. Set `true` for remote-only setups. |
95
95
  | `CLAWMEM_VAULTS` | (none) | JSON map of vault name → SQLite path for multi-vault mode. E.g. `{"work":"~/.cache/clawmem/work.sqlite"}` |
96
96
  | `CLAWMEM_ENABLE_AMEM` | enabled | A-MEM note construction + link generation during indexing. |
97
- | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs. Needs long-lived MCP process. |
97
+ | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs and runs Phase 2/3 consolidation + deductive synthesis. **v0.8.2:** every tick is wrapped in a DB-backed `worker_leases` row (`light-consolidation` key), so multiple host processes against the same vault cannot race on Phase 2 merge writes. Hosted by either `clawmem watch` (canonical, long-lived) or `clawmem mcp` (per-session fallback). |
98
98
  | `CLAWMEM_CONSOLIDATION_INTERVAL` | 300000 | Worker interval in ms (min 15000). |
99
- | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane; off by default. |
99
+ | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane; off by default. **v0.8.2:** canonical host is `clawmem watch` (e.g. systemd `clawmem-watcher.service`); `clawmem mcp` retains the same gate as a fallback host but emits a stderr warning advising operators to move heavy-lane hosting to the watcher because per-session stdio MCPs may never be alive during the configured quiet window. |
100
100
  | `CLAWMEM_HEAVY_LANE_INTERVAL` | 1800000 | **v0.8.0.** Heavy-lane tick interval in ms (min 30000, default 30 min). |
101
101
  | `CLAWMEM_HEAVY_LANE_WINDOW_START` | (none) | **v0.8.0.** Start hour (0-23) of the quiet window. Unset → no window. |
102
102
  | `CLAWMEM_HEAVY_LANE_WINDOW_END` | (none) | **v0.8.0.** End hour (0-23, exclusive) of the quiet window. Supports midnight wrap (22→6). |
package/README.md CHANGED
@@ -108,6 +108,22 @@ Adds +56 tests (13 worker-lease + 35 maintenance unit + 8 maintenance integratio
108
108
 
109
109
  Adds +27 tests (22 unit + 5 integration) on top of the v0.8.0 baseline.
110
110
 
111
+ ### v0.8.2 Dual-Host Worker Architecture
112
+
113
+ Both maintenance lanes can now be hosted by the long-lived `clawmem watch` watcher service in addition to the existing per-session `clawmem mcp` host. This makes the systemd-managed watcher the canonical 24/7 home for the v0.8.0 heavy maintenance lane — its quiet-window logic finally sees a live worker at the configured hours regardless of whether any Claude Code session is open. The light consolidation lane (Phase 1 backfill + Phase 2 merge + Phase 3 deductive synthesis + Phase 4 recall stats) now also acquires its own DB-backed `worker_leases` row before each tick, symmetric with the heavy lane's existing exclusivity, so multiple host processes against the same vault cannot race on Phase 2 merges or Phase 3 deductive writes.
114
+
115
+ - **Light-lane worker lease** — `runConsolidationTick` wraps every tick (Phase 1 → 4) in `withWorkerLease` against a new `light-consolidation` worker name with a 10-minute TTL. Two host processes (e.g. one watcher service + one per-session stdio MCP) cannot both consolidate the same near-duplicate observations or both INSERT a duplicate row into `consolidated_observations`. Phase 1 enrichment is also serialized — overkill for cost but cleaner for symmetry. The in-process `isRunning` reentrancy guard remains the cheap first defense before the SQLite lease round-trip.
116
+ - **`cmdWatch` hosts both workers** — `clawmem watch` honors the same `CLAWMEM_ENABLE_CONSOLIDATION` and `CLAWMEM_HEAVY_LANE` env-var gates as `cmdMcp`. Off by default. Mirror the existing systemd unit (or your wrapper `.env`) to opt in. The recommended deployment for v0.8.2+ is to set both env vars on `clawmem-watcher.service` and leave `cmdMcp` unset, so the heavy lane has a continuously available host independent of Claude Code session lifecycle.
117
+ - **`cmdMcp` is now a fallback host with a heavy-lane warning** — `cmdMcp` retains the same env-var gates so non-watcher deployments (e.g. macOS users running everything via Claude Code launchd) keep working unchanged. When `CLAWMEM_HEAVY_LANE=true` is set on a stdio MCP host, `cmdMcp` emits a one-line warning to stderr advising operators to move heavy-lane hosting to `clawmem watch` instead.
118
+ - **Async drain on shutdown** — both worker stop helpers (`stopConsolidationWorker` and the closure returned by `startHeavyMaintenanceWorker`) are now `async`, clearing their `setInterval` AND polling their in-flight running flag until any mid-tick worker drains. This guarantees the worker's `withWorkerLease` finally block runs against a still-open store, so the lease is released cleanly instead of leaking until TTL expiry. Bounded waits (15s light, 30s heavy) prevent a stuck tick from wedging shutdown indefinitely; the next process reclaims any stranded lease atomically.
119
+ - **Signal handlers registered before worker startup** — both `cmdWatch` and `cmdMcp` now register their `SIGINT`/`SIGTERM` handlers BEFORE any worker initialization. A signal arriving in the brief window between worker startup and handler registration would otherwise terminate the host via the default signal action (exit 143) and skip the async drain entirely.
120
+ - **Subprocess smoke test** — new `tests/integration/cmdwatch-workers.integration.test.ts` spawns `bun src/clawmem.ts watch` against a temp vault with short worker intervals, exercises the env-var gates, exercises a real heavy-lane tick (slow path, ~35s), and asserts the lease is released cleanly on `SIGTERM`.
121
+ - **Bug fix: removed dead skill-vault watcher block from `clawmem.ts cmdWatch()`** — a try/catch wrapped block had been silently destructuring `getSkillContentRoot` from `./config.ts`, but that helper is forge-internal and was never exported in public ClawMem. The runtime catch swallowed the failure so it had no observable effect, but TypeScript flagged a static `TS2339` error on the destructure. v0.8.2 removes the dead code path. No behavior change for public users.
122
+
123
+ Adds +15 tests (9 light-lane lease unit + 5 cmdWatch fast subprocess + 1 cmdWatch slow subprocess) on top of the v0.8.1 baseline.
124
+
125
+ For operational guidance — enabling the workers via systemd drop-in, tuning intervals to your usage pattern, monitoring queries, and rollback steps — see [docs/guides/systemd-services.md](docs/guides/systemd-services.md#background-maintenance-workers-v082).
126
+
111
127
  ## Architecture
112
128
 
113
129
  <p align="center">
@@ -173,7 +189,7 @@ After installing, here's the full journey from zero to working memory:
173
189
  | **1. Bootstrap** | Create a vault, index your first collection, embed, install hooks and MCP | `clawmem bootstrap ~/notes --name notes` | One command does it all. Or run each step manually (see below). |
174
190
  | **2. Choose models** | Pick embedding + reranker models based on your hardware | 12GB+ VRAM → SOTA stack (zembed-1 + zerank-2). Less → QMD native combo. No GPU → cloud embedding or CPU fallback. | [GPU Services](#gpu-services) |
175
191
  | **3. Download models** | Get the GGUF files for your chosen stack | `wget` from HuggingFace, or let `node-llama-cpp` auto-download the QMD native models on first use | [Embedding](#embedding), [LLM Server](#llm-server), [Reranker Server](#reranker-server) |
176
- | **4. Start services** | Run GPU servers (if using dedicated GPU) and background services | `llama-server` for each model. systemd units for watcher + embed timer. | [systemd services](docs/guides/systemd-services.md) |
192
+ | **4. Start services** | Run GPU servers (if using dedicated GPU) and background services. Optionally enable the v0.8.2 background maintenance workers in the watcher unit so consolidation + deductive synthesis run automatically. | `llama-server` for each model. systemd units for watcher + embed timer. Drop-in for the watcher to enable workers + tune intervals + set the quiet window. | [systemd services](docs/guides/systemd-services.md), [background workers](docs/guides/systemd-services.md#background-maintenance-workers-v082) |
177
193
  | **5. Decide what to index** | Add collections for your projects, notes, research, and domain docs | `clawmem collection add ~/project --name project` | The more relevant markdown you index, the better retrieval works. See [building a rich context field](docs/introduction.md#building-a-rich-context-field). |
178
194
  | **6. Connect your agent** | Hook into Claude Code, OpenClaw, Hermes, or any MCP client | `clawmem setup hooks && clawmem setup mcp` for Claude Code. `clawmem setup openclaw` for OpenClaw. Copy `src/hermes/` to Hermes plugins for Hermes. | [Integration](#integration) |
179
195
  | **7. Verify** | Confirm everything is working | `clawmem doctor` (full health check) or `clawmem status` (quick index stats) | [Verify Installation](#verify-installation) |
@@ -223,6 +239,8 @@ clawmem embed # Re-embed if upgrading embedding models (not needed f
223
239
 
224
240
  Routine patch updates (e.g. 0.2.0 → 0.2.1) do not require reindexing.
225
241
 
242
+ For version-specific upgrade notes (opt-in features, optional cleanup steps, verification commands), see [docs/guides/upgrading.md](docs/guides/upgrading.md).
243
+
226
244
  ### Integration
227
245
 
228
246
  #### Claude Code
package/SKILL.md CHANGED
@@ -85,14 +85,14 @@ curl http://host:8090/v1/models
85
85
  | `CLAWMEM_RERANK_URL` | `http://localhost:8090` | Reranker server. Falls to `node-llama-cpp` if unset + `NO_LOCAL_MODELS=false`. |
86
86
  | `CLAWMEM_NO_LOCAL_MODELS` | `false` | Blocks `node-llama-cpp` auto-downloads. Set `true` for remote-only. |
87
87
  | `CLAWMEM_ENABLE_AMEM` | enabled | A-MEM note construction + link generation during indexing. |
88
- | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs. Needs long-lived MCP process. |
88
+ | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Light-lane consolidation worker (Phase 1 backfill + Phase 2 merge + Phase 3 deductive synthesis + Phase 4 recall stats). **v0.8.2:** every tick wraps in a `worker_leases` row (`light-consolidation` key) so multiple host processes against the same vault cannot race on Phase 2 merges. Hosted by `clawmem watch` (canonical) or `clawmem mcp` (per-session fallback). |
89
89
  | `CLAWMEM_CONSOLIDATION_INTERVAL` | 300000 | Worker interval in ms (min 15000). |
90
90
  | `CLAWMEM_MERGE_SCORE_NORMAL` | `0.93` | **v0.7.1.** Phase 2 merge-safety score threshold when candidate and existing anchors align. |
91
91
  | `CLAWMEM_MERGE_SCORE_STRICT` | `0.98` | **v0.7.1.** Strictest merge-safety threshold (fallback when anchors are ambiguous). |
92
92
  | `CLAWMEM_MERGE_GUARD_DRY_RUN` | `false` | **v0.7.1.** When `true`, Phase 2 merge-safety rejections are logged but not enforced — use for calibration. |
93
93
  | `CLAWMEM_CONTRADICTION_POLICY` | `link` | **v0.7.1.** How the merge-time contradiction gate handles a blocked merge. `link` (default) keeps both rows + inserts `contradicts` edge. `supersede` marks the old row `status='inactive'`. |
94
94
  | `CLAWMEM_CONTRADICTION_MIN_CONFIDENCE` | `0.5` | **v0.7.1.** Minimum combined heuristic+LLM confidence required before the contradiction gate blocks a merge. |
95
- | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane. |
95
+ | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane. **v0.8.2:** canonical host is `clawmem watch` (e.g. systemd `clawmem-watcher.service`); `clawmem mcp` retains the same gate as a fallback host but emits a stderr warning advising operators to move heavy-lane hosting to the watcher because per-session stdio MCPs may never be alive during the configured quiet window. |
96
96
  | `CLAWMEM_HEAVY_LANE_INTERVAL` | 1800000 | **v0.8.0.** Heavy-lane tick interval in ms (min 30000, default 30 min). |
97
97
  | `CLAWMEM_HEAVY_LANE_WINDOW_START` / `_END` | (none) | **v0.8.0.** Start/end hours (0-23) of the quiet window. Supports midnight wrap (22→6). Null on either bound = always in window. |
98
98
  | `CLAWMEM_HEAVY_LANE_MAX_USAGES` | 30 | **v0.8.0.** Max `context_usage` rows in the last 10 min before the heavy lane skips with `reason='query_rate_high'`. |
@@ -767,7 +767,7 @@ clawmem consolidate [--dry-run] # Find and archive duplicate low-confidence docu
767
767
  - SAME (composite scoring), MAGMA (intent + graph), A-MEM (self-evolving notes) layer on top of QMD substrate.
768
768
  - Three `llama-server` instances on local or remote GPU. Wrapper defaults to `localhost:8088/8089/8090`.
769
769
  - `CLAWMEM_NO_LOCAL_MODELS=false` (default) allows in-process fallback. Set `true` for remote-only to fail fast.
770
- - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs. Only runs if MCP process stays alive long enough (every 5min).
770
+ - Consolidation worker (`CLAWMEM_ENABLE_CONSOLIDATION=true`) backfills unenriched docs and runs Phase 2 merge / Phase 3 deductive synthesis. **v0.8.2:** hosted by either `clawmem watch` (long-lived, canonical) or `clawmem mcp` (per-session fallback); every tick acquires a `light-consolidation` `worker_leases` row before doing work, so dual-hosting against the same vault is safe.
771
771
  - Beads integration: `syncBeadsIssues()` queries `bd` CLI (Dolt backend, v0.58.0+), creates markdown docs, maps dependency edges into `memory_relations`. Watcher auto-triggers on `.beads/` changes; `beads_sync` MCP for manual sync.
772
772
  - HTTP REST API: `clawmem serve [--port 7438]` — optional REST server on localhost. Search, retrieval, lifecycle, and graph traversal. `POST /retrieve` mirrors `memory_retrieve` with auto-routing (keyword/semantic/causal/timeline/hybrid). `POST /search` provides direct mode selection. Bearer token auth via `CLAWMEM_API_TOKEN` env var (disabled if unset).
773
773
  - OpenClaw ContextEngine plugin: `clawmem setup openclaw` — registers as native OpenClaw context engine. Dual-mode: shares vault with Claude Code hooks. Uses `before_prompt_build` for retrieval, `afterTurn()` for extraction, `compact()` for pre-compaction + runtime delegation (v0.3.0+, required for OpenClaw v2026.3.28+).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "On-device context engine and memory for AI agents. Claude Code and OpenClaw. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clawmem.ts CHANGED
@@ -45,6 +45,14 @@ import { enrichResults, reciprocalRankFusion, toRanked, type RankedResult } from
45
45
  import { splitDocument } from "./splitter.ts";
46
46
  import { getProfile, updateProfile, isProfileStale } from "./profile.ts";
47
47
  import { regenerateAllDirectoryContexts } from "./directory-context.ts";
48
+ import {
49
+ startConsolidationWorker,
50
+ stopConsolidationWorker,
51
+ } from "./consolidation.ts";
52
+ import {
53
+ parseHeavyLaneConfigFromEnv,
54
+ startHeavyMaintenanceWorker,
55
+ } from "./maintenance.ts";
48
56
  import { readHookInput, writeHookOutput, makeEmptyOutput, type HookOutput } from "./hooks.ts";
49
57
  import { contextSurfacing } from "./hooks/context-surfacing.ts";
50
58
  import { sessionBootstrap } from "./hooks/session-bootstrap.ts";
@@ -1363,13 +1371,74 @@ async function cmdWatch() {
1363
1371
  const dirs = collections.map(col => col.path);
1364
1372
  const s = getStore();
1365
1373
 
1374
+ // v0.8.2 Codex Turn 1 fix: register signal handlers BEFORE any async
1375
+ // startup work or worker startup. Resources are declared as null and
1376
+ // assigned once their respective creators run; the shutdown closure
1377
+ // captures the variable references so updates after registration are
1378
+ // visible. Without this ordering, a SIGTERM arriving during the brief
1379
+ // window between the worker startup banner and the handler registration
1380
+ // would terminate the watcher via the default signal action (exit 143)
1381
+ // instead of running the async drain → release → close sequence.
1382
+ let stopHeavyLane: (() => Promise<void>) | null = null;
1383
+ let watcherHandle: { close: () => void } | null = null;
1384
+ let checkpointTimerHandle: Timer | null = null;
1385
+
1386
+ // Graceful shutdown — stop workers, close watchers, then exit. SIGTERM
1387
+ // handling is critical for systemd `systemctl --user stop` to shut down
1388
+ // cleanly instead of being killed by the unit timeout. Both worker stops
1389
+ // are awaited so any mid-tick worker drains and releases its lease via
1390
+ // its own withWorkerLease finally block before we close the store.
1391
+ const shutdown = async (signal: string) => {
1392
+ console.log(`\n${c.dim}[watch] Received ${signal}, shutting down...${c.reset}`);
1393
+ if (stopHeavyLane) {
1394
+ await stopHeavyLane();
1395
+ stopHeavyLane = null;
1396
+ }
1397
+ await stopConsolidationWorker();
1398
+ if (checkpointTimerHandle) {
1399
+ clearInterval(checkpointTimerHandle);
1400
+ checkpointTimerHandle = null;
1401
+ }
1402
+ if (watcherHandle) {
1403
+ watcherHandle.close();
1404
+ watcherHandle = null;
1405
+ }
1406
+ closeStore();
1407
+ process.exit(0);
1408
+ };
1409
+ process.on("SIGINT", () => { void shutdown("SIGINT"); });
1410
+ process.on("SIGTERM", () => { void shutdown("SIGTERM"); });
1411
+
1366
1412
  console.log(`${c.bold}Watching ${dirs.length} collection(s) for changes...${c.reset}`);
1367
1413
  for (const col of collections) {
1368
1414
  console.log(` ${c.dim}${col.name}: ${col.path}${c.reset}`);
1369
1415
  }
1370
1416
  console.log(`${c.dim}Press Ctrl+C to stop.${c.reset}`);
1371
1417
 
1372
- const watcher = startWatcher(dirs, {
1418
+ // v0.8.2: Light + heavy maintenance lane workers (opt-in via env vars).
1419
+ // Hosting them in `cmdWatch` makes the long-lived watcher service the
1420
+ // canonical host for both lanes — `clawmem-watcher.service` runs 24/7
1421
+ // under systemd, so the heavy lane's quiet-window logic actually sees a
1422
+ // live worker at the configured hours regardless of whether any Claude
1423
+ // Code session is open. `cmdMcp` (stdio MCP) keeps the same env-var
1424
+ // gates as a fallback host, but warns when CLAWMEM_HEAVY_LANE=true
1425
+ // since per-session MCPs are short-lived. Both hosts share the same
1426
+ // DB-backed `worker_leases` exclusivity (heavy lane v0.8.0, light lane
1427
+ // v0.8.2), so running both at once is safe.
1428
+ if (Bun.env.CLAWMEM_ENABLE_CONSOLIDATION === "true") {
1429
+ const llm = getDefaultLlamaCpp();
1430
+ const intervalMs = parseInt(Bun.env.CLAWMEM_CONSOLIDATION_INTERVAL || "300000", 10);
1431
+ console.log(`${c.dim}[watch] Starting consolidation worker (light lane, interval=${intervalMs}ms)${c.reset}`);
1432
+ startConsolidationWorker(s, llm, intervalMs);
1433
+ }
1434
+ if (Bun.env.CLAWMEM_HEAVY_LANE === "true") {
1435
+ const llm = getDefaultLlamaCpp();
1436
+ const cfg = parseHeavyLaneConfigFromEnv();
1437
+ console.log(`${c.dim}[watch] Starting heavy maintenance lane worker${c.reset}`);
1438
+ stopHeavyLane = startHeavyMaintenanceWorker(s, llm, cfg);
1439
+ }
1440
+
1441
+ watcherHandle = startWatcher(dirs, {
1373
1442
  debounceMs: 2000,
1374
1443
  onChanged: async (fullPath, event) => {
1375
1444
  // Find which collection this belongs to
@@ -1424,45 +1493,12 @@ async function cmdWatch() {
1424
1493
  },
1425
1494
  });
1426
1495
 
1427
- // Skill vault watcher: watch _clawmem-skills/ content root if configured
1428
- let skillWatcher: { close: () => void } | null = null;
1429
- try {
1430
- const { getVaultPath, getSkillContentRoot } = await import("./config.ts");
1431
- const { resolveStore } = await import("./store.ts");
1432
- const skillVaultPath = getVaultPath("skill");
1433
- const skillRoot = getSkillContentRoot();
1434
-
1435
- if (skillVaultPath && existsSync(skillRoot)) {
1436
- const skillStore = resolveStore("skill");
1437
- console.log(`${c.bold}Watching skill vault content root...${c.reset}`);
1438
- console.log(` ${c.dim}skill: ${skillRoot} → ${skillVaultPath}${c.reset}`);
1439
-
1440
- skillWatcher = startWatcher([skillRoot], {
1441
- debounceMs: 2000,
1442
- onChanged: async (fullPath, event) => {
1443
- const relativePath = fullPath.slice(skillRoot.length + 1);
1444
- console.log(`${c.dim}[${event}]${c.reset} skill/${relativePath}`);
1445
-
1446
- const stats = await indexCollection(skillStore, "skill-observations", skillRoot, "**/*.md");
1447
- if (stats.added > 0 || stats.updated > 0 || stats.removed > 0) {
1448
- console.log(` skill: +${stats.added} ~${stats.updated} -${stats.removed}`);
1449
- }
1450
- },
1451
- onError: (err) => {
1452
- console.error(`${c.red}Skill watch error: ${err.message}${c.reset}`);
1453
- },
1454
- });
1455
- }
1456
- } catch {
1457
- // Skill vault not configured — skip
1458
- }
1459
-
1460
1496
  // Periodic WAL checkpoint: the watcher holds a long-lived DB connection which
1461
1497
  // prevents SQLite auto-checkpoint from shrinking the WAL file. Without this,
1462
1498
  // the WAL grows unbounded (observed 77MB+), slowing every concurrent DB access
1463
1499
  // (hooks, MCP) and eventually causing UserPromptSubmit hook timeouts.
1464
1500
  const WAL_CHECKPOINT_INTERVAL = 5 * 60 * 1000; // 5 minutes
1465
- const checkpointTimer = setInterval(() => {
1501
+ checkpointTimerHandle = setInterval(() => {
1466
1502
  try {
1467
1503
  s.db.exec("PRAGMA wal_checkpoint(PASSIVE)");
1468
1504
  } catch {
@@ -1470,16 +1506,7 @@ async function cmdWatch() {
1470
1506
  }
1471
1507
  }, WAL_CHECKPOINT_INTERVAL);
1472
1508
 
1473
- // Keep running until Ctrl+C
1474
- process.on("SIGINT", () => {
1475
- clearInterval(checkpointTimer);
1476
- watcher.close();
1477
- skillWatcher?.close();
1478
- closeStore();
1479
- process.exit(0);
1480
- });
1481
-
1482
- // Block forever
1509
+ // Block forever shutdown is driven by signal handlers registered above.
1483
1510
  await new Promise(() => {});
1484
1511
  }
1485
1512
 
@@ -17,6 +17,7 @@ import type { LlamaCpp } from "./llm.ts";
17
17
  import { extractJsonFromLLM } from "./amem.ts";
18
18
  import { hashContent } from "./indexer.ts";
19
19
  import { passesMergeSafety } from "./text-similarity.ts";
20
+ import { withWorkerLease } from "./worker-lease.ts";
20
21
  import {
21
22
  checkContradiction,
22
23
  isActionableContradiction,
@@ -166,22 +167,68 @@ let consolidationTimer: Timer | null = null;
166
167
  let isRunning = false;
167
168
  let tickCount = 0;
168
169
 
170
+ /**
171
+ * DB-backed worker lease name for the light consolidation lane (v0.8.2).
172
+ * Distinct from the heavy-maintenance lane's lease so both lanes can hold
173
+ * independent exclusivity against the same SQLite vault without colliding.
174
+ */
175
+ export const DEFAULT_LIGHT_LANE_WORKER_NAME = "light-consolidation";
176
+
177
+ /**
178
+ * Default worker-lease TTL for the light lane (10 min). A tick normally
179
+ * finishes in seconds, but Phase 2 consolidation + Phase 3 deductive
180
+ * synthesis can stack many LLM calls under worst-case conditions. A 10-min
181
+ * ceiling covers that case without leaving a stranded lease forever if the
182
+ * process is SIGKILL'd mid-tick — the next worker reclaims it atomically
183
+ * via the single-statement upsert in `acquireWorkerLease` once the TTL
184
+ * has elapsed.
185
+ */
186
+ export const DEFAULT_LIGHT_LANE_LEASE_TTL_MS = 10 * 60 * 1000;
187
+
188
+ /**
189
+ * Options for a single consolidation tick (v0.8.2). All fields optional;
190
+ * omitting the bag reproduces pre-v0.8.2 behavior except for the newly
191
+ * added DB-backed lease wrap, which is always on.
192
+ *
193
+ * - `workerName` override the lease name (default "light-consolidation").
194
+ * Tests should pass a unique name to avoid cross-test
195
+ * contention with other suites running in the same bun
196
+ * process.
197
+ * - `leaseTtlMs` override the lease TTL. Tests use short TTLs (e.g.
198
+ * 100 ms with a past `now`) to exercise expiry reclaim
199
+ * without real delay.
200
+ */
201
+ export interface ConsolidationTickOptions {
202
+ workerName?: string;
203
+ leaseTtlMs?: number;
204
+ }
205
+
169
206
  // =============================================================================
170
207
  // Worker Functions
171
208
  // =============================================================================
172
209
 
173
210
  /**
174
- * Starts the consolidation worker that enriches documents missing A-MEM metadata
175
- * and periodically consolidates observations.
211
+ * Starts the consolidation worker that enriches documents missing A-MEM
212
+ * metadata and periodically consolidates observations.
176
213
  *
177
- * @param store - Store instance with A-MEM methods
178
- * @param llm - LLM instance for memory note construction
179
- * @param intervalMs - Tick interval in milliseconds (default: 300000 = 5 min)
214
+ * v0.8.2 every tick is wrapped in a DB-backed worker lease (see
215
+ * `runConsolidationTick`), so multiple host processes running this worker
216
+ * against the same vault cannot run Phase 2 merge / Phase 3 deductive
217
+ * synthesis concurrently. The tick still uses an in-process `isRunning`
218
+ * reentrancy guard that fires before the lease round-trip, so the common
219
+ * case (single process, overlapping timer fires) is handled without
220
+ * touching SQLite.
221
+ *
222
+ * @param store - Store instance with A-MEM methods
223
+ * @param llm - LLM instance for memory note construction
224
+ * @param intervalMs - Tick interval in milliseconds (default 300000 = 5 min)
225
+ * @param opts - Optional lease overrides (worker name, TTL)
180
226
  */
181
227
  export function startConsolidationWorker(
182
228
  store: Store,
183
229
  llm: LlamaCpp,
184
- intervalMs: number = 300000
230
+ intervalMs: number = 300000,
231
+ opts: ConsolidationTickOptions = {},
185
232
  ): void {
186
233
  // Clamp interval to minimum 15 seconds
187
234
  const interval = Math.max(15000, intervalMs);
@@ -190,7 +237,7 @@ export function startConsolidationWorker(
190
237
 
191
238
  // Set up periodic tick
192
239
  consolidationTimer = setInterval(async () => {
193
- await tick(store, llm);
240
+ await runConsolidationTick(store, llm, opts);
194
241
  }, interval);
195
242
 
196
243
  // Use unref() to avoid blocking process exit
@@ -200,55 +247,133 @@ export function startConsolidationWorker(
200
247
  }
201
248
 
202
249
  /**
203
- * Stops the consolidation worker.
250
+ * Stops the consolidation worker. Async since v0.8.2 — clears the interval
251
+ * AND awaits any in-flight tick before resolving, so callers (signal
252
+ * handlers, test fixtures) can safely close the store afterward without
253
+ * yanking the DB out from under a mid-tick worker. The wait is bounded by
254
+ * `STOP_DRAIN_TIMEOUT_MS` (15s) so a pathologically stuck tick cannot
255
+ * wedge shutdown indefinitely; if the timeout fires, the function logs
256
+ * and returns anyway (the next process will reclaim the stale lease via
257
+ * the v0.8.0 `worker_leases` TTL upsert).
204
258
  */
205
- export function stopConsolidationWorker(): void {
259
+ export async function stopConsolidationWorker(): Promise<void> {
206
260
  if (consolidationTimer) {
207
261
  clearInterval(consolidationTimer);
208
262
  consolidationTimer = null;
263
+ console.log("[consolidation] Worker stop signaled — draining in-flight tick");
264
+ }
265
+ const deadline = Date.now() + STOP_DRAIN_TIMEOUT_MS;
266
+ while (isRunning && Date.now() < deadline) {
267
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
268
+ }
269
+ if (isRunning) {
270
+ console.log(
271
+ `[consolidation] Worker stop drain timed out after ${STOP_DRAIN_TIMEOUT_MS}ms — tick still running`,
272
+ );
273
+ } else {
209
274
  console.log("[consolidation] Worker stopped");
210
275
  }
211
276
  }
212
277
 
213
278
  /**
214
- * Single worker tick: A-MEM backfill + periodic observation consolidation.
279
+ * v0.8.2 bounded wait for in-flight light-lane tick during shutdown.
280
+ * 15 seconds is more than enough for Phase 1 + Phase 4 to drain (the
281
+ * cheap phases) and lets Phase 2/3 mid-flight LLM calls finish naturally
282
+ * in most environments. Stuck-tick scenarios (e.g. unreachable LLM with
283
+ * no socket timeout) fall back to the v0.8.0 worker_leases TTL reclaim.
284
+ */
285
+ const STOP_DRAIN_TIMEOUT_MS = 15_000;
286
+
287
+ /**
288
+ * Run one consolidation tick: Phase 1 (A-MEM backfill) → Phase 2 (observation
289
+ * consolidation, every 6th tick) → Phase 3 (deductive synthesis, every 3rd
290
+ * tick) → Phase 4 (recall stats recomputation, every tick).
291
+ *
292
+ * v0.8.2 — wrapped in a DB-backed worker lease so at most one host process
293
+ * ticks at a time against the same vault, symmetric with the v0.8.0 heavy
294
+ * maintenance lane's `worker_leases` exclusivity pattern. Phase 2 is the
295
+ * race-sensitive phase Codex flagged in the v0.8.2 pre-rollout review:
296
+ * without the lease, two concurrent workers could both INSERT a new
297
+ * consolidated observation for the same cluster, or both merge into the
298
+ * same existing row and lose source_ids from the read-modify-write update
299
+ * in `mergeIntoExistingConsolidation`.
300
+ *
301
+ * An in-process reentrancy guard (`isRunning`) fires before the lease
302
+ * round-trip, so overlapping setInterval timer fires from the same process
303
+ * do not incur a SQLite round-trip per skip.
304
+ *
305
+ * Returns `{ acquired }` so integration tests (and the setInterval wrapper)
306
+ * can distinguish ticks that did real work from ticks skipped by the lease
307
+ * or reentrancy gate.
308
+ *
309
+ * Exported in v0.8.2 so tests can drive individual ticks directly without
310
+ * spinning up the setInterval loop.
215
311
  */
216
- async function tick(store: Store, llm: LlamaCpp): Promise<void> {
217
- // Reentrancy guard
312
+ export async function runConsolidationTick(
313
+ store: Store,
314
+ llm: LlamaCpp,
315
+ opts: ConsolidationTickOptions = {},
316
+ ): Promise<{ acquired: boolean }> {
317
+ // In-process reentrancy guard: catches overlapping setInterval fires in
318
+ // the same process before we hit SQLite. Cheap; the lease is the
319
+ // cross-process authority.
218
320
  if (isRunning) {
219
- console.log("[consolidation] Skipping tick (already running)");
220
- return;
321
+ console.log("[consolidation] Skipping tick (already running in-process)");
322
+ return { acquired: false };
221
323
  }
222
324
 
223
- isRunning = true;
224
- tickCount++;
325
+ const workerName = opts.workerName ?? DEFAULT_LIGHT_LANE_WORKER_NAME;
326
+ const leaseTtlMs = opts.leaseTtlMs ?? DEFAULT_LIGHT_LANE_LEASE_TTL_MS;
225
327
 
328
+ isRunning = true;
226
329
  try {
227
- // Phase 1: A-MEM backfill (every tick)
228
- await backfillAmem(store, llm);
330
+ const lease = await withWorkerLease(
331
+ store,
332
+ workerName,
333
+ leaseTtlMs,
334
+ async () => {
335
+ tickCount++;
336
+ try {
337
+ // Phase 1: A-MEM backfill (every tick)
338
+ await backfillAmem(store, llm);
339
+
340
+ // Phase 2: Observation consolidation (every 6th tick — ~30 min
341
+ // at default interval). Race-sensitive — see doc comment above.
342
+ if (tickCount % 6 === 0) {
343
+ await consolidateObservations(store, llm);
344
+ }
229
345
 
230
- // Phase 2: Observation consolidation (every 6th tick, ~30 min at default interval)
231
- if (tickCount % 6 === 0) {
232
- await consolidateObservations(store, llm);
233
- }
346
+ // Phase 3: Deductive synthesis (every 3rd tick ~15 min).
347
+ // Writes are mostly idempotent on the hash-stable path but the
348
+ // anti-contamination validator still burns LLM calls, so
349
+ // running two workers in parallel is pure cost.
350
+ if (tickCount % 3 === 0) {
351
+ await generateDeductiveObservations(store, llm);
352
+ }
234
353
 
235
- // Phase 3: Deductive synthesis (every 3rd tick, ~15 min at default interval)
236
- if (tickCount % 3 === 0) {
237
- await generateDeductiveObservations(store, llm);
238
- }
354
+ // Phase 4: Recall stats recomputation (every tick lightweight
355
+ // SQL aggregation). Non-critical recall stats are
356
+ // informational, not retrieval-blocking.
357
+ try {
358
+ const updated = store.recomputeRecallStats();
359
+ if (updated > 0) {
360
+ console.log(`[consolidation] Phase 4: recomputed recall_stats for ${updated} docs`);
361
+ }
362
+ } catch (err) {
363
+ console.error("[consolidation] Phase 4 recall stats failed:", err);
364
+ }
365
+ } catch (err) {
366
+ console.error("[consolidation] Tick failed:", err);
367
+ }
368
+ },
369
+ );
239
370
 
240
- // Phase 4: Recall stats recomputation (every tick — lightweight SQL aggregation)
241
- try {
242
- const updated = store.recomputeRecallStats();
243
- if (updated > 0) {
244
- console.log(`[consolidation] Phase 4: recomputed recall_stats for ${updated} docs`);
245
- }
246
- } catch (err) {
247
- // Non-critical — recall stats are informational, not retrieval-blocking
248
- console.error("[consolidation] Phase 4 recall stats failed:", err);
371
+ if (!lease.acquired) {
372
+ console.log(
373
+ `[consolidation] Skipping tick (lease '${workerName}' held by another worker)`,
374
+ );
249
375
  }
250
- } catch (err) {
251
- console.error("[consolidation] Tick failed:", err);
376
+ return { acquired: lease.acquired };
252
377
  } finally {
253
378
  isRunning = false;
254
379
  }
@@ -77,6 +77,37 @@ const DEFAULT_CONFIG: Required<Omit<HeavyMaintenanceConfig, "workerName" | "cloc
77
77
 
78
78
  const DEFAULT_WORKER_NAME = "heavy-maintenance";
79
79
 
80
+ /**
81
+ * Parse a `HeavyMaintenanceConfig` from `Bun.env` (v0.8.2). Shared by every
82
+ * host that can start the heavy lane (`cmdMcp` in mcp.ts, `cmdWatch` in
83
+ * clawmem.ts) so the env var convention stays in one place. Each field is
84
+ * left undefined when its env var is unset, so `DEFAULT_CONFIG` continues
85
+ * to drive any field the operator did not explicitly override.
86
+ */
87
+ export function parseHeavyLaneConfigFromEnv(): HeavyMaintenanceConfig {
88
+ return {
89
+ intervalMs: Bun.env.CLAWMEM_HEAVY_LANE_INTERVAL
90
+ ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_INTERVAL, 10)
91
+ : undefined,
92
+ windowStartHour: Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_START
93
+ ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_START, 10)
94
+ : null,
95
+ windowEndHour: Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_END
96
+ ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_END, 10)
97
+ : null,
98
+ maxContextUsagesPer10m: Bun.env.CLAWMEM_HEAVY_LANE_MAX_USAGES
99
+ ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_MAX_USAGES, 10)
100
+ : undefined,
101
+ staleObservationLimit: Bun.env.CLAWMEM_HEAVY_LANE_OBS_LIMIT
102
+ ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_OBS_LIMIT, 10)
103
+ : undefined,
104
+ staleDeductiveLimit: Bun.env.CLAWMEM_HEAVY_LANE_DED_LIMIT
105
+ ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_DED_LIMIT, 10)
106
+ : undefined,
107
+ useSurprisalSelector: Bun.env.CLAWMEM_HEAVY_LANE_SURPRISAL === "true",
108
+ };
109
+ }
110
+
80
111
  // =============================================================================
81
112
  // Journal helpers
82
113
  // =============================================================================
@@ -503,7 +534,7 @@ export function startHeavyMaintenanceWorker(
503
534
  store: Store,
504
535
  llm: LlamaCpp,
505
536
  cfg: HeavyMaintenanceConfig = {},
506
- ): () => void {
537
+ ): () => Promise<void> {
507
538
  const merged = { ...DEFAULT_CONFIG, ...cfg };
508
539
  // Clamp interval to minimum 30 seconds so buggy configs can't pin the CPU.
509
540
  const interval = Math.max(30_000, merged.intervalMs);
@@ -530,11 +561,35 @@ export function startHeavyMaintenanceWorker(
530
561
  }, interval);
531
562
  heavyTimer.unref();
532
563
 
533
- return () => {
564
+ // v0.8.2 async stop handle. Clears the timer AND awaits any in-flight
565
+ // tick before resolving, so callers can safely close the store afterward
566
+ // without yanking the DB from under a mid-tick worker. Bounded wait —
567
+ // a pathologically stuck tick cannot wedge shutdown indefinitely; the
568
+ // worker_leases TTL upsert reclaims any stranded lease on the next
569
+ // process startup.
570
+ return async () => {
534
571
  if (heavyTimer) {
535
572
  clearInterval(heavyTimer);
536
573
  heavyTimer = null;
574
+ console.log("[heavy-lane] Worker stop signaled — draining in-flight tick");
575
+ }
576
+ const deadline = Date.now() + HEAVY_STOP_DRAIN_TIMEOUT_MS;
577
+ while (heavyRunning && Date.now() < deadline) {
578
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
579
+ }
580
+ if (heavyRunning) {
581
+ console.log(
582
+ `[heavy-lane] Worker stop drain timed out after ${HEAVY_STOP_DRAIN_TIMEOUT_MS}ms — tick still running`,
583
+ );
584
+ } else {
537
585
  console.log("[heavy-lane] Worker stopped");
538
586
  }
539
587
  };
540
588
  }
589
+
590
+ /**
591
+ * v0.8.2 — bounded wait for in-flight heavy-lane tick during shutdown.
592
+ * 30 seconds covers a Phase 2 + Phase 3 stack with reasonable LLM latencies
593
+ * before falling back to the worker_leases TTL reclaim path.
594
+ */
595
+ const HEAVY_STOP_DRAIN_TIMEOUT_MS = 30_000;
package/src/mcp.ts CHANGED
@@ -39,7 +39,10 @@ import { classifyIntent, decomposeQuery, extractTemporalConstraint, type IntentT
39
39
  import { adaptiveTraversal, mergeTraversalResults, mpfpTraversal } from "./graph-traversal.ts";
40
40
  import { getDefaultLlamaCpp } from "./llm.ts";
41
41
  import { startConsolidationWorker, stopConsolidationWorker } from "./consolidation.ts";
42
- import { startHeavyMaintenanceWorker, type HeavyMaintenanceConfig } from "./maintenance.ts";
42
+ import {
43
+ parseHeavyLaneConfigFromEnv,
44
+ startHeavyMaintenanceWorker,
45
+ } from "./maintenance.ts";
43
46
  import { listVaults, loadVaultConfig } from "./config.ts";
44
47
  import { getEntityGraphNeighbors, searchEntities } from "./entity.ts";
45
48
 
@@ -2595,8 +2598,37 @@ This is the recommended entry point for ALL memory queries.`,
2595
2598
  await server.connect(transport);
2596
2599
 
2597
2600
  // ---------------------------------------------------------------------------
2598
- // Consolidation Worker
2599
- // ---------------------------------------------------------------------------
2601
+ // Shutdown wiring + Workers
2602
+ // ---------------------------------------------------------------------------
2603
+
2604
+ // v0.8.2 Codex Turn 2 fix: register signal handlers BEFORE any worker
2605
+ // startup, mirroring the same null-handle capture pattern that cmdWatch
2606
+ // uses. The handler is the only thing that suppresses Node's default
2607
+ // signal action (terminate), so a SIGTERM arriving in the brief window
2608
+ // between worker startup and `process.on(...)` registration would
2609
+ // exit-143 the process and skip the async drain entirely, leaking any
2610
+ // lease the worker had just acquired. Capturing `stopHeavyLane` as a
2611
+ // mutable closure variable lets the registration happen before the
2612
+ // worker is actually created — the handler reads whatever value is
2613
+ // bound at the moment a signal arrives.
2614
+ let stopHeavyLane: (() => Promise<void>) | null = null;
2615
+
2616
+ // Signal handlers for graceful shutdown. async stop sequence: both
2617
+ // worker stops await any in-flight tick before resolving so the store
2618
+ // is not closed underneath a mid-tick worker. Bounded waits inside the
2619
+ // stop functions guarantee the handler cannot wedge indefinitely.
2620
+ const shutdownMcp = async (signal: string) => {
2621
+ console.error(`\n[mcp] Received ${signal}, shutting down...`);
2622
+ if (stopHeavyLane) {
2623
+ await stopHeavyLane();
2624
+ stopHeavyLane = null;
2625
+ }
2626
+ await stopConsolidationWorker();
2627
+ closeAllStores();
2628
+ process.exit(0);
2629
+ };
2630
+ process.on("SIGINT", () => { void shutdownMcp("SIGINT"); });
2631
+ process.on("SIGTERM", () => { void shutdownMcp("SIGTERM"); });
2600
2632
 
2601
2633
  // Start consolidation worker if enabled
2602
2634
  if (Bun.env.CLAWMEM_ENABLE_CONSOLIDATION === "true") {
@@ -2609,49 +2641,25 @@ This is the recommended entry point for ALL memory queries.`,
2609
2641
  // longer interval than the light lane, only inside a configurable quiet
2610
2642
  // window, and gated by context_usage query-rate so interactive sessions
2611
2643
  // are never starved. Off by default.
2612
- let stopHeavyLane: (() => void) | null = null;
2644
+ //
2645
+ // v0.8.2: warn when this lane is enabled on a stdio MCP host. Per-session
2646
+ // MCPs spawned by Claude Code die with the session, which means the
2647
+ // configured quiet window may never see a live worker if no Claude Code
2648
+ // session is open at that time. The watcher service (`clawmem watch`) is
2649
+ // the canonical long-lived host for the heavy lane as of v0.8.2 — see
2650
+ // docs/concepts/architecture.md and docs/guides/upgrading.md for the
2651
+ // dual-host rationale.
2613
2652
  if (Bun.env.CLAWMEM_HEAVY_LANE === "true") {
2653
+ console.error(
2654
+ "[mcp] WARNING: CLAWMEM_HEAVY_LANE=true on a stdio MCP host. " +
2655
+ "Per-session MCPs are short-lived; the configured quiet window may " +
2656
+ "never see a live worker. As of v0.8.2 the canonical heavy-lane host " +
2657
+ "is `clawmem watch` (e.g. systemd user unit clawmem-watcher.service). " +
2658
+ "Set the same env var on the watcher service for reliable operation.",
2659
+ );
2614
2660
  const llm = getDefaultLlamaCpp();
2615
- const cfg: HeavyMaintenanceConfig = {
2616
- intervalMs: Bun.env.CLAWMEM_HEAVY_LANE_INTERVAL
2617
- ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_INTERVAL, 10)
2618
- : undefined,
2619
- windowStartHour: Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_START
2620
- ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_START, 10)
2621
- : null,
2622
- windowEndHour: Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_END
2623
- ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_WINDOW_END, 10)
2624
- : null,
2625
- maxContextUsagesPer10m: Bun.env.CLAWMEM_HEAVY_LANE_MAX_USAGES
2626
- ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_MAX_USAGES, 10)
2627
- : undefined,
2628
- staleObservationLimit: Bun.env.CLAWMEM_HEAVY_LANE_OBS_LIMIT
2629
- ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_OBS_LIMIT, 10)
2630
- : undefined,
2631
- staleDeductiveLimit: Bun.env.CLAWMEM_HEAVY_LANE_DED_LIMIT
2632
- ? parseInt(Bun.env.CLAWMEM_HEAVY_LANE_DED_LIMIT, 10)
2633
- : undefined,
2634
- useSurprisalSelector: Bun.env.CLAWMEM_HEAVY_LANE_SURPRISAL === "true",
2635
- };
2636
- stopHeavyLane = startHeavyMaintenanceWorker(store, llm, cfg);
2661
+ stopHeavyLane = startHeavyMaintenanceWorker(store, llm, parseHeavyLaneConfigFromEnv());
2637
2662
  }
2638
-
2639
- // Signal handlers for graceful shutdown
2640
- process.on("SIGINT", () => {
2641
- console.error("\n[mcp] Received SIGINT, shutting down...");
2642
- stopConsolidationWorker();
2643
- if (stopHeavyLane) stopHeavyLane();
2644
- closeAllStores();
2645
- process.exit(0);
2646
- });
2647
-
2648
- process.on("SIGTERM", () => {
2649
- console.error("\n[mcp] Received SIGTERM, shutting down...");
2650
- stopConsolidationWorker();
2651
- if (stopHeavyLane) stopHeavyLane();
2652
- closeAllStores();
2653
- process.exit(0);
2654
- });
2655
2663
  }
2656
2664
 
2657
2665
  if (import.meta.main) {