smart-context-mcp 0.8.0 → 1.0.1

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/README.md CHANGED
@@ -1,16 +1,78 @@
1
1
  # smart-context-mcp
2
2
 
3
- `smart-context-mcp` is an MCP server that reduces agent token usage and improves response quality with compact file summaries, ranked code search, and curated context.
3
+ [![npm version](https://badge.fury.io/js/smart-context-mcp.svg)](https://www.npmjs.com/package/smart-context-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- It exposes:
6
+ **MCP server that reduces AI agent token usage by 90% and improves response quality.**
6
7
 
7
- - `smart_read`: compact file summaries instead of full-file dumps
8
+ Instead of reading entire files and repeating context, this MCP provides 7 smart tools that compress, rank, and maintain context efficiently.
9
+
10
+ ## Why use this?
11
+
12
+ **Problem:** AI agents waste tokens reading full files, repeating context, and searching inefficiently.
13
+
14
+ **Solution:** This MCP reduces token usage by **~90%** in real projects while improving response quality.
15
+
16
+ **Real metrics from production use:**
17
+ - 14.5M tokens → 1.6M tokens (89.87% reduction)
18
+ - 3,666 successful calls across 7 tools
19
+ - Compression ratios: 3x to 46x depending on tool
20
+
21
+ ## Quick Start (2 commands)
22
+
23
+ ```bash
24
+ npm install smart-context-mcp
25
+ npx smart-context-init --target .
26
+ ```
27
+
28
+ That's it. Restart your AI client (Cursor, Codex, Claude Desktop) and the tools are available.
29
+
30
+ **Important:** The init command automatically sets the correct `cwd` (working directory) in the generated configs, so the MCP server runs from your project root. This works for standalone projects, monorepos, and nested workspaces.
31
+
32
+ ## What you get
33
+
34
+ Seven focused tools that work automatically:
35
+
36
+ - `smart_read`: compact file summaries instead of full file dumps (3x compression)
8
37
  - `smart_read_batch`: read multiple files in one call — reduces round-trip latency
9
- - `smart_search`: ripgrep-first code search with grouped, ranked results and intent-aware ranking
10
- - `smart_context`: one-call context planner that combines search + read + graph expansion
11
- - `smart_shell`: safe diagnostic shell execution with a restricted allowlist
38
+ - `smart_search`: ripgrep-first code search with intent-aware ranking (21x compression)
39
+ - `smart_context`: one-call context planner search + read + graph expansion
40
+ - `smart_summary`: maintain compressed conversation state across sessions (46x compression)
41
+ - `smart_shell`: safe diagnostic shell execution with restricted commands (18x compression)
12
42
  - `build_index`: lightweight symbol index for faster lookups and smarter ranking
13
43
 
44
+ **Strongest in:** Modern web/backend codebases (JS/TS, React, Next.js, Node.js, Python, Go, Rust), infra repos (Terraform, Docker, YAML)
45
+
46
+ ## Example: Before vs After
47
+
48
+ ### Without this MCP
49
+ ```
50
+ Agent: Let me read auth.js...
51
+ [Reads 4,000 tokens of full file]
52
+
53
+ Agent: Let me search for "jwt validation"...
54
+ [Returns 10,000 tokens of grep results]
55
+
56
+ Agent: [Next turn] What were we doing?
57
+ [Repeats 5,000 tokens of context]
58
+
59
+ Total: ~19,000 tokens
60
+ ```
61
+
62
+ ### With this MCP
63
+ ```
64
+ Agent: Let me use smart_read on auth.js...
65
+ [Returns 500 tokens of signatures]
66
+
67
+ Agent: Let me use smart_search for "jwt validation"...
68
+ [Returns 400 tokens of ranked snippets]
69
+
70
+ Agent: [Next turn] Let me get the context...
71
+ [smart_summary returns 100 tokens]
72
+
73
+ Total: ~1,000 tokens (95% reduction)
74
+ ```
75
+
14
76
  ## Quick start
15
77
 
16
78
  ```bash
@@ -109,7 +171,7 @@ After installing and running `smart-context-init`, each client picks up the serv
109
171
 
110
172
  ### Cursor
111
173
 
112
- Open the project in Cursor. The MCP server starts automatically. Enable it in **Cursor Settings > MCP** if needed. All six tools are available in Agent mode.
174
+ Open the project in Cursor. The MCP server starts automatically. Enable it in **Cursor Settings > MCP** if needed. All seven tools are available in Agent mode.
113
175
 
114
176
  ### Codex CLI
115
177
 
@@ -355,6 +417,92 @@ When using diff mode, the response includes a `diffSummary`:
355
417
  }
356
418
  ```
357
419
 
420
+ ### `smart_summary`
421
+
422
+ Maintain compressed conversation state across sessions. Solves the context-loss problem when resuming work after hours or days.
423
+
424
+ **Actions:**
425
+
426
+ | Action | Purpose | Returns |
427
+ |--------|---------|---------|
428
+ | `get` | Retrieve current or specified session | Resume summary (≤500 tokens) + compression metadata |
429
+ | `update` | Create or replace session | New session with compressed state |
430
+ | `append` | Add to existing session | Merged session state |
431
+ | `reset` | Clear session | Confirmation |
432
+ | `list_sessions` | Show all available sessions | Array of sessions with metadata |
433
+
434
+ **Parameters:**
435
+ - `action` (required) — one of the actions above
436
+ - `sessionId` (optional) — session identifier; auto-generated from `goal` if omitted
437
+ - `update` (required for update/append) — object with:
438
+ - `goal`: primary objective
439
+ - `status`: current state (`planning` | `in_progress` | `blocked` | `completed`)
440
+ - `pinnedContext`: critical context that should survive compression when possible
441
+ - `unresolvedQuestions`: open questions that matter for the next turn
442
+ - `currentFocus`: current work area in one short phrase
443
+ - `whyBlocked`: blocker summary when status is `blocked`
444
+ - `completed`: array of completed steps
445
+ - `decisions`: array of key decisions with rationale
446
+ - `blockers`: array of current blockers
447
+ - `nextStep`: immediate next action
448
+ - `touchedFiles`: array of modified files
449
+ - `maxTokens` (optional, default 500) — hard cap on summary size
450
+
451
+ `update` replaces the stored session state for that `sessionId`, so omitted fields are cleared. Use `append` when you want to keep existing state and add progress incrementally.
452
+
453
+ **Storage:**
454
+ - Sessions persist in `.devctx/sessions/<sessionId>.json`
455
+ - Active session tracked in `.devctx/sessions/active.json`
456
+ - 30-day retention for inactive sessions
457
+ - No expiration for active sessions
458
+
459
+ **Resume summary fields:**
460
+ - `status` and `nextStep` are preserved with highest priority
461
+ - `pinnedContext` and `unresolvedQuestions` preserve critical context and open questions
462
+ - `currentFocus` and `whyBlocked` are included when relevant
463
+ - `recentCompleted`, `keyDecisions`, and `hotFiles` are derived from the persisted state
464
+ - `completedCount`, `decisionsCount`, and `touchedFilesCount` preserve activity scale cheaply
465
+ - Empty fields are omitted to save tokens
466
+
467
+ **Response metadata:**
468
+ - `schemaVersion`: persisted session schema version
469
+ - `truncated`: whether the resume summary had to be compressed
470
+ - `compressionLevel`: `none` | `trimmed` | `reduced` | `status_only`
471
+ - `omitted`: fields dropped from the resume summary to fit the token budget
472
+
473
+ **Compression strategy:**
474
+ - Keeps the persisted session state intact and compresses only the resume summary
475
+ - Prioritizes `nextStep`, `status`, and active blockers over history
476
+ - Deduplicates repeated completed steps, decisions, and touched files
477
+ - Uses token-aware reduction until the summary fits `maxTokens`
478
+
479
+ **Example workflow:**
480
+
481
+ ```javascript
482
+ // Start of work session
483
+ smart_summary({ action: "get" })
484
+ // → retrieves last active session or returns "not found"
485
+
486
+ // After implementing auth middleware
487
+ smart_summary({
488
+ action: "append",
489
+ update: {
490
+ completed: ["auth middleware"],
491
+ decisions: ["JWT with 1h expiry, refresh tokens in Redis"],
492
+ touchedFiles: ["src/middleware/auth.js"],
493
+ nextStep: "add role-based access control"
494
+ }
495
+ })
496
+
497
+ // Monday after weekend - resume work
498
+ smart_summary({ action: "get" })
499
+ // → full context restored, continue from nextStep
500
+
501
+ // List all sessions
502
+ smart_summary({ action: "list_sessions" })
503
+ // → see all available sessions, pick one to resume
504
+ ```
505
+
358
506
  ### `build_index`
359
507
 
360
508
  - Builds a lightweight symbol index for the project (functions, classes, methods, types, etc.)
@@ -403,8 +551,9 @@ Metrics include: P@5, P@10, Recall, wrong-file rate, retrieval honesty, follow-u
403
551
  ## Notes
404
552
 
405
553
  - `@vscode/ripgrep` provides a bundled `rg` binary, so a system install is not required.
406
- - Metrics are written under `.devctx/metrics.jsonl` in the package root.
407
- - Symbol index stored in `.devctx/index.json` when `build_index` is used.
554
+ - Metrics are written to `<projectRoot>/.devctx/metrics.jsonl` (override with `DEVCTX_METRICS_FILE` env var).
555
+ - Symbol index stored in `<projectRoot>/.devctx/index.json` when `build_index` is used.
556
+ - Conversation sessions stored in `<projectRoot>/.devctx/sessions/` when `smart_summary` is used.
408
557
  - This package is a navigation and diagnostics layer, not a full semantic code intelligence system.
409
558
 
410
559
  ## Repository
@@ -412,3 +561,13 @@ Metrics include: P@5, P@10, Recall, wrong-file rate, retrieval honesty, follow-u
412
561
  Source repository and full project documentation:
413
562
 
414
563
  - https://github.com/Arrayo/devctx-mcp-mvp
564
+
565
+ ## Author
566
+
567
+ **Francisco Caballero Portero**
568
+ Email: fcp1978@hotmail.com
569
+ GitHub: [@Arrayo](https://github.com/Arrayo)
570
+
571
+ ## License
572
+
573
+ MIT License - see [LICENSE](LICENSE) file for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
- "version": "0.8.0",
3
+ "version": "1.0.1",
4
4
  "description": "MCP server that reduces agent token usage and improves response quality with compact file summaries, ranked code search, and curated context.",
5
5
  "author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
6
6
  "type": "module",
@@ -20,7 +20,7 @@ const parseArgs = (argv) => {
20
20
  const options = {
21
21
  target: process.cwd(),
22
22
  name: 'devctx',
23
- command: 'node',
23
+ command: process.execPath,
24
24
  args: null,
25
25
  clients: [...supportedClients],
26
26
  dryRun: false,
@@ -118,11 +118,12 @@ const writeFile = (filePath, content, dryRun) => {
118
118
  console.log(`updated ${filePath}`);
119
119
  };
120
120
 
121
- const getServerConfig = ({ name, command, args }) => ({
121
+ const getServerConfig = ({ name, command, args, cwd }) => ({
122
122
  name,
123
123
  config: {
124
124
  command,
125
125
  args,
126
+ ...(cwd ? { cwd } : {}),
126
127
  },
127
128
  });
128
129
 
@@ -162,6 +163,7 @@ const buildCodexSection = (serverConfig) => {
162
163
  'required = false',
163
164
  `command = ${JSON.stringify(serverConfig.config.command)}`,
164
165
  `args = [${serverConfig.config.args.map((value) => JSON.stringify(value)).join(', ')}]`,
166
+ ...(serverConfig.config.cwd ? [`cwd = ${JSON.stringify(serverConfig.config.cwd)}`] : []),
165
167
  'startup_timeout_sec = 15.0',
166
168
  'tool_timeout_sec = 30.0',
167
169
  ];
@@ -319,6 +321,7 @@ const main = () => {
319
321
  name: options.name,
320
322
  command: options.command,
321
323
  args,
324
+ cwd: targetDir,
322
325
  });
323
326
 
324
327
  const clientSet = new Set(options.clients);
@@ -90,6 +90,16 @@ const readEntries = (filePath) => {
90
90
  return { entries, invalidLines };
91
91
  };
92
92
 
93
+ const getCompressedTokens = (entry) => Number(entry.compressedTokens ?? entry.finalTokens ?? 0);
94
+
95
+ const getSavedTokens = (entry, compressedTokens) => {
96
+ if (entry.savedTokens !== undefined) {
97
+ return Number(entry.savedTokens ?? 0);
98
+ }
99
+
100
+ return Math.max(0, Number(entry.rawTokens ?? 0) - compressedTokens);
101
+ };
102
+
93
103
  const aggregate = (entries) => {
94
104
  const byTool = new Map();
95
105
  let rawTokens = 0;
@@ -98,6 +108,8 @@ const aggregate = (entries) => {
98
108
 
99
109
  for (const entry of entries) {
100
110
  const tool = entry.tool ?? 'unknown';
111
+ const compressedTokensForEntry = getCompressedTokens(entry);
112
+ const savedTokensForEntry = getSavedTokens(entry, compressedTokensForEntry);
101
113
  const current = byTool.get(tool) ?? {
102
114
  tool,
103
115
  count: 0,
@@ -108,13 +120,13 @@ const aggregate = (entries) => {
108
120
 
109
121
  current.count += 1;
110
122
  current.rawTokens += Number(entry.rawTokens ?? 0);
111
- current.compressedTokens += Number(entry.compressedTokens ?? 0);
112
- current.savedTokens += Number(entry.savedTokens ?? 0);
123
+ current.compressedTokens += compressedTokensForEntry;
124
+ current.savedTokens += savedTokensForEntry;
113
125
  byTool.set(tool, current);
114
126
 
115
127
  rawTokens += Number(entry.rawTokens ?? 0);
116
- compressedTokens += Number(entry.compressedTokens ?? 0);
117
- savedTokens += Number(entry.savedTokens ?? 0);
128
+ compressedTokens += compressedTokensForEntry;
129
+ savedTokens += savedTokensForEntry;
118
130
  }
119
131
 
120
132
  const tools = [...byTool.values()]
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ import { smartSearch } from './tools/smart-search.js';
8
8
  import { smartContext } from './tools/smart-context.js';
9
9
  import { smartReadBatch } from './tools/smart-read-batch.js';
10
10
  import { smartShell } from './tools/smart-shell.js';
11
+ import { smartSummary } from './tools/smart-summary.js';
11
12
  import { projectRoot, projectRootSource } from './utils/paths.js';
12
13
 
13
14
  const require = createRequire(import.meta.url);
@@ -120,6 +121,31 @@ export const createDevctxServer = () => {
120
121
  },
121
122
  );
122
123
 
124
+ server.tool(
125
+ 'smart_summary',
126
+ 'Maintain compressed conversation state across turns. Actions: get (retrieve current/last session), update (create or replace a session; omitted fields are cleared), append (add to existing session), reset (clear session), list_sessions (show all sessions). Sessions persist in .devctx/sessions/ with 30-day retention. Auto-generates sessionId from goal if not provided. Returns a resume summary capped at maxTokens (default 500) plus compression metadata (`truncated`, `compressionLevel`, `omitted`) and `schemaVersion`. Tracks: goal, status, pinned context, unresolved questions, current focus, blockers, next step, completed steps, key decisions, and touched files.',
127
+ {
128
+ action: z.enum(['get', 'update', 'append', 'reset', 'list_sessions']),
129
+ sessionId: z.string().optional(),
130
+ update: z.object({
131
+ goal: z.string().optional(),
132
+ status: z.enum(['planning', 'in_progress', 'blocked', 'completed']).optional(),
133
+ pinnedContext: z.array(z.string()).optional(),
134
+ unresolvedQuestions: z.array(z.string()).optional(),
135
+ currentFocus: z.string().optional(),
136
+ whyBlocked: z.string().optional(),
137
+ completed: z.array(z.string()).optional(),
138
+ decisions: z.array(z.string()).optional(),
139
+ blockers: z.array(z.string()).optional(),
140
+ nextStep: z.string().optional(),
141
+ touchedFiles: z.array(z.string()).optional(),
142
+ }).optional(),
143
+ maxTokens: z.number().int().min(100).max(2000).optional(),
144
+ },
145
+ async ({ action, sessionId, update, maxTokens }) =>
146
+ asTextResult(await smartSummary({ action, sessionId, update, maxTokens })),
147
+ );
148
+
123
149
  return server;
124
150
  };
125
151
 
@@ -0,0 +1,585 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { projectRoot } from '../utils/runtime-config.js';
4
+ import { countTokens } from '../tokenCounter.js';
5
+ import { persistMetrics } from '../metrics.js';
6
+
7
+ const MAX_SESSION_AGE_MS = 30 * 24 * 60 * 60 * 1000;
8
+ const DEFAULT_MAX_TOKENS = 500;
9
+ const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
10
+ const DEFAULT_STATUS = 'in_progress';
11
+ const SESSION_SCHEMA_VERSION = 2;
12
+
13
+ const getSessionsDir = () => path.join(projectRoot, '.devctx', 'sessions');
14
+ const getActiveSessionFile = () => path.join(getSessionsDir(), 'active.json');
15
+
16
+ const ensureSessionsDir = () => {
17
+ const sessionsDir = getSessionsDir();
18
+ if (!fs.existsSync(sessionsDir)) {
19
+ fs.mkdirSync(sessionsDir, { recursive: true });
20
+ }
21
+ };
22
+
23
+ const generateSessionId = (goal) => {
24
+ const date = new Date().toISOString().split('T')[0];
25
+ const slug = goal
26
+ ? goal.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30)
27
+ : 'session';
28
+ return `${date}-${slug}`;
29
+ };
30
+
31
+ const getSessionPath = (sessionId) => path.join(getSessionsDir(), `${sessionId}.json`);
32
+
33
+ const loadSession = (sessionId) => {
34
+ const sessionPath = getSessionPath(sessionId);
35
+ if (!fs.existsSync(sessionPath)) {
36
+ return null;
37
+ }
38
+ try {
39
+ const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
40
+ return data;
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const saveSession = (sessionId, data) => {
47
+ ensureSessionsDir();
48
+ const sessionPath = getSessionPath(sessionId);
49
+ const sessionData = {
50
+ ...data,
51
+ schemaVersion: SESSION_SCHEMA_VERSION,
52
+ sessionId,
53
+ updatedAt: new Date().toISOString(),
54
+ };
55
+ fs.writeFileSync(sessionPath, JSON.stringify(sessionData, null, 2), 'utf8');
56
+
57
+ const activeSessionFile = getActiveSessionFile();
58
+ fs.writeFileSync(activeSessionFile, JSON.stringify({ sessionId, updatedAt: sessionData.updatedAt }, null, 2), 'utf8');
59
+
60
+ return sessionData;
61
+ };
62
+
63
+ const getActiveSession = () => {
64
+ const activeSessionFile = getActiveSessionFile();
65
+ if (!fs.existsSync(activeSessionFile)) {
66
+ return null;
67
+ }
68
+ try {
69
+ const { sessionId } = JSON.parse(fs.readFileSync(activeSessionFile, 'utf8'));
70
+ const activeSession = loadSession(sessionId);
71
+ if (!activeSession) {
72
+ fs.unlinkSync(activeSessionFile);
73
+ return null;
74
+ }
75
+ return activeSession;
76
+ } catch {
77
+ try {
78
+ fs.unlinkSync(activeSessionFile);
79
+ } catch {}
80
+ return null;
81
+ }
82
+ };
83
+
84
+ const cleanupStaleSessions = () => {
85
+ ensureSessionsDir();
86
+ const sessionsDir = getSessionsDir();
87
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.json') && f !== 'active.json');
88
+ const now = Date.now();
89
+ let cleaned = 0;
90
+
91
+ const activeSession = getActiveSession();
92
+ const activeSessionId = activeSession?.sessionId;
93
+
94
+ for (const file of files) {
95
+ const sessionPath = path.join(sessionsDir, file);
96
+ try {
97
+ const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
98
+
99
+ if (data.sessionId === activeSessionId) {
100
+ continue;
101
+ }
102
+
103
+ const age = now - new Date(data.updatedAt).getTime();
104
+ if (age > MAX_SESSION_AGE_MS) {
105
+ fs.unlinkSync(sessionPath);
106
+ cleaned += 1;
107
+ }
108
+ } catch {
109
+ fs.unlinkSync(sessionPath);
110
+ cleaned += 1;
111
+ }
112
+ }
113
+
114
+ return cleaned;
115
+ };
116
+
117
+ const listSessions = () => {
118
+ ensureSessionsDir();
119
+ cleanupStaleSessions();
120
+
121
+ const sessionsDir = getSessionsDir();
122
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.json') && f !== 'active.json');
123
+ const now = Date.now();
124
+
125
+ return files
126
+ .map(file => {
127
+ const sessionPath = path.join(sessionsDir, file);
128
+ try {
129
+ const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
130
+ const age = now - new Date(data.updatedAt).getTime();
131
+ return {
132
+ sessionId: data.sessionId,
133
+ goal: data.goal,
134
+ status: data.status,
135
+ updatedAt: data.updatedAt,
136
+ ageMs: age,
137
+ isStale: age > MAX_SESSION_AGE_MS,
138
+ };
139
+ } catch {
140
+ return null;
141
+ }
142
+ })
143
+ .filter(Boolean)
144
+ .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
145
+ };
146
+
147
+ const truncateString = (str, maxLength) => {
148
+ if (!str || str.length <= maxLength) return str;
149
+ if (maxLength <= 3) return '';
150
+ return str.slice(0, maxLength - 3) + '...';
151
+ };
152
+
153
+ const normalizeStatus = (status, fallback = DEFAULT_STATUS) =>
154
+ VALID_STATUSES.has(status) ? status : fallback;
155
+
156
+ const isMeaningfulString = (value) => typeof value === 'string' && value.trim().length > 0;
157
+
158
+ const compactFilePath = (filePath) => {
159
+ if (!isMeaningfulString(filePath)) {
160
+ return filePath;
161
+ }
162
+
163
+ const normalized = filePath.replace(/\\/g, '/');
164
+ const parts = normalized.split('/').filter(Boolean);
165
+ if (parts.length <= 3 && normalized.length <= 60) {
166
+ return normalized;
167
+ }
168
+
169
+ const tail = parts.slice(-3).join('/');
170
+ return normalized.length <= tail.length ? normalized : `.../${tail}`;
171
+ };
172
+
173
+ const validateUpdateInput = (update) => {
174
+ if (!update || typeof update !== 'object') {
175
+ throw new Error('update parameter is required for update/append actions');
176
+ }
177
+
178
+ if (update.status !== undefined && !VALID_STATUSES.has(update.status)) {
179
+ throw new Error(`Invalid status: ${update.status}. Valid statuses: planning, in_progress, blocked, completed`);
180
+ }
181
+ };
182
+
183
+ const mergeUniqueStrings = (...lists) => {
184
+ const seen = new Set();
185
+ const result = [];
186
+
187
+ for (const list of lists) {
188
+ for (const item of list || []) {
189
+ if (!isMeaningfulString(item) || seen.has(item)) {
190
+ continue;
191
+ }
192
+ seen.add(item);
193
+ result.push(item);
194
+ }
195
+ }
196
+
197
+ return result;
198
+ };
199
+
200
+ const uniqueTail = (items, limit) => mergeUniqueStrings(items || []).slice(-limit);
201
+ const uniqueHead = (items, limit) => mergeUniqueStrings(items || []).slice(0, limit);
202
+
203
+ const buildSummaryMetrics = (rawTokens, finalTokens) => ({
204
+ rawTokens,
205
+ finalTokens,
206
+ compressedTokens: finalTokens,
207
+ savedTokens: Math.max(0, rawTokens - finalTokens),
208
+ });
209
+
210
+ const pruneEmptyFields = (value) =>
211
+ Object.fromEntries(
212
+ Object.entries(value).filter(([, item]) => {
213
+ if (item === undefined || item === null || item === '') {
214
+ return false;
215
+ }
216
+ if (Array.isArray(item) && item.length === 0) {
217
+ return false;
218
+ }
219
+ return true;
220
+ }),
221
+ );
222
+
223
+ const buildResumeSummary = (data) => {
224
+ const status = normalizeStatus(data.status);
225
+ const whyBlocked = status === 'blocked'
226
+ ? (isMeaningfulString(data.whyBlocked) ? data.whyBlocked : (data.blockers || []).find(isMeaningfulString))
227
+ : undefined;
228
+ const completed = mergeUniqueStrings(data.completed);
229
+ const decisions = mergeUniqueStrings(data.decisions);
230
+ const touchedFiles = mergeUniqueStrings(data.touchedFiles);
231
+
232
+ return pruneEmptyFields({
233
+ status,
234
+ nextStep: isMeaningfulString(data.nextStep) ? data.nextStep : undefined,
235
+ pinnedContext: uniqueHead(data.pinnedContext, 3),
236
+ unresolvedQuestions: uniqueHead(data.unresolvedQuestions, 3),
237
+ currentFocus: isMeaningfulString(data.currentFocus) ? data.currentFocus : undefined,
238
+ whyBlocked,
239
+ goal: isMeaningfulString(data.goal) ? data.goal : undefined,
240
+ recentCompleted: uniqueTail(completed, 3),
241
+ keyDecisions: uniqueTail(decisions, 2),
242
+ hotFiles: uniqueTail(touchedFiles.map(compactFilePath), 5),
243
+ completedCount: data.completedCount ?? completed.length,
244
+ decisionsCount: data.decisionsCount ?? decisions.length,
245
+ touchedFilesCount: data.touchedFilesCount ?? touchedFiles.length,
246
+ });
247
+ };
248
+
249
+ const compressSummary = (data, maxTokens) => {
250
+ const baseSummary = buildResumeSummary(data);
251
+ let compressed = baseSummary;
252
+ let summary = JSON.stringify(compressed, null, 2);
253
+ let tokens = countTokens(summary);
254
+
255
+ if (tokens <= maxTokens) {
256
+ return { compressed, tokens, truncated: false, omitted: [], compressionLevel: 'none' };
257
+ }
258
+
259
+ const recomputeTokens = () => {
260
+ compressed = pruneEmptyFields(compressed);
261
+ summary = JSON.stringify(compressed, null, 2);
262
+ tokens = countTokens(summary);
263
+ };
264
+
265
+ const shrinkScalarField = (field, { removable = true } = {}) => {
266
+ const value = compressed[field];
267
+ if (!isMeaningfulString(value)) {
268
+ return false;
269
+ }
270
+
271
+ if (value.length <= 12) {
272
+ if (!removable) {
273
+ return false;
274
+ }
275
+ delete compressed[field];
276
+ return true;
277
+ }
278
+
279
+ const next = truncateString(value, Math.max(4, Math.floor(value.length * 0.6)));
280
+ if (!next || next === value) {
281
+ if (!removable) {
282
+ return false;
283
+ }
284
+ delete compressed[field];
285
+ return true;
286
+ }
287
+
288
+ compressed[field] = next;
289
+ return true;
290
+ };
291
+
292
+ const shrinkArrayField = (field) => {
293
+ const value = compressed[field];
294
+ if (!Array.isArray(value) || value.length === 0) {
295
+ return false;
296
+ }
297
+
298
+ if (value.length > 1) {
299
+ compressed[field] = value.slice(-1);
300
+ return true;
301
+ }
302
+
303
+ const [item] = value;
304
+ if (!isMeaningfulString(item)) {
305
+ delete compressed[field];
306
+ return true;
307
+ }
308
+
309
+ if (item.length <= 12) {
310
+ delete compressed[field];
311
+ return true;
312
+ }
313
+
314
+ compressed[field] = [truncateString(item, Math.max(4, Math.floor(item.length * 0.6)))];
315
+ return true;
316
+ };
317
+
318
+ const reductionSteps = [
319
+ () => shrinkArrayField('recentCompleted'),
320
+ () => shrinkArrayField('keyDecisions'),
321
+ () => shrinkArrayField('hotFiles'),
322
+ () => shrinkArrayField('unresolvedQuestions'),
323
+ () => shrinkScalarField('goal'),
324
+ () => shrinkScalarField('currentFocus'),
325
+ () => shrinkScalarField('whyBlocked'),
326
+ () => shrinkArrayField('pinnedContext'),
327
+ () => shrinkScalarField('nextStep', { removable: false }),
328
+ ];
329
+
330
+ let madeProgress = true;
331
+
332
+ while (tokens > maxTokens && madeProgress) {
333
+ madeProgress = false;
334
+
335
+ for (const reduce of reductionSteps) {
336
+ if (!reduce()) {
337
+ continue;
338
+ }
339
+
340
+ recomputeTokens();
341
+ madeProgress = true;
342
+
343
+ if (tokens <= maxTokens) {
344
+ break;
345
+ }
346
+ }
347
+ }
348
+
349
+ if (tokens > maxTokens && isMeaningfulString(compressed.nextStep)) {
350
+ while (tokens > maxTokens && shrinkScalarField('nextStep')) {
351
+ recomputeTokens();
352
+ }
353
+ }
354
+
355
+ if (tokens > maxTokens) {
356
+ compressed = pruneEmptyFields({
357
+ status: normalizeStatus(data.status),
358
+ nextStep: isMeaningfulString(data.nextStep) ? data.nextStep : undefined,
359
+ pinnedContext: uniqueHead(data.pinnedContext, 1),
360
+ completedCount: data.completedCount ?? mergeUniqueStrings(data.completed).length,
361
+ decisionsCount: data.decisionsCount ?? mergeUniqueStrings(data.decisions).length,
362
+ touchedFilesCount: data.touchedFilesCount ?? mergeUniqueStrings(data.touchedFiles).length,
363
+ });
364
+ recomputeTokens();
365
+
366
+ while (tokens > maxTokens && isMeaningfulString(compressed.nextStep) && shrinkScalarField('nextStep')) {
367
+ recomputeTokens();
368
+ }
369
+ }
370
+
371
+ if (tokens > maxTokens) {
372
+ compressed = { status: normalizeStatus(data.status) };
373
+ recomputeTokens();
374
+ }
375
+
376
+ const omitted = Object.keys(baseSummary).filter((key) => !(key in compressed));
377
+ const compressionLevel = Object.keys(compressed).length === 1 && compressed.status
378
+ ? 'status_only'
379
+ : omitted.length > 0
380
+ ? 'reduced'
381
+ : 'trimmed';
382
+
383
+ return { compressed, tokens, truncated: true, omitted, compressionLevel };
384
+ };
385
+
386
+ export const smartSummary = async ({ action, sessionId, update, maxTokens = DEFAULT_MAX_TOKENS }) => {
387
+ const startTime = Date.now();
388
+
389
+ ensureSessionsDir();
390
+
391
+ if (action === 'list_sessions') {
392
+ const sessions = listSessions();
393
+ const activeSession = getActiveSession();
394
+
395
+ return {
396
+ action: 'list_sessions',
397
+ sessions,
398
+ activeSessionId: activeSession?.sessionId || null,
399
+ totalSessions: sessions.length,
400
+ staleSessions: sessions.filter(s => s.isStale).length,
401
+ };
402
+ }
403
+
404
+ if (action === 'get') {
405
+ const targetSessionId = sessionId || getActiveSession()?.sessionId;
406
+
407
+ if (!targetSessionId) {
408
+ return {
409
+ action: 'get',
410
+ sessionId: null,
411
+ found: false,
412
+ message: 'No active session found. Use action=update to create one.',
413
+ };
414
+ }
415
+
416
+ const session = loadSession(targetSessionId);
417
+
418
+ if (!session) {
419
+ return {
420
+ action: 'get',
421
+ sessionId: targetSessionId,
422
+ found: false,
423
+ message: 'Session not found.',
424
+ };
425
+ }
426
+
427
+ const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(session, maxTokens);
428
+
429
+ const rawTokens = countTokens(JSON.stringify(session));
430
+ const summaryMetrics = buildSummaryMetrics(rawTokens, tokens);
431
+
432
+ persistMetrics({
433
+ tool: 'smart_summary',
434
+ action: 'get',
435
+ sessionId: targetSessionId,
436
+ ...summaryMetrics,
437
+ latencyMs: Date.now() - startTime,
438
+ });
439
+
440
+ return {
441
+ action: 'get',
442
+ sessionId: targetSessionId,
443
+ found: true,
444
+ summary: compressed,
445
+ tokens,
446
+ truncated,
447
+ omitted,
448
+ compressionLevel,
449
+ schemaVersion: session.schemaVersion ?? 1,
450
+ updatedAt: session.updatedAt,
451
+ };
452
+ }
453
+
454
+ if (action === 'reset') {
455
+ const targetSessionId = sessionId || getActiveSession()?.sessionId;
456
+
457
+ if (!targetSessionId) {
458
+ return {
459
+ action: 'reset',
460
+ sessionId: null,
461
+ message: 'No session to reset.',
462
+ };
463
+ }
464
+
465
+ const activeSession = getActiveSession();
466
+ const isActiveSession = activeSession?.sessionId === targetSessionId;
467
+
468
+ const sessionPath = getSessionPath(targetSessionId);
469
+ if (fs.existsSync(sessionPath)) {
470
+ fs.unlinkSync(sessionPath);
471
+ }
472
+
473
+ if (isActiveSession) {
474
+ const activeSessionFile = getActiveSessionFile();
475
+ if (fs.existsSync(activeSessionFile)) {
476
+ fs.unlinkSync(activeSessionFile);
477
+ }
478
+ }
479
+
480
+ return {
481
+ action: 'reset',
482
+ sessionId: targetSessionId,
483
+ message: 'Session cleared.',
484
+ };
485
+ }
486
+
487
+ if (action === 'update' || action === 'append') {
488
+ validateUpdateInput(update);
489
+
490
+ let targetSessionId = sessionId;
491
+ let existingData = {};
492
+
493
+ if (!targetSessionId || targetSessionId === 'new') {
494
+ if (action === 'append') {
495
+ const activeSession = getActiveSession();
496
+ if (activeSession) {
497
+ targetSessionId = activeSession.sessionId;
498
+ existingData = activeSession;
499
+ } else {
500
+ targetSessionId = generateSessionId(update.goal);
501
+ }
502
+ } else {
503
+ targetSessionId = generateSessionId(update.goal);
504
+ }
505
+ } else {
506
+ const existing = loadSession(targetSessionId);
507
+ if (existing) {
508
+ existingData = existing;
509
+ }
510
+ }
511
+
512
+ const resolvedStatus = normalizeStatus(update.status, normalizeStatus(existingData.status));
513
+ const completed = action === 'append'
514
+ ? mergeUniqueStrings(existingData.completed, update.completed)
515
+ : mergeUniqueStrings(update.completed);
516
+ const decisions = action === 'append'
517
+ ? mergeUniqueStrings(existingData.decisions, update.decisions)
518
+ : mergeUniqueStrings(update.decisions);
519
+ const touchedFiles = action === 'append'
520
+ ? mergeUniqueStrings(existingData.touchedFiles, update.touchedFiles)
521
+ : mergeUniqueStrings(update.touchedFiles);
522
+ const mergedData = action === 'append'
523
+ ? {
524
+ goal: update.goal || existingData.goal || 'Untitled session',
525
+ status: resolvedStatus,
526
+ pinnedContext: mergeUniqueStrings(existingData.pinnedContext, update.pinnedContext),
527
+ unresolvedQuestions: mergeUniqueStrings(existingData.unresolvedQuestions, update.unresolvedQuestions),
528
+ currentFocus: update.currentFocus || existingData.currentFocus || '',
529
+ whyBlocked: update.whyBlocked || existingData.whyBlocked || '',
530
+ completed,
531
+ decisions,
532
+ blockers: update.blockers !== undefined ? mergeUniqueStrings(update.blockers) : (existingData.blockers || []),
533
+ nextStep: update.nextStep || existingData.nextStep || '',
534
+ touchedFiles,
535
+ completedCount: completed.length,
536
+ decisionsCount: decisions.length,
537
+ touchedFilesCount: touchedFiles.length,
538
+ }
539
+ : {
540
+ goal: update.goal || 'Untitled session',
541
+ status: normalizeStatus(update.status),
542
+ pinnedContext: mergeUniqueStrings(update.pinnedContext),
543
+ unresolvedQuestions: mergeUniqueStrings(update.unresolvedQuestions),
544
+ currentFocus: update.currentFocus ?? '',
545
+ whyBlocked: update.whyBlocked ?? '',
546
+ completed,
547
+ decisions,
548
+ blockers: mergeUniqueStrings(update.blockers),
549
+ nextStep: update.nextStep ?? '',
550
+ touchedFiles,
551
+ completedCount: completed.length,
552
+ decisionsCount: decisions.length,
553
+ touchedFilesCount: touchedFiles.length,
554
+ };
555
+
556
+ const savedData = saveSession(targetSessionId, mergedData);
557
+ const { compressed, tokens, truncated, omitted, compressionLevel } = compressSummary(savedData, maxTokens);
558
+
559
+ const rawTokens = countTokens(JSON.stringify(savedData));
560
+ const summaryMetrics = buildSummaryMetrics(rawTokens, tokens);
561
+
562
+ persistMetrics({
563
+ tool: 'smart_summary',
564
+ action,
565
+ sessionId: targetSessionId,
566
+ ...summaryMetrics,
567
+ latencyMs: Date.now() - startTime,
568
+ });
569
+
570
+ return {
571
+ action,
572
+ sessionId: targetSessionId,
573
+ summary: compressed,
574
+ tokens,
575
+ truncated,
576
+ omitted,
577
+ compressionLevel,
578
+ schemaVersion: savedData.schemaVersion,
579
+ updatedAt: savedData.updatedAt,
580
+ message: action === 'append' ? 'Session updated incrementally.' : 'Session saved.',
581
+ };
582
+ }
583
+
584
+ throw new Error(`Invalid action: ${action}. Valid actions: get, update, append, reset, list_sessions`);
585
+ };