context-mode 1.0.108 → 1.0.110

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.108"
9
+ "version": "1.0.110"
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.108",
16
+ "version": "1.0.110",
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.108",
3
+ "version": "1.0.110",
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.108",
6
+ "version": "1.0.110",
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.108",
3
+ "version": "1.0.110",
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",
package/build/cli.js CHANGED
@@ -672,21 +672,45 @@ async function upgrade() {
672
672
  if (detection.platform !== 'opencode' && detection.platform !== 'kilo') {
673
673
  // Rebuild native addons for current Node.js ABI (fixes #131)
674
674
  s.start("Rebuilding native addons");
675
- try {
676
- npmExecFile(["rebuild", "better-sqlite3"], {
677
- cwd: pluginRoot,
678
- stdio: "pipe",
679
- timeout: 60000,
680
- });
681
- s.stop(color.green("Native addons rebuilt"));
682
- changes.push("Rebuilt better-sqlite3 for current Node.js");
675
+ const bsqBindingPath = resolve(pluginRoot, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
676
+ // Skip rebuild when the binding from `npm install --production` is
677
+ // already present. Earlier code ran `npm rebuild better-sqlite3`
678
+ // unconditionally — its internal prebuild-install spawn raced with
679
+ // the prior install's tree-prune, intermittently failing to resolve
680
+ // `rc/index.js` and printing a scary "rebuild warning" even though
681
+ // the binding was healthy. Pre-check eliminates the race for the
682
+ // 99% case (binding survived install).
683
+ if (existsSync(bsqBindingPath)) {
684
+ s.stop(color.green("Native addons OK") + color.dim(" — binding present"));
685
+ changes.push("better-sqlite3 binding already present (no rebuild needed)");
683
686
  }
684
- catch (err) {
685
- const message = err instanceof Error ? err.message : String(err);
686
- s.stop(color.yellow("Native addon rebuild warning"));
687
- p.log.warn(color.yellow("better-sqlite3 rebuild issue") +
688
- ` ${message}` +
689
- color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`));
687
+ else {
688
+ // Binding actually missing delegate to the shared 3-layer heal
689
+ // (scripts/heal-better-sqlite3.mjs, PR #410) instead of raw
690
+ // `npm rebuild`. Single source of truth across postinstall +
691
+ // ensure-deps + cli upgrade. Layer A spawns prebuild-install
692
+ // directly via process.execPath, bypassing PATH/MSVC and the
693
+ // npm-internal rc-resolution race that bit `npm rebuild`.
694
+ try {
695
+ const healUrl = pathToFileURL(resolve(pluginRoot, "scripts", "heal-better-sqlite3.mjs")).href;
696
+ const { healBetterSqlite3Binding } = await import(healUrl);
697
+ const result = healBetterSqlite3Binding(pluginRoot);
698
+ if (result?.healed) {
699
+ s.stop(color.green("Native addons healed") + color.dim(` (${result.reason})`));
700
+ changes.push(`Healed better-sqlite3 binding via ${result.reason}`);
701
+ }
702
+ else {
703
+ s.stop(color.yellow("Native addon heal needs manual step"));
704
+ p.log.warn(color.dim(` Run: cd "${pluginRoot}" && npm install better-sqlite3`));
705
+ }
706
+ }
707
+ catch (err) {
708
+ const message = err instanceof Error ? err.message : String(err);
709
+ s.stop(color.yellow("Native addon heal unavailable"));
710
+ p.log.warn(color.yellow("better-sqlite3 heal helper missing") +
711
+ ` — ${message}` +
712
+ color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`));
713
+ }
690
714
  }
691
715
  }
692
716
  // Update global npm
@@ -194,7 +194,10 @@ async function createContextModePlugin(ctx) {
194
194
  // Mutate output.args — OpenCode reads the mutated output object
195
195
  Object.assign(output.args, decision.updatedInput);
196
196
  }
197
- // "context" action no-op (OpenCode doesn't support context injection)
197
+ if (decision.action === "context" && decision.additionalContext) {
198
+ // Mutate output.args — OpenCode reads the mutated output object
199
+ output.args.additionalContext = decision.additionalContext;
200
+ }
198
201
  },
199
202
  // ── PostToolUse: Session event capture ──────────────
200
203
  "tool.execute.after": async (input, output) => {
package/build/server.js CHANGED
@@ -19,6 +19,7 @@ import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime
19
19
  import { classifyNonZeroExit } from "./exit-classify.js";
20
20
  import { startLifecycleGuard } from "./lifecycle.js";
21
21
  import { getWorktreeSuffix, SessionDB } from "./session/db.js";
22
+ import { persistToolCallCounter, restoreSessionStats } from "./session/persist-tool-calls.js";
22
23
  import { searchAllSources } from "./search/unified.js";
23
24
  import { buildNodeCommand } from "./adapters/types.js";
24
25
  import { detectPlatform, getSessionDirSegments } from "./adapters/detect.js";
@@ -169,6 +170,15 @@ function hashProjectDir() {
169
170
  const normalized = projectDir.replace(/\\/g, "/");
170
171
  return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
171
172
  }
173
+ /**
174
+ * Resolve the per-project SessionDB path the way 4742160 originally did
175
+ * for `persistToolCallCounter`. Centralized so the write-back, the
176
+ * restore-on-startup, and any future SessionDB consumer all hash to the
177
+ * same file under worktree isolation.
178
+ */
179
+ function getSessionDbPath() {
180
+ return join(getSessionDir(), `${hashProjectDir()}${getWorktreeSuffix()}.db`);
181
+ }
172
182
  /**
173
183
  * Compute a per-project, per-platform persistent path for the ContentStore.
174
184
  * Derives content dir from the adapter's session dir so each platform
@@ -351,10 +361,13 @@ function trackResponse(toolName, response) {
351
361
  // Persist a sidecar JSON snapshot for the statusline — read at ~3-5 Hz by
352
362
  // bin/statusline.mjs (and any external dashboard) so they don't have to
353
363
  // open the SQLite database. Throttled inside persistStats() (500ms) so
354
- // it's safe to call on every response. The b392c2f concurrency refactor
355
- // dropped the SessionDB tool-call counter (`persistToolCallCounter`); we
356
- // keep persistStats here because the statusline depends on it.
364
+ // it's safe to call on every response.
357
365
  persistStats();
366
+ // Persist to SessionDB so counters survive process restart, --continue,
367
+ // upgrade. Re-introduces the write path 4742160 added and b392c2f dropped.
368
+ // setImmediate keeps this off the response hot path; the helper itself
369
+ // is best-effort (never throws).
370
+ setImmediate(() => persistToolCallCounter(getSessionDbPath(), toolName, bytes));
358
371
  return response;
359
372
  }
360
373
  function trackIndexed(bytes) {
@@ -2823,6 +2836,28 @@ async function main() {
2823
2836
  }
2824
2837
  }
2825
2838
  catch { /* best effort — _detectedAdapter stays null, falls back to .claude */ }
2839
+ // Restore tool-call counters from SessionDB BEFORE the heartbeat fires
2840
+ // so the very first persistStats() carries the prior PID's totals into
2841
+ // the sidecar JSON the statusline reads. Otherwise `/ctx-upgrade` flashes
2842
+ // `0 calls / $0.00` until the user makes another MCP tool call. Wrapped
2843
+ // in try/catch — a stats-restore failure must never block server startup.
2844
+ try {
2845
+ const restored = restoreSessionStats(getSessionDbPath());
2846
+ if (restored) {
2847
+ for (const [tool, count] of Object.entries(restored.calls)) {
2848
+ sessionStats.calls[tool] = count;
2849
+ }
2850
+ for (const [tool, bytes] of Object.entries(restored.bytesReturned)) {
2851
+ sessionStats.bytesReturned[tool] = bytes;
2852
+ }
2853
+ // Anchor uptime_ms to the original session start so `/ctx-upgrade`
2854
+ // doesn't reset the "session age" the statusline shows.
2855
+ if (restored.sessionStart > 0) {
2856
+ sessionStats.sessionStart = restored.sessionStart;
2857
+ }
2858
+ }
2859
+ }
2860
+ catch { /* best effort — never block startup on a stats restore failure */ }
2826
2861
  // Non-blocking version check — result stored for trackResponse warnings.
2827
2862
  // First fetch at startup, then refresh every hour so long-running sessions
2828
2863
  // (some users keep the MCP server alive 24h+) catch new releases without a
@@ -185,6 +185,13 @@ export interface LifetimeStats {
185
185
  autoMemoryProjects: number;
186
186
  /** Per-prefix breakdown of auto-memory files (user/feedback/project/...). */
187
187
  autoMemoryByPrefix: Record<string, number>;
188
+ /**
189
+ * Per-category event counts aggregated across every SessionDB on disk.
190
+ * Keys are the raw category strings (file/cwd/rule/...) — the renderer
191
+ * looks them up against `categoryLabels` for display. Empty `{}` when no
192
+ * sidecar has any events. Optional for back-compat with older fixtures.
193
+ */
194
+ categoryCounts: Record<string, number>;
188
195
  }
189
196
  /**
190
197
  * Aggregate lifetime stats from all SessionDB files in `sessionsDir` and
@@ -332,6 +332,7 @@ export function getLifetimeStats(opts) {
332
332
  ?? join(homedir(), ".claude", "projects");
333
333
  let totalEvents = 0;
334
334
  let totalSessions = 0;
335
+ const categoryCounts = {};
335
336
  // ── SessionDB aggregation ──
336
337
  if (existsSync(sessionsDir)) {
337
338
  let dbFiles = [];
@@ -358,6 +359,20 @@ export function getLifetimeStats(opts) {
358
359
  const ss = sdb.prepare("SELECT COUNT(*) AS cnt FROM session_meta").get();
359
360
  totalEvents += ev?.cnt ?? 0;
360
361
  totalSessions += ss?.cnt ?? 0;
362
+ // Per-category aggregation across every sidecar so the
363
+ // Persistent memory bars stay populated even when the
364
+ // current project's local DB is fresh / empty.
365
+ try {
366
+ const catRows = sdb.prepare("SELECT category, COUNT(*) AS cnt FROM session_events GROUP BY category").all();
367
+ for (const row of catRows) {
368
+ if (!row.category)
369
+ continue;
370
+ categoryCounts[row.category] = (categoryCounts[row.category] ?? 0) + (row.cnt ?? 0);
371
+ }
372
+ }
373
+ catch {
374
+ // older schema / no category column — ignore
375
+ }
361
376
  }
362
377
  finally {
363
378
  sdb.close();
@@ -414,6 +429,7 @@ export function getLifetimeStats(opts) {
414
429
  autoMemoryCount,
415
430
  autoMemoryProjects,
416
431
  autoMemoryByPrefix,
432
+ categoryCounts,
417
433
  };
418
434
  }
419
435
  // ─────────────────────────────────────────────────────────
@@ -503,7 +519,25 @@ function renderProjectMemory(pm, opts) {
503
519
  const lifetimeTokens = lifeEvents * 256 + sessionTokensSaved;
504
520
  out.push(` ${fmtNum(lifeEvents)} events · ${sessionLabel} · ~${tokensToUsd(lifetimeTokens)} saved lifetime`);
505
521
  out.push("");
506
- const cats = pm.by_category;
522
+ // Prefer lifetime categoryCounts (aggregated across every SessionDB) so
523
+ // the bar block matches the lifetime header above. Falls back to the
524
+ // project-local pm.by_category when lifetime data is absent (tests, older
525
+ // callers) or when no sidecar has any events yet.
526
+ const lifetimeCats = opts?.lifetime?.categoryCounts;
527
+ let cats;
528
+ if (lifetimeCats && Object.keys(lifetimeCats).length > 0) {
529
+ cats = Object.entries(lifetimeCats)
530
+ .filter(([, c]) => c > 0)
531
+ .map(([category, count]) => ({
532
+ category,
533
+ count,
534
+ label: categoryLabels[category] || category,
535
+ }))
536
+ .sort((a, b) => b.count - a.count);
537
+ }
538
+ else {
539
+ cats = pm.by_category;
540
+ }
507
541
  const visible = cats.slice(0, topN);
508
542
  const maxCount = visible.length > 0 ? visible[0].count : 1;
509
543
  for (const cat of visible) {
@@ -529,8 +563,11 @@ function renderAutoMemory(lifetime) {
529
563
  const entries = Object.entries(lifetime.autoMemoryByPrefix)
530
564
  .sort((a, b) => b[1] - a[1])
531
565
  .slice(0, 6);
566
+ // Top entry sets the bar scale so the visual stays proportional even when
567
+ // the absolute counts are tiny. Entries are pre-sorted desc.
568
+ const maxCount = entries.length > 0 ? entries[0][1] : 1;
532
569
  for (const [prefix, count] of entries) {
533
- out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)}`);
570
+ out.push(` ${prefix.padEnd(12)} ${String(count).padStart(2)} ${dataBar(count, maxCount, 20)}`);
534
571
  }
535
572
  return out;
536
573
  }
@@ -77,7 +77,9 @@ export declare class SessionDB extends SQLiteBase {
77
77
  * Eviction: if session exceeds MAX_EVENTS_PER_SESSION, evicts the
78
78
  * lowest-priority (then oldest) event.
79
79
  */
80
- insertEvent(sessionId: string, event: SessionEvent, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
80
+ insertEvent(sessionId: string, event: Omit<SessionEvent, "data_hash"> & {
81
+ data_hash?: string;
82
+ }, sourceHook?: string, attribution?: Partial<ProjectAttribution>): void;
81
83
  /**
82
84
  * Bulk-insert N events in a SINGLE transaction.
83
85
  *
@@ -0,0 +1,54 @@
1
+ /**
2
+ * persist-tool-calls — runtime glue between MCP server's in-memory
3
+ * `sessionStats` and the on-disk `tool_calls` SessionDB table.
4
+ *
5
+ * Why this module exists
6
+ * ──────────────────────
7
+ * Commit 4742160 (May 2 16:58) added the SessionDB write path so the
8
+ * statusline counters survived `npm update -g context-mode` and
9
+ * `claude --continue`. Commit b392c2f (May 2 21:43) — the concurrency
10
+ * refactor — silently dropped that wiring as collateral. Same-session
11
+ * `/ctx-upgrade` flips the statusline back to `0 calls / $0.00`
12
+ * because the new PID starts with an empty `sessionStats` and never
13
+ * looks at the table the old PID was writing to.
14
+ *
15
+ * This module re-introduces the write path AND adds the read-side
16
+ * restore that 4742160 never shipped — both pure helpers so the
17
+ * server.ts wiring is a one-liner and the unit tests don't need to
18
+ * boot the MCP server.
19
+ */
20
+ /**
21
+ * Shape returned by {@link restoreSessionStats}. Subset of the in-memory
22
+ * `sessionStats` object the MCP server keeps — only the fields that can
23
+ * be recovered from SessionDB.
24
+ */
25
+ export interface RestoredSessionStats {
26
+ /** Per-tool call counts. */
27
+ calls: Record<string, number>;
28
+ /** Per-tool returned bytes. */
29
+ bytesReturned: Record<string, number>;
30
+ /**
31
+ * Epoch-ms for `session_meta.started_at` of the latest session, so the
32
+ * statusline `uptime_ms` reflects the original session start instead of
33
+ * resetting to `Date.now()` on every PID change.
34
+ */
35
+ sessionStart: number;
36
+ }
37
+ /**
38
+ * Increment the persistent tool-call counter for `toolName` under whatever
39
+ * session_id `session_meta` currently treats as the most recent. This is
40
+ * called from {@link trackResponse} on every tool response and must be
41
+ * cheap, non-throwing, and best-effort — a stats failure must never break
42
+ * the MCP tool call.
43
+ */
44
+ export declare function persistToolCallCounter(sessionDbPath: string, toolName: string, bytes: number): void;
45
+ /**
46
+ * Read the latest session's tool-call totals back out of SessionDB so the
47
+ * MCP server can hydrate its in-memory `sessionStats` on startup. Returns
48
+ * `null` when the DB is missing or empty so the caller can keep the
49
+ * default zero-state without branching twice.
50
+ *
51
+ * Used during MCP server boot (BEFORE the heartbeat fires) so the
52
+ * statusline doesn't briefly flash `0 calls / $0.00` after upgrade.
53
+ */
54
+ export declare function restoreSessionStats(sessionDbPath: string): RestoredSessionStats | null;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * persist-tool-calls — runtime glue between MCP server's in-memory
3
+ * `sessionStats` and the on-disk `tool_calls` SessionDB table.
4
+ *
5
+ * Why this module exists
6
+ * ──────────────────────
7
+ * Commit 4742160 (May 2 16:58) added the SessionDB write path so the
8
+ * statusline counters survived `npm update -g context-mode` and
9
+ * `claude --continue`. Commit b392c2f (May 2 21:43) — the concurrency
10
+ * refactor — silently dropped that wiring as collateral. Same-session
11
+ * `/ctx-upgrade` flips the statusline back to `0 calls / $0.00`
12
+ * because the new PID starts with an empty `sessionStats` and never
13
+ * looks at the table the old PID was writing to.
14
+ *
15
+ * This module re-introduces the write path AND adds the read-side
16
+ * restore that 4742160 never shipped — both pure helpers so the
17
+ * server.ts wiring is a one-liner and the unit tests don't need to
18
+ * boot the MCP server.
19
+ */
20
+ import { existsSync } from "node:fs";
21
+ import { SessionDB } from "./db.js";
22
+ /**
23
+ * Increment the persistent tool-call counter for `toolName` under whatever
24
+ * session_id `session_meta` currently treats as the most recent. This is
25
+ * called from {@link trackResponse} on every tool response and must be
26
+ * cheap, non-throwing, and best-effort — a stats failure must never break
27
+ * the MCP tool call.
28
+ */
29
+ export function persistToolCallCounter(sessionDbPath, toolName, bytes) {
30
+ try {
31
+ if (!existsSync(sessionDbPath))
32
+ return;
33
+ const sdb = new SessionDB({ dbPath: sessionDbPath });
34
+ try {
35
+ const sid = sdb.getLatestSessionId();
36
+ if (!sid)
37
+ return;
38
+ sdb.incrementToolCall(sid, toolName, bytes);
39
+ }
40
+ finally {
41
+ sdb.close();
42
+ }
43
+ }
44
+ catch {
45
+ // Best-effort: counter must never throw and break the parent tool call.
46
+ }
47
+ }
48
+ /**
49
+ * Read the latest session's tool-call totals back out of SessionDB so the
50
+ * MCP server can hydrate its in-memory `sessionStats` on startup. Returns
51
+ * `null` when the DB is missing or empty so the caller can keep the
52
+ * default zero-state without branching twice.
53
+ *
54
+ * Used during MCP server boot (BEFORE the heartbeat fires) so the
55
+ * statusline doesn't briefly flash `0 calls / $0.00` after upgrade.
56
+ */
57
+ export function restoreSessionStats(sessionDbPath) {
58
+ try {
59
+ if (!existsSync(sessionDbPath))
60
+ return null;
61
+ const sdb = new SessionDB({ dbPath: sessionDbPath });
62
+ try {
63
+ const sid = sdb.getLatestSessionId();
64
+ if (!sid)
65
+ return null;
66
+ const stats = sdb.getToolCallStats(sid);
67
+ const calls = {};
68
+ const bytesReturned = {};
69
+ for (const [tool, row] of Object.entries(stats.byTool)) {
70
+ calls[tool] = row.calls;
71
+ bytesReturned[tool] = row.bytesReturned;
72
+ }
73
+ // started_at is "YYYY-MM-DD HH:MM:SS" in UTC (SQLite datetime() default);
74
+ // append "Z" so Date.parse interprets it as UTC, matching how the
75
+ // session was actually persisted.
76
+ let sessionStart = Date.now();
77
+ try {
78
+ const meta = sdb.getSessionStats(sid);
79
+ if (meta?.started_at) {
80
+ const parsed = Date.parse(`${meta.started_at}Z`);
81
+ if (Number.isFinite(parsed) && parsed > 0)
82
+ sessionStart = parsed;
83
+ }
84
+ }
85
+ catch {
86
+ // best-effort — keep `Date.now()` fallback
87
+ }
88
+ // Skip empty restores so callers can `if (restored)` and not stomp
89
+ // their already-zero default with another zero.
90
+ if (Object.keys(calls).length === 0 &&
91
+ Object.keys(bytesReturned).length === 0) {
92
+ // Still useful to return sessionStart so uptime_ms doesn't reset
93
+ // even when no tool calls were made — but only if we found a session.
94
+ return { calls, bytesReturned, sessionStart };
95
+ }
96
+ return { calls, bytesReturned, sessionStart };
97
+ }
98
+ finally {
99
+ sdb.close();
100
+ }
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
@@ -36,7 +36,7 @@ export declare const ATTRIBUTION_CONFIDENCE: {
36
36
  /** Fallback: session_origin without path signal */
37
37
  readonly FALLBACK_SESSION_ORIGIN: 0.35;
38
38
  };
39
- export type AttributionSource = "event_path" | "cwd_event" | "input_cwd" | "workspace_root" | "last_seen" | "session_origin" | "unknown";
39
+ export type AttributionSource = "event_path" | "cwd_event" | "input_cwd" | "workspace_root" | "last_seen" | "session_origin" | "env" | "test" | "unknown";
40
40
  export interface ProjectAttribution {
41
41
  projectDir: string;
42
42
  source: AttributionSource;