context-mode 1.0.129 → 1.0.131

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.129"
9
+ "version": "1.0.131"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.129",
16
+ "version": "1.0.131",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.129",
3
+ "version": "1.0.131",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.129",
6
+ "version": "1.0.131",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.129",
3
+ "version": "1.0.131",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -139,6 +139,26 @@ export declare function isSQLiteCorruptionError(msg: string): boolean;
139
139
  export declare function renameCorruptDB(dbPath: string): void;
140
140
  export declare abstract class SQLiteBase {
141
141
  #private;
142
+ /**
143
+ * Open (or create) a SQLite DB at `dbPath`.
144
+ *
145
+ * v1.0.130 — multi-writer is the contract. ALL SQLiteBase consumers
146
+ * (SessionDB, ContentStore) may open the same on-disk dbPath from
147
+ * multiple processes simultaneously — that is the legitimate multi-
148
+ * window UX shape and the WAL handles it natively. SQLITE_BUSY on
149
+ * write contention is absorbed by `withRetry()` below (busy_timeout
150
+ * = 30000ms inside `new Database(...)`).
151
+ *
152
+ * v1.0.128 introduced a single-writer guard here as a defense against
153
+ * #560. That defense was an over-correction — the actual root causes
154
+ * of #560 were #559 (zombie MCP child accumulation) and #561 (Pi
155
+ * misdetection writing to the wrong DB path), both fixed in v1.0.128
156
+ * + v1.0.129. The single-writer guard broke legitimate multi-window
157
+ * users; v1.0.130 rolls it out. See
158
+ * docs/adr/0001-sessiondb-multi-writer.md and the v1.0.130 INVARIANT
159
+ * block in tests/util/db-base-platform-gate.test.ts for the
160
+ * regression-proof anchor (source-pin + behavioural).
161
+ */
142
162
  constructor(dbPath: string);
143
163
  /** Called once after WAL pragmas are applied. Subclasses run CREATE TABLE/VIRTUAL TABLE here. */
144
164
  protected abstract initSchema(): void;
package/build/db-base.js CHANGED
@@ -9,12 +9,6 @@ import { createRequire } from "node:module";
9
9
  import { existsSync, unlinkSync, renameSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
- // v1.0.128 — Issue #560 single-writer enforcement.
13
- // Lockfile is the PRIMARY defense (clean UX with conflicting PID),
14
- // `locking_mode = EXCLUSIVE` (applied in applyWALPragmas below) is the
15
- // SECONDARY defense for the narrow race window between lockfile claim
16
- // and the actual `new Database(...)` open. Both skip-gate on tmpdir paths.
17
- import { acquireDbLock, releaseDbLock } from "./util/db-lock.js";
18
12
  // ─────────────────────────────────────────────────────────
19
13
  // bun:sqlite adapter (#45)
20
14
  // ─────────────────────────────────────────────────────────
@@ -317,14 +311,12 @@ export function applyWALPragmas(db) {
317
311
  }
318
312
  catch { /* unsupported runtime */ }
319
313
  // NOTE: `locking_mode = EXCLUSIVE` is intentionally NOT applied here.
320
- // Multi-writer scenarios are valid: `ContentStore` (FTS5 shared
321
- // knowledge base) is opened concurrently from multiple sessions on the
322
- // SAME db file by design applying EXCLUSIVE here would deadlock the
323
- // second instance and break documented `withRetry`-based BUSY handling.
324
- // Single-writer enforcement (lockfile + EXCLUSIVE) lives in
325
- // `SQLiteBase` ctor which is opted into by per-project DBs (SessionDB).
326
- // Different DB paths from different worktrees/sessions ARE concurrent
327
- // by design — the lockfile is per-dbPath, not per-process.
314
+ // ALL DBs built on this helper — ContentStore (FTS5 shared knowledge
315
+ // base) AND SessionDB (per-project events) are multi-writer-safe by
316
+ // contract. WAL + busy_timeout + the withRetry() wrapper below handle
317
+ // SQLITE_BUSY natively. EXCLUSIVE locking is opt-out, never opt-in
318
+ // from a base class shared by multi-writer consumers.
319
+ // See docs/adr/0001-sessiondb-multi-writer.md for the v1.0.130 ADR.
328
320
  }
329
321
  // ─────────────────────────────────────────────────────────
330
322
  // DB file helpers
@@ -465,26 +457,19 @@ export function renameCorruptDB(dbPath) {
465
457
  * re-imports within the same fork process (ESM isolate mode clears
466
458
  * module-level state but globalThis persists).
467
459
  */
468
- // v1.0.128 — symbol name versioned because the value type changed from
469
- // Set<DatabaseInstance> to Map<DatabaseInstance, string> (issue #560).
470
- // A persistent global slot from a pre-v128 module would deserialize as
471
- // the wrong shape and crash the exit hook iteration.
472
- const _kLiveDBs = Symbol.for("__context_mode_live_dbs_v2__");
473
- // v1.0.128 issue #560: pair each DatabaseInstance with the dbPath that
474
- // owns its lockfile. The exit hook needs both — closeDB(db) handles the
475
- // WAL checkpoint, releaseDbLock(dbPath) drops the .lock file. We use a
476
- // Map keyed by DatabaseInstance to keep API call sites unchanged.
460
+ // v1.0.130 — symbol name bumped because the value type reverted from
461
+ // Map<DatabaseInstance, string> (v1.0.128 lockfile pairing) back to
462
+ // Set<DatabaseInstance>. A persistent global slot from a v1.0.128 or
463
+ // v1.0.129 module would deserialize as the wrong shape and crash the
464
+ // exit hook iteration.
465
+ const _kLiveDBs = Symbol.for("__context_mode_live_dbs_v3__");
477
466
  const _liveDBs = (() => {
478
467
  const g = globalThis;
479
468
  if (!g[_kLiveDBs]) {
480
- g[_kLiveDBs] = new Map();
469
+ g[_kLiveDBs] = new Set();
481
470
  process.on("exit", () => {
482
- for (const [db, dbPath] of g[_kLiveDBs]) {
471
+ for (const db of g[_kLiveDBs]) {
483
472
  closeDB(db);
484
- // Release lock AFTER close so the WAL checkpoint inside closeDB
485
- // runs while we still own the writer slot (no second-opener can
486
- // race in mid-checkpoint).
487
- releaseDbLock({ dbPath });
488
473
  }
489
474
  g[_kLiveDBs].clear();
490
475
  });
@@ -494,45 +479,34 @@ const _liveDBs = (() => {
494
479
  export class SQLiteBase {
495
480
  #dbPath;
496
481
  #db;
482
+ /**
483
+ * Open (or create) a SQLite DB at `dbPath`.
484
+ *
485
+ * v1.0.130 — multi-writer is the contract. ALL SQLiteBase consumers
486
+ * (SessionDB, ContentStore) may open the same on-disk dbPath from
487
+ * multiple processes simultaneously — that is the legitimate multi-
488
+ * window UX shape and the WAL handles it natively. SQLITE_BUSY on
489
+ * write contention is absorbed by `withRetry()` below (busy_timeout
490
+ * = 30000ms inside `new Database(...)`).
491
+ *
492
+ * v1.0.128 introduced a single-writer guard here as a defense against
493
+ * #560. That defense was an over-correction — the actual root causes
494
+ * of #560 were #559 (zombie MCP child accumulation) and #561 (Pi
495
+ * misdetection writing to the wrong DB path), both fixed in v1.0.128
496
+ * + v1.0.129. The single-writer guard broke legitimate multi-window
497
+ * users; v1.0.130 rolls it out. See
498
+ * docs/adr/0001-sessiondb-multi-writer.md and the v1.0.130 INVARIANT
499
+ * block in tests/util/db-base-platform-gate.test.ts for the
500
+ * regression-proof anchor (source-pin + behavioural).
501
+ */
497
502
  constructor(dbPath) {
498
503
  const Database = loadDatabase();
499
504
  this.#dbPath = dbPath;
500
- // v1.0.128 — Issue #560 PRIMARY single-writer guard. Must claim
501
- // BEFORE `new Database(...)` so a contending opener gets the clean
502
- // DatabaseLockedError UX (PID + verbatim message) instead of the
503
- // SQLITE_BUSY surfaced by EXCLUSIVE locking. Skip-gate via
504
- // tmpdir-prefix check inside the helper — defaultDBPath() output
505
- // (per-process tmp DBs) does not contend, so it never claims a lock.
506
- acquireDbLock({ dbPath });
507
- // v1.0.129 — single source of truth for skip decision. Both lockfile
508
- // (above) and EXCLUSIVE pragma (below) MUST share the same skip-gate:
509
- // tests open SessionDB twice on the same tmpdir path to exercise
510
- // multi-writer scenarios; if EXCLUSIVE fires here when lockfile didn't,
511
- // the second open hits SQLITE_BUSY on its FIRST pragma call inside
512
- // applyWALPragmas — caused 82 test failures during v1.0.128 verification.
513
- const skipExclusive = dbPath.startsWith(tmpdir());
514
505
  cleanOrphanedWALFiles(dbPath);
515
506
  let db;
516
507
  try {
517
508
  db = new Database(dbPath, { timeout: 30000 });
518
509
  applyWALPragmas(db);
519
- // v1.0.128 — Issue #560 SECONDARY defense for SQLiteBase callers (single-writer
520
- // DBs like SessionDB). The .lock file (acquireDbLock above) is PRIMARY — it
521
- // surfaces the conflicting PID with a clear UX message. EXCLUSIVE locking
522
- // closes the narrow race window between lockfile claim + the actual
523
- // `new Database(...)` open: a parallel process passing the lockfile
524
- // check would still get SQLITE_BUSY from this pragma. Wrapped in try/catch —
525
- // backends that don't expose locking_mode (or pragma at all) still get the
526
- // lockfile floor. NOTE: NOT applied in `applyWALPragmas` because multi-writer
527
- // callers (ContentStore — FTS5 shared knowledge base across sessions) rely
528
- // on the default SHARED locking mode + withRetry to handle SQLITE_BUSY.
529
- // Skip on tmpdir paths to match acquireDbLock's skip-gate.
530
- if (!skipExclusive) {
531
- try {
532
- db.pragma("locking_mode = EXCLUSIVE");
533
- }
534
- catch { /* unsupported runtime */ }
535
- }
536
510
  }
537
511
  catch (err) {
538
512
  const msg = err instanceof Error ? err.message : String(err);
@@ -542,27 +516,17 @@ export class SQLiteBase {
542
516
  try {
543
517
  db = new Database(dbPath, { timeout: 30000 });
544
518
  applyWALPragmas(db);
545
- if (!skipExclusive) {
546
- try {
547
- db.pragma("locking_mode = EXCLUSIVE");
548
- }
549
- catch { /* unsupported runtime */ }
550
- }
551
519
  }
552
520
  catch (retryErr) {
553
- // Free the lock before bubbling — caller can never reach
554
- // close()/cleanup() if the ctor throws.
555
- releaseDbLock({ dbPath });
556
521
  throw new Error(`Failed to create fresh DB after renaming corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
557
522
  }
558
523
  }
559
524
  else {
560
- releaseDbLock({ dbPath });
561
525
  throw err;
562
526
  }
563
527
  }
564
528
  this.#db = db;
565
- _liveDBs.set(this.#db, dbPath);
529
+ _liveDBs.add(this.#db);
566
530
  this.initSchema();
567
531
  this.prepareStatements();
568
532
  }
@@ -578,10 +542,6 @@ export class SQLiteBase {
578
542
  close() {
579
543
  _liveDBs.delete(this.#db);
580
544
  closeDB(this.#db);
581
- // v1.0.128 — Issue #560: drop the .lock file AFTER closeDB so the
582
- // WAL checkpoint inside closeDB completes while we still own the
583
- // writer slot. releaseDbLock is no-op for tmpdir paths (skip-gate).
584
- releaseDbLock({ dbPath: this.#dbPath });
585
545
  }
586
546
  withRetry(fn) {
587
547
  return withRetry(fn);
@@ -594,9 +554,5 @@ export class SQLiteBase {
594
554
  _liveDBs.delete(this.#db);
595
555
  closeDB(this.#db);
596
556
  deleteDBFiles(this.#dbPath);
597
- // v1.0.128 — Issue #560: also drop the lockfile during cleanup. Per-
598
- // process tmp DBs (defaultDBPath()) skip-gate inside the helper, so
599
- // this is only a side-effect for shared on-disk content stores.
600
- releaseDbLock({ dbPath: this.#dbPath });
601
557
  }
602
558
  }
@@ -1256,7 +1256,16 @@ function renderNarrative5Section(args) {
1256
1256
  const lifetimeLegacyTokens = lifetimeEventsTokens + lifetimeRescueTokens;
1257
1257
  const lifetimeRealTokens = realBytes?.lifetime?.totalSavedTokens ?? 0;
1258
1258
  const lifetimeTokensWithout = Math.max(lifetimeLegacyTokens, lifetimeRealTokens);
1259
- const lifetimeTokensWith = Math.max(1, Math.round(lifetimeTokensWithout * 0.02));
1259
+ // Lifetime "with" — measured when available, else legacy 0.02 fallback.
1260
+ // Honest definition (matches conversation bar below):
1261
+ // "with" = bytes_returned (what the model actually re-saw)
1262
+ // "without" = bytes_returned + bytes_avoided
1263
+ // When the schema has measurement, derive `with` from `bytes_returned/4`.
1264
+ const lifeRet = realBytes?.lifetime?.bytesReturned ?? 0;
1265
+ const lifeAv = realBytes?.lifetime?.bytesAvoided ?? 0;
1266
+ const lifetimeTokensWith = (lifeRet + lifeAv) > 0
1267
+ ? Math.max(1, Math.floor(lifeRet / 4))
1268
+ : Math.max(1, Math.round(lifetimeTokensWithout * 0.02));
1260
1269
  // Bytes from realBytes when present, else derive from tokens (×4 — same
1261
1270
  // ratio Phase 8 uses everywhere). All-work bytes drives the opener tally
1262
1271
  // + the section-3 receipt + section-4 cost example.
@@ -1328,15 +1337,32 @@ function renderNarrative5Section(args) {
1328
1337
  out.push(` Without that, you'd be re-explaining everything to a blank model right now.`);
1329
1338
  }
1330
1339
  out.push("");
1331
- // Without/With bars — the screenshottable proof for THIS conversation.
1332
- const convTokensWith = Math.max(1, Math.round(conversationTokens * 0.02));
1333
- const withoutBar = dataBar(conversationTokens, conversationTokens, 32);
1334
- const withBar = dataBar(convTokensWith, conversationTokens, 32);
1335
- const convPct = conversationTokens > 0 ? (1 - convTokensWith / conversationTokens) * 100 : 0;
1336
- out.push(` Without context-mode ${kb(convBytes).padStart(8)} ${withoutBar} ${fmtNum(conversationTokens).padStart(7)} tokens`);
1337
- out.push(` With context-mode ${kb(Math.max(1, Math.round(convBytes * 0.02))).padStart(8)} ${withBar} ${fmtNum(convTokensWith).padStart(7)} tokens`);
1338
- out.push(` ${convPct.toFixed(0)}% kept out of context · your AI ran ${Math.max(1, Math.round(conversationTokens / convTokensWith))}× longer before /compact fired`);
1339
- out.push("");
1340
+ // Without/With bars — measured from real per-event bytes_returned / bytes_avoided.
1341
+ //
1342
+ // Honest definitions:
1343
+ // Without = bytes the model WOULD have re-seen with no filtering = bytes_returned + bytes_avoided
1344
+ // With = bytes the model ACTUALLY re-saw after context-mode = bytes_returned
1345
+ //
1346
+ // No fallback to heuristic. If the schema has zero signal for this
1347
+ // conversation (no hook ever populated bytes_avoided / bytes_returned),
1348
+ // the section is skipped entirely. Honesty over decoration.
1349
+ const realConv = realBytes?.conversation;
1350
+ const measuredAvoided = realConv?.bytesAvoided ?? 0;
1351
+ const measuredReturned = realConv?.bytesReturned ?? 0;
1352
+ if (measuredAvoided + measuredReturned > 0) {
1353
+ const convBytesWithout = measuredReturned + measuredAvoided;
1354
+ const convBytesWith = Math.max(1, measuredReturned);
1355
+ const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
1356
+ const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
1357
+ const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);
1358
+ const withBar = dataBar(convTokensWith, convTokensWithout, 32);
1359
+ const convPct = (1 - convTokensWith / convTokensWithout) * 100;
1360
+ const convMult = Math.max(1, Math.round(convTokensWithout / convTokensWith));
1361
+ out.push(` Without context-mode ${kb(convBytesWithout).padStart(8)} ${withoutBar} ${fmtNum(convTokensWithout).padStart(7)} tokens`);
1362
+ out.push(` With context-mode ${kb(convBytesWith).padStart(8)} ${withBar} ${fmtNum(convTokensWith).padStart(7)} tokens`);
1363
+ out.push(` ${convPct.toFixed(0)}% kept out of context · your AI ran ${convMult}× longer before /compact fired`);
1364
+ out.push("");
1365
+ }
1340
1366
  // Timeline — drop-in if conversation has byDay.
1341
1367
  if (conversation.byDay && conversation.byDay.length > 0) {
1342
1368
  const totalConvDays = conversation.lastEventMs && conversation.firstEventMs