context-mode 1.0.133 → 1.0.134

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.133"
9
+ "version": "1.0.134"
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.133",
16
+ "version": "1.0.134",
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.133",
3
+ "version": "1.0.134",
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.133",
6
+ "version": "1.0.134",
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.133",
3
+ "version": "1.0.134",
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/server.d.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";
3
3
  import { ContentStore } from "./store.js";
4
+ /**
5
+ * Build the FK-attribution object passed to every ContentStore.index*() call
6
+ * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
7
+ * the current session — eventId stays undefined because MCP tool invocations
8
+ * are not paired with PostToolUse event rows at index time (the hook fires
9
+ * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
10
+ * legacy unattributed rows readable.
11
+ */
12
+ export declare function currentAttribution(): {
13
+ sessionId?: string;
14
+ } | undefined;
15
+ /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
16
+ export declare function resolveSessionIdFromSessionDB(opts?: {
17
+ projectDir?: string;
18
+ sessionsDir?: string;
19
+ bypassCache?: boolean;
20
+ }): string | undefined;
4
21
  /**
5
22
  * Parse FTS5 highlight markers to find match positions in the
6
23
  * original (marker-free) text. Returns character offsets into the
package/build/server.js CHANGED
@@ -29,7 +29,7 @@ import { getHookScriptPaths } from "./util/hook-config.js";
29
29
  import { resolveClaudeConfigDir } from "./util/claude-config.js";
30
30
  import { resolveProjectDir } from "./util/project-dir.js";
31
31
  import { loadDatabase } from "./db-base.js";
32
- import { AnalyticsEngine, formatReport, getConversationStats, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
32
+ import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
33
33
  const __pkg_dir = dirname(fileURLToPath(import.meta.url));
34
34
  const VERSION = (() => {
35
35
  for (const rel of ["../package.json", "./package.json"]) {
@@ -86,12 +86,56 @@ let _store = null;
86
86
  * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
87
87
  * legacy unattributed rows readable.
88
88
  */
89
- function currentAttribution() {
90
- const sessionId = process.env.CLAUDE_SESSION_ID;
89
+ export function currentAttribution() {
90
+ // CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
91
+ // Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
92
+ // and writes session_events via hooks. Read the most-recent session_id from
93
+ // THIS project's session DB. Works for claude-code/cursor/gemini-cli/codex/
94
+ // kiro/opencode/zed/kilo/openclaw/qwen-code/vscode-copilot/jetbrains-copilot/
95
+ // omp/pi/antigravity — no adapter-specific transcript path required.
96
+ const sessionId = process.env.CLAUDE_SESSION_ID ?? resolveSessionIdFromSessionDB();
91
97
  if (!sessionId)
92
98
  return undefined;
93
99
  return { sessionId };
94
100
  }
101
+ let __cachedSessionId;
102
+ /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
103
+ export function resolveSessionIdFromSessionDB(opts) {
104
+ // 2s cache — ctx_fetch_and_index can fire 5+ chunks/sec; DB open cost adds up.
105
+ const now = Date.now();
106
+ if (!opts?.bypassCache && __cachedSessionId && now - __cachedSessionId.checkedAt < 2000) {
107
+ return __cachedSessionId.sid;
108
+ }
109
+ try {
110
+ const projectDir = opts?.projectDir
111
+ ?? process.env.CLAUDE_PROJECT_DIR
112
+ ?? process.env.CONTEXT_MODE_PROJECT_DIR;
113
+ if (!projectDir)
114
+ return undefined;
115
+ const sessionsDir = opts?.sessionsDir ?? getSessionDir();
116
+ const dbPath = resolveSessionDbPath({ projectDir, sessionsDir });
117
+ if (!existsSync(dbPath))
118
+ return undefined;
119
+ const Database = loadDatabase();
120
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
121
+ try {
122
+ const row = db.prepare("SELECT session_id FROM session_events ORDER BY created_at DESC LIMIT 1").get();
123
+ const sid = row?.session_id;
124
+ if (sid)
125
+ __cachedSessionId = { sid, checkedAt: now };
126
+ return sid;
127
+ }
128
+ finally {
129
+ try {
130
+ db.close();
131
+ }
132
+ catch { /* best-effort */ }
133
+ }
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
95
139
  /**
96
140
  * Auto-index session events files written by SessionStart hook.
97
141
  * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
@@ -2622,7 +2666,21 @@ server.registerTool("ctx_stats", {
2622
2666
  // Render-time read-only — no DB mutation, no backfill.
2623
2667
  const contentDbPath = getStorePath();
2624
2668
  const convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
2625
- const lifeReal = getRealBytesStats({ sessionsDir: getSessionDir() });
2669
+ const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
2670
+ // v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
2671
+ // session_id filter). Without this fold, lifetime "kept out"
2672
+ // only counts session_events.bytes_avoided and ignores the
2673
+ // bulk of indexed payload across every prior conversation.
2674
+ const lifeContentBytes = getContentBytesAllSessions(contentDbPath);
2675
+ const lifeReal = {
2676
+ ...lifeRealBase,
2677
+ contentBytes: lifeRealBase.contentBytes + lifeContentBytes,
2678
+ bytesAvoided: lifeRealBase.bytesAvoided + lifeContentBytes,
2679
+ totalSavedTokens: Math.floor((lifeRealBase.eventDataBytes
2680
+ + lifeRealBase.bytesAvoided
2681
+ + lifeContentBytes
2682
+ + lifeRealBase.snapshotBytes) / 4),
2683
+ };
2626
2684
  realBytes = { conversation: convReal, lifetime: lifeReal };
2627
2685
  }
2628
2686
  }
@@ -391,6 +391,24 @@ export interface RealBytesStats {
391
391
  export declare function getContentBytesForSession(sessionId: string, contentDbPath: string, opts?: {
392
392
  loadDatabase?: () => unknown;
393
393
  }): number;
394
+ /**
395
+ * v1.0.134 SLICE C — lifetime tier all-chunks aggregate.
396
+ *
397
+ * Sibling of {@link getContentBytesForSession} that omits the session_id
398
+ * filter so the lifetime tier sees every chunk in the content store —
399
+ * including legacy unattributed rows (sessionId === '') and chunks
400
+ * attributed to other adapters' sessions. Without this, the lifetime
401
+ * "kept out" headline only counts session_events.bytes_avoided and
402
+ * misses the bulk of indexed payload.
403
+ *
404
+ * Best-effort: returns 0 when the DB file is missing, the schema lacks
405
+ * the `chunks` table, or the query fails. Never throws — same contract
406
+ * as the rest of the analytics module so a corrupt content DB cannot
407
+ * crash ctx_stats.
408
+ */
409
+ export declare function getContentBytesAllSessions(contentDbPath: string, opts?: {
410
+ loadDatabase?: () => unknown;
411
+ }): number;
394
412
  /**
395
413
  * Compute real-bytes stats across one session, one project (worktree
396
414
  * filter), or every session on disk (lifetime).
@@ -750,6 +750,52 @@ export function getContentBytesForSession(sessionId, contentDbPath, opts) {
750
750
  return 0;
751
751
  }
752
752
  }
753
+ /**
754
+ * v1.0.134 SLICE C — lifetime tier all-chunks aggregate.
755
+ *
756
+ * Sibling of {@link getContentBytesForSession} that omits the session_id
757
+ * filter so the lifetime tier sees every chunk in the content store —
758
+ * including legacy unattributed rows (sessionId === '') and chunks
759
+ * attributed to other adapters' sessions. Without this, the lifetime
760
+ * "kept out" headline only counts session_events.bytes_avoided and
761
+ * misses the bulk of indexed payload.
762
+ *
763
+ * Best-effort: returns 0 when the DB file is missing, the schema lacks
764
+ * the `chunks` table, or the query fails. Never throws — same contract
765
+ * as the rest of the analytics module so a corrupt content DB cannot
766
+ * crash ctx_stats.
767
+ */
768
+ export function getContentBytesAllSessions(contentDbPath, opts) {
769
+ if (!contentDbPath)
770
+ return 0;
771
+ if (!existsSync(contentDbPath))
772
+ return 0;
773
+ let DatabaseCtor = null;
774
+ try {
775
+ DatabaseCtor = opts?.loadDatabase
776
+ ? opts.loadDatabase()
777
+ : loadDatabaseImpl();
778
+ }
779
+ catch {
780
+ return 0;
781
+ }
782
+ if (!DatabaseCtor)
783
+ return 0;
784
+ try {
785
+ const db = new DatabaseCtor(contentDbPath, { readonly: true });
786
+ try {
787
+ const row = db.prepare(`SELECT COALESCE(SUM(LENGTH(content) + LENGTH(title)), 0) AS bytes
788
+ FROM chunks`).get();
789
+ return Number(row?.bytes ?? 0);
790
+ }
791
+ finally {
792
+ db.close();
793
+ }
794
+ }
795
+ catch {
796
+ return 0;
797
+ }
798
+ }
753
799
  /**
754
800
  * Compute real-bytes stats across one session, one project (worktree
755
801
  * filter), or every session on disk (lifetime).
@@ -1044,6 +1090,21 @@ export function getMultiAdapterRealBytesStats(opts) {
1044
1090
  worktreeHash: opts?.worktreeHash,
1045
1091
  loadDatabase: opts?.loadDatabase,
1046
1092
  });
1093
+ // ARCH-REVIEW-V134-ABC SLICE C: aggregate this adapter's content DB
1094
+ // bytes into the lifetime sum. `getRealBytesStats` operates on
1095
+ // session events only and never touches the sibling content/ tree —
1096
+ // without this step the lifetime tier in ctx_stats reports 0 for
1097
+ // every adapter except whichever one happens to share the
1098
+ // sessionsDir of the caller. Lifetime tier ignores sessionId so
1099
+ // the all-sessions aggregator is the right helper here.
1100
+ if (!opts?.sessionId) {
1101
+ const contentDbPath = join(entry.contentDir, "content.db");
1102
+ const adapterContentBytes = getContentBytesAllSessions(contentDbPath, {
1103
+ loadDatabase: opts?.loadDatabase,
1104
+ });
1105
+ one.contentBytes += adapterContentBytes;
1106
+ sum.contentBytes += adapterContentBytes;
1107
+ }
1047
1108
  perAdapter.push({ name: entry.name, ...one });
1048
1109
  sum.eventDataBytes += one.eventDataBytes;
1049
1110
  sum.bytesAvoided += one.bytesAvoided;
@@ -1396,19 +1457,32 @@ function renderNarrative5Section(args) {
1396
1457
  out.push("");
1397
1458
  // Without/With bars — measured from real per-event bytes_returned / bytes_avoided.
1398
1459
  //
1399
- // Honest definitions:
1400
- // Without = bytes the model WOULD have re-seen with no filtering = bytes_returned + bytes_avoided
1401
- // With = bytes the model ACTUALLY re-saw after context-mode = bytes_returned
1460
+ // Honest definitions (v1.0.134 SLICE B — eventDataBytes floor):
1461
+ // Without = bytes the model WOULD have re-seen with no filtering
1462
+ // = bytes_avoided + bytes_returned + eventDataBytes
1463
+ // With = bytes the model ACTUALLY re-saw after context-mode
1464
+ // = bytes_returned + eventDataBytes
1465
+ //
1466
+ // Why eventDataBytes belongs on BOTH sides:
1467
+ // `eventDataBytes` is the raw payload captured by the hook (tool args,
1468
+ // prompt body, etc). Those bytes were "kept out" — never inflated back
1469
+ // into context — but they still represent real measured signal. Pre-fix
1470
+ // the formula was `with = max(1, bytesReturned)`, which collapsed to 1
1471
+ // whenever the conversation hadn't accumulated any re-served bytes yet
1472
+ // (early in a session, or for tool-heavy work that never re-hits index).
1473
+ // That produced a degenerate ~100% kept-out bar even when the only
1474
+ // honest signal we had was a few KB of event payloads.
1402
1475
  //
1403
1476
  // No fallback to heuristic. If the schema has zero signal for this
1404
- // conversation (no hook ever populated bytes_avoided / bytes_returned),
1477
+ // conversation (no hook ever populated any of the three columns),
1405
1478
  // the section is skipped entirely. Honesty over decoration.
1406
1479
  const realConv = realBytes?.conversation;
1407
1480
  const measuredAvoided = realConv?.bytesAvoided ?? 0;
1408
1481
  const measuredReturned = realConv?.bytesReturned ?? 0;
1409
- if (measuredAvoided + measuredReturned > 0) {
1410
- const convBytesWithout = measuredReturned + measuredAvoided;
1411
- const convBytesWith = Math.max(1, measuredReturned);
1482
+ const measuredEvent = realConv?.eventDataBytes ?? 0;
1483
+ if (measuredAvoided + measuredReturned + measuredEvent > 0) {
1484
+ const convBytesWithout = measuredAvoided + measuredReturned + measuredEvent;
1485
+ const convBytesWith = Math.max(1, measuredReturned + measuredEvent);
1412
1486
  const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
1413
1487
  const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
1414
1488
  const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);