@mxalbert/context-mode 2.0.2 → 2.0.4

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/build/server.js CHANGED
@@ -21,7 +21,7 @@ import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime
21
21
  import { classifyNonZeroExit } from "./exit-classify.js";
22
22
  import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
23
23
  import { charSafePrefix } from "./truncate.js";
24
- import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
24
+ import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
25
25
  import { purgeSession } from "./session/purge.js";
26
26
  import { NPM_LATEST_URL, PACKAGE_NAME, PLUGIN_KEY } from "./package-identity.js";
27
27
  import { emitCacheHitEvent, emitIndexWriteEvent, emitSandboxExecuteEvent, } from "./session/event-emit.js";
@@ -370,8 +370,19 @@ process.on("exit", () => { try {
370
370
  unlinkSync(CM_FS_PRELOAD);
371
371
  }
372
372
  catch { /* best effort */ } });
373
- // Lazy singleton — no DB overhead unless index/search is used
374
- let _store = null;
373
+ // Lazy per-project store cache — no DB overhead unless index/search is used.
374
+ // Keyed by resolved dbPath: multi-project hosts (opencode web serves MANY
375
+ // projects inside ONE process, each plugin tool call arriving with its own
376
+ // projectDir via withProjectDirOverride) get one store per project. A single
377
+ // cached instance would funnel every project's reads/writes into whichever
378
+ // project ran the first ctx_* tool — cross-project knowledge-base exposure
379
+ // AND one long-lived connection whose files other processes' startup
380
+ // cleanups may unlink (surfaces as disk I/O error, SQLITE_IOERR_VNODE).
381
+ // Mirrors the #645 SessionDB singleton re-keying pattern applied to the
382
+ // Pi/OMP/OpenClaw plugins.
383
+ const _stores = new Map();
384
+ /** True once the process-wide startup cleanups have run (see getStore). */
385
+ let _startupCleanupsRan = false;
375
386
  /**
376
387
  * Build the FK-attribution object passed to every ContentStore.index*() call
377
388
  * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
@@ -434,27 +445,34 @@ export function resolveSessionIdFromSessionDB(opts) {
434
445
  }
435
446
  }
436
447
  /**
437
- * Auto-index session events files written by SessionStart hook.
438
- * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
439
- * CLAUDE_PROJECT_DIR is NOT available to MCP servers only to hooks —
440
- * so we glob-scan instead of computing a specific hash.
441
- * Files are consumed (deleted) after indexing to prevent double-indexing.
442
- * Called on every getStore() readdirSync is sub-millisecond when no files match.
448
+ * Auto-index the CURRENT project's session events file written by the
449
+ * SessionStart hook (same per-project path derivation the hook uses —
450
+ * canonical hash + worktree suffix). The file is consumed (deleted) after
451
+ * indexing to prevent double-indexing. Called on every getStore().
452
+ *
453
+ * Scoped to this project deliberately: the events filename is per-project,
454
+ * and a multi-project host (opencode web) shares one sessions directory
455
+ * across projects. The previous implementation glob-scanned the whole
456
+ * directory, so whichever project ran the first ctx_* tool indexed — and
457
+ * consumed — every OTHER project's pending events into its own store.
443
458
  */
444
459
  function maybeIndexSessionEvents(store) {
445
460
  try {
446
461
  const sessionsDir = getSessionDir();
447
462
  if (!existsSync(sessionsDir))
448
463
  return;
449
- const files = readdirSync(sessionsDir).filter(f => f.endsWith("-events.md"));
450
- for (const file of files) {
451
- const filePath = join(sessionsDir, file);
452
- try {
453
- store.index({ path: filePath, source: "session-events", attribution: currentAttribution() });
454
- unlinkSync(filePath);
455
- }
456
- catch { /* best-effort per file */ }
464
+ const eventsPath = resolveSessionPath({
465
+ projectDir: getProjectDir(),
466
+ sessionsDir,
467
+ ext: "-events.md",
468
+ });
469
+ if (!existsSync(eventsPath))
470
+ return;
471
+ try {
472
+ store.index({ path: eventsPath, source: "session-events", attribution: currentAttribution() });
473
+ unlinkSync(eventsPath);
457
474
  }
475
+ catch { /* best-effort */ }
458
476
  }
459
477
  catch { /* best-effort — session continuity never blocks tools */ }
460
478
  }
@@ -632,19 +650,27 @@ function getStorePath() {
632
650
  return resolveContentStorePath({ projectDir: getProjectDir(), contentDir: dir });
633
651
  }
634
652
  function getStore() {
635
- if (!_store) {
636
- // Content DB cleanup on fresh start is handled by SessionStart hook.
637
- // Server just opens whatever DB exists (or creates new if hook deleted it).
638
- const dbPath = getStorePath();
639
- _store = new ContentStore(dbPath);
653
+ // Opens whatever DB exists (or creates a new one). Routine retention
654
+ // never deletes per-platform DB FILES it is row-level only
655
+ // (cleanupStaleSources below) and on-disk reclamation is ctx_purge's
656
+ // job (rationale in the startup block below). The only file-deleting
657
+ // exception is the constructor's corruption-recovery path.
658
+ const dbPath = getStorePath();
659
+ let store = _stores.get(dbPath);
660
+ if (!store) {
661
+ store = new ContentStore(dbPath);
662
+ _stores.set(dbPath, store);
640
663
  // Wire deny-policy hook: store re-checks the Read deny list before
641
664
  // re-reading any file_path during auto-refresh. Catches policy edits
642
- // made after a file was originally indexed. See #442 round-3.
643
- _store.setDenyChecker((filePath) => {
665
+ // made after a file was originally indexed. See #442 round-3. The
666
+ // project dir is pinned at creation — the store is project-scoped, so
667
+ // its deny policy stays this project's even when other projects make
668
+ // calls into the same process.
669
+ const storeProjectDir = getProjectDir();
670
+ store.setDenyChecker((filePath) => {
644
671
  try {
645
- const projectDir = getProjectDir();
646
- const denyGlobs = readToolDenyPatterns("Read", projectDir);
647
- const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
672
+ const denyGlobs = readToolDenyPatterns("Read", storeProjectDir);
673
+ const r = evaluateFilePath(filePath, denyGlobs, undefined, storeProjectDir);
648
674
  return r.denied;
649
675
  }
650
676
  catch {
@@ -652,22 +678,36 @@ function getStore() {
652
678
  return true;
653
679
  }
654
680
  });
655
- // One-time startup cleanup: remove stale content DBs (>14 days)
681
+ // Per-store: drop this project's sources untouched for 14 days.
656
682
  try {
657
- const contentDir = dirname(getStorePath());
658
- cleanupStaleContentDBs(contentDir, 14);
659
- _store.cleanupStaleSources(14);
660
- // Also clean legacy shared dir from before platform isolation
661
- const legacyDir = join(homedir(), ".context-mode", "content");
662
- if (existsSync(legacyDir))
663
- cleanupStaleContentDBs(legacyDir, 0);
683
+ store.cleanupStaleSources(14);
664
684
  }
665
685
  catch { /* best-effort */ }
666
- // Also clean old PID-based DBs from migration
667
- cleanupStaleDBs();
686
+ // Process-wide, once: sweep the legacy shared content dir (pre-platform-
687
+ // isolation) and orphaned PID-based tmpdir DBs from the migration. Runs
688
+ // on FIRST store creation only — not once per project.
689
+ //
690
+ // The per-platform content dir gets NO automatic file deletion: mtime
691
+ // cannot distinguish a long-idle LIVE connection (a web host with a
692
+ // project open but untouched for weeks) from an abandoned DB, and
693
+ // unlinking an open store's db/-wal/-shm surfaces as unrecoverable
694
+ // disk I/O errors (SQLITE_IOERR_VNODE on macOS). Retention inside
695
+ // each store is handled logically above (cleanupStaleSources);
696
+ // on-disk reclamation is ctx_purge's job.
697
+ if (!_startupCleanupsRan) {
698
+ _startupCleanupsRan = true;
699
+ try {
700
+ const legacyDir = join(homedir(), ".context-mode", "content");
701
+ if (existsSync(legacyDir))
702
+ cleanupStaleContentDBs(legacyDir, 0);
703
+ }
704
+ catch { /* best-effort */ }
705
+ // Also clean old PID-based DBs from migration
706
+ cleanupStaleDBs();
707
+ }
668
708
  }
669
- maybeIndexSessionEvents(_store);
670
- return _store;
709
+ maybeIndexSessionEvents(store);
710
+ return store;
671
711
  }
672
712
  // ─────────────────────────────────────────────────────────
673
713
  // Session stats — track context consumption per tool
@@ -1099,7 +1139,7 @@ function checkFilePathDenyPolicy(filePath, toolName) {
1099
1139
  try {
1100
1140
  const projectDir = getProjectDir();
1101
1141
  const denyGlobs = readToolDenyPatterns("Read", projectDir);
1102
- const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
1142
+ const result = evaluateFilePath(filePath, denyGlobs, undefined, projectDir);
1103
1143
  if (result.denied) {
1104
1144
  return trackResponse(toolName, {
1105
1145
  content: [{
@@ -2087,10 +2127,9 @@ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
2087
2127
  const store = getStore();
2088
2128
  const projectDir = getProjectDir();
2089
2129
  const denyGlobs = readToolDenyPatterns("Read", projectDir);
2090
- const isWin32 = process.platform === "win32";
2091
2130
  const perFileDeny = (absPath) => {
2092
2131
  try {
2093
- return evaluateFilePath(absPath, denyGlobs, isWin32, projectDir).denied;
2132
+ return evaluateFilePath(absPath, denyGlobs, undefined, projectDir).denied;
2094
2133
  }
2095
2134
  catch {
2096
2135
  return false; // fail-open consistent with checkFilePathDenyPolicy
@@ -4629,12 +4668,17 @@ EXAMPLE: ctx_purge(confirm: true, scope: "project")`,
4629
4668
  storePathForPurge = getStorePath();
4630
4669
  }
4631
4670
  catch { /* best effort — store path may be unresolvable on fresh install */ }
4632
- if (_store) {
4633
- try {
4634
- _store.cleanup();
4671
+ if (storePathForPurge) {
4672
+ // Close ONLY the calling project's store — other projects' stores in a
4673
+ // multi-project host (opencode web) are neither purged nor disturbed.
4674
+ const openStore = _stores.get(storePathForPurge);
4675
+ if (openStore) {
4676
+ try {
4677
+ openStore.cleanup();
4678
+ }
4679
+ catch { /* best effort */ }
4680
+ _stores.delete(storePathForPurge);
4635
4681
  }
4636
- catch { /* best effort */ }
4637
- _store = null;
4638
4682
  }
4639
4683
  // FTS5 store: pass contentDir so purgeSession sweeps BOTH canonical
4640
4684
  // and legacy raw-casing variants (dual-hash, mirrors session events).
@@ -4906,8 +4950,15 @@ async function main() {
4906
4950
  // Clean up own DB + backgrounded processes + preload script on shutdown
4907
4951
  const shutdown = () => {
4908
4952
  executor.cleanupBackgrounded();
4909
- if (_store)
4910
- _store.close(); // persist DB for --continue sessions
4953
+ // Persist every open per-project DB for --continue sessions. Cleared
4954
+ // after close so a repeated shutdown call cannot double-close a store.
4955
+ for (const openStore of _stores.values()) {
4956
+ try {
4957
+ openStore.close();
4958
+ }
4959
+ catch { /* best effort */ }
4960
+ }
4961
+ _stores.clear();
4911
4962
  try {
4912
4963
  unlinkSync(CM_FS_PRELOAD);
4913
4964
  }
@@ -131,7 +131,8 @@ export function purgeSession(opts) {
131
131
  //
132
132
  // Caller is responsible for closing any persistent ContentStore
133
133
  // handle BEFORE invoking purgeSession (Windows file lock). The
134
- // ctx_purge handler does this via _store?.cleanup() before delegating.
134
+ // ctx_purge handler does this via the per-project store cache's
135
+ // cleanup() before delegating.
135
136
  const ftsTargets = [];
136
137
  if (storePath && existsSync(storePath))
137
138
  ftsTargets.push(storePath);
package/build/store.d.ts CHANGED
@@ -19,9 +19,28 @@ export declare function sanitizeTrigramQuery(query: string, mode?: "AND" | "OR")
19
19
  export declare function cleanupStaleDBs(): number;
20
20
  /**
21
21
  * Clean up stale per-project content store DBs older than maxAgeDays.
22
- * Scans the given directory for *.db files and checks mtime.
23
- * Also detects zombie processes holding WAL locksif a WAL file exists
24
- * but the owning PID is dead, the DB files are cleaned up regardless of age.
22
+ * Scans the given directory for *.db files and unlinks each (plus its
23
+ * -wal/-shm sidecars) when its EFFECTIVE last-write timethe newer of the
24
+ * main .db mtime and a non-empty -wal mtime exceeds the cutoff.
25
+ *
26
+ * Why the -wal mtime participates: in WAL mode commits touch only the -wal
27
+ * file; the main .db mtime advances at checkpoint. A main-only check can
28
+ * delete an actively-used store that simply hasn't checkpointed within the
29
+ * window. Conversely, a non-empty -wal older than some threshold is NOT
30
+ * proof the owning process is dead — a live-but-idle connection (a session
31
+ * quiet over lunch) is indistinguishable from a crashed process by mtime
32
+ * alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
33
+ * which on macOS unlinks the files under an open connection and surfaces
34
+ * as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
35
+ * of retrying recovers an invalidated fd.
36
+ *
37
+ * Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
38
+ * latest write), never shorten it. A WAL never triggers deletion on its
39
+ * own — the way the old 1-hour zombie rule did — and once the whole DB
40
+ * (main + non-empty wal) is beyond maxAgeDays, the caller's retention
41
+ * policy owns the consequences. Callers that cannot tolerate deleting a
42
+ * possibly-live DB (the per-platform content dir — see getStore() in
43
+ * src/server.ts) must not call this with a nonzero maxAgeDays.
25
44
  */
26
45
  export declare function cleanupStaleContentDBs(contentDir: string, maxAgeDays: number): number;
27
46
  export declare class ContentStore {
package/build/store.js CHANGED
@@ -158,24 +158,30 @@ export function cleanupStaleDBs() {
158
158
  catch { /* ignore readdir errors */ }
159
159
  return cleaned;
160
160
  }
161
- /**
162
- * Check if a PID is still alive (not a zombie holding a WAL lock).
163
- * Returns true if the process exists, false if it's dead.
164
- */
165
- function isProcessAlive(pid) {
166
- try {
167
- process.kill(pid, 0);
168
- return true;
169
- }
170
- catch {
171
- return false;
172
- }
173
- }
174
161
  /**
175
162
  * Clean up stale per-project content store DBs older than maxAgeDays.
176
- * Scans the given directory for *.db files and checks mtime.
177
- * Also detects zombie processes holding WAL locksif a WAL file exists
178
- * but the owning PID is dead, the DB files are cleaned up regardless of age.
163
+ * Scans the given directory for *.db files and unlinks each (plus its
164
+ * -wal/-shm sidecars) when its EFFECTIVE last-write timethe newer of the
165
+ * main .db mtime and a non-empty -wal mtime exceeds the cutoff.
166
+ *
167
+ * Why the -wal mtime participates: in WAL mode commits touch only the -wal
168
+ * file; the main .db mtime advances at checkpoint. A main-only check can
169
+ * delete an actively-used store that simply hasn't checkpointed within the
170
+ * window. Conversely, a non-empty -wal older than some threshold is NOT
171
+ * proof the owning process is dead — a live-but-idle connection (a session
172
+ * quiet over lunch) is indistinguishable from a crashed process by mtime
173
+ * alone. The pre-fix heuristic deleted such DBs unconditionally after 1h,
174
+ * which on macOS unlinks the files under an open connection and surfaces
175
+ * as disk I/O error (SQLITE_IOERR_VNODE) on every later write — no amount
176
+ * of retrying recovers an invalidated fd.
177
+ *
178
+ * Contract: a -wal can only EXTEND a DB's life (its mtime counts as the
179
+ * latest write), never shorten it. A WAL never triggers deletion on its
180
+ * own — the way the old 1-hour zombie rule did — and once the whole DB
181
+ * (main + non-empty wal) is beyond maxAgeDays, the caller's retention
182
+ * policy owns the consequences. Callers that cannot tolerate deleting a
183
+ * possibly-live DB (the per-platform content dir — see getStore() in
184
+ * src/server.ts) must not call this with a nonzero maxAgeDays.
179
185
  */
180
186
  export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
181
187
  let cleaned = 0;
@@ -187,26 +193,18 @@ export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
187
193
  for (const file of files) {
188
194
  try {
189
195
  const filePath = join(contentDir, file);
190
- const mtime = statSync(filePath).mtimeMs;
191
- let shouldClean = mtime < cutoff;
192
- // Detect zombie processes holding WAL locks:
193
- // If a WAL file exists, try to read the WAL header to extract the PID.
194
- // WAL files from dead processes can block new connections.
195
- if (!shouldClean) {
196
- const walPath = filePath + "-wal";
197
- if (existsSync(walPath)) {
198
- try {
199
- const walStat = statSync(walPath);
200
- // If WAL file is non-empty and DB hasn't been modified in >1 hour,
201
- // the owning process may be dead — check via mtime staleness
202
- if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
203
- shouldClean = true;
204
- }
205
- }
206
- catch { /* ignore WAL check errors */ }
196
+ // Effective last-write: newer of main .db and non-empty -wal. A fresh
197
+ // WAL is evidence of a live (or recently-live) connection — it can
198
+ // only push the cutoff later, never mark a fresh DB stale.
199
+ let effectiveMs = statSync(filePath).mtimeMs;
200
+ try {
201
+ const walStat = statSync(filePath + "-wal");
202
+ if (walStat.size > 0 && walStat.mtimeMs > effectiveMs) {
203
+ effectiveMs = walStat.mtimeMs;
207
204
  }
208
205
  }
209
- if (shouldClean) {
206
+ catch { /* no -wal — main mtime stands */ }
207
+ if (effectiveMs < cutoff) {
210
208
  for (const suffix of ["", "-wal", "-shm"]) {
211
209
  try {
212
210
  unlinkSync(filePath + suffix);