smart-context-mcp 0.8.0 → 1.0.0

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,76 @@
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
+ ## What you get
31
+
32
+ Seven focused tools that work automatically:
33
+
34
+ - `smart_read`: compact file summaries instead of full file dumps (3x compression)
8
35
  - `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
36
+ - `smart_search`: ripgrep-first code search with intent-aware ranking (21x compression)
37
+ - `smart_context`: one-call context planner search + read + graph expansion
38
+ - `smart_summary`: maintain compressed conversation state across sessions (46x compression)
39
+ - `smart_shell`: safe diagnostic shell execution with restricted commands (18x compression)
12
40
  - `build_index`: lightweight symbol index for faster lookups and smarter ranking
13
41
 
42
+ **Strongest in:** Modern web/backend codebases (JS/TS, React, Next.js, Node.js, Python, Go, Rust), infra repos (Terraform, Docker, YAML)
43
+
44
+ ## Example: Before vs After
45
+
46
+ ### Without this MCP
47
+ ```
48
+ Agent: Let me read auth.js...
49
+ [Reads 4,000 tokens of full file]
50
+
51
+ Agent: Let me search for "jwt validation"...
52
+ [Returns 10,000 tokens of grep results]
53
+
54
+ Agent: [Next turn] What were we doing?
55
+ [Repeats 5,000 tokens of context]
56
+
57
+ Total: ~19,000 tokens
58
+ ```
59
+
60
+ ### With this MCP
61
+ ```
62
+ Agent: Let me use smart_read on auth.js...
63
+ [Returns 500 tokens of signatures]
64
+
65
+ Agent: Let me use smart_search for "jwt validation"...
66
+ [Returns 400 tokens of ranked snippets]
67
+
68
+ Agent: [Next turn] Let me get the context...
69
+ [smart_summary returns 100 tokens]
70
+
71
+ Total: ~1,000 tokens (95% reduction)
72
+ ```
73
+
14
74
  ## Quick start
15
75
 
16
76
  ```bash
@@ -109,7 +169,7 @@ After installing and running `smart-context-init`, each client picks up the serv
109
169
 
110
170
  ### Cursor
111
171
 
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.
172
+ 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
173
 
114
174
  ### Codex CLI
115
175
 
@@ -355,6 +415,92 @@ When using diff mode, the response includes a `diffSummary`:
355
415
  }
356
416
  ```
357
417
 
418
+ ### `smart_summary`
419
+
420
+ Maintain compressed conversation state across sessions. Solves the context-loss problem when resuming work after hours or days.
421
+
422
+ **Actions:**
423
+
424
+ | Action | Purpose | Returns |
425
+ |--------|---------|---------|
426
+ | `get` | Retrieve current or specified session | Resume summary (≤500 tokens) + compression metadata |
427
+ | `update` | Create or replace session | New session with compressed state |
428
+ | `append` | Add to existing session | Merged session state |
429
+ | `reset` | Clear session | Confirmation |
430
+ | `list_sessions` | Show all available sessions | Array of sessions with metadata |
431
+
432
+ **Parameters:**
433
+ - `action` (required) — one of the actions above
434
+ - `sessionId` (optional) — session identifier; auto-generated from `goal` if omitted
435
+ - `update` (required for update/append) — object with:
436
+ - `goal`: primary objective
437
+ - `status`: current state (`planning` | `in_progress` | `blocked` | `completed`)
438
+ - `pinnedContext`: critical context that should survive compression when possible
439
+ - `unresolvedQuestions`: open questions that matter for the next turn
440
+ - `currentFocus`: current work area in one short phrase
441
+ - `whyBlocked`: blocker summary when status is `blocked`
442
+ - `completed`: array of completed steps
443
+ - `decisions`: array of key decisions with rationale
444
+ - `blockers`: array of current blockers
445
+ - `nextStep`: immediate next action
446
+ - `touchedFiles`: array of modified files
447
+ - `maxTokens` (optional, default 500) — hard cap on summary size
448
+
449
+ `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.
450
+
451
+ **Storage:**
452
+ - Sessions persist in `.devctx/sessions/<sessionId>.json`
453
+ - Active session tracked in `.devctx/sessions/active.json`
454
+ - 30-day retention for inactive sessions
455
+ - No expiration for active sessions
456
+
457
+ **Resume summary fields:**
458
+ - `status` and `nextStep` are preserved with highest priority
459
+ - `pinnedContext` and `unresolvedQuestions` preserve critical context and open questions
460
+ - `currentFocus` and `whyBlocked` are included when relevant
461
+ - `recentCompleted`, `keyDecisions`, and `hotFiles` are derived from the persisted state
462
+ - `completedCount`, `decisionsCount`, and `touchedFilesCount` preserve activity scale cheaply
463
+ - Empty fields are omitted to save tokens
464
+
465
+ **Response metadata:**
466
+ - `schemaVersion`: persisted session schema version
467
+ - `truncated`: whether the resume summary had to be compressed
468
+ - `compressionLevel`: `none` | `trimmed` | `reduced` | `status_only`
469
+ - `omitted`: fields dropped from the resume summary to fit the token budget
470
+
471
+ **Compression strategy:**
472
+ - Keeps the persisted session state intact and compresses only the resume summary
473
+ - Prioritizes `nextStep`, `status`, and active blockers over history
474
+ - Deduplicates repeated completed steps, decisions, and touched files
475
+ - Uses token-aware reduction until the summary fits `maxTokens`
476
+
477
+ **Example workflow:**
478
+
479
+ ```javascript
480
+ // Start of work session
481
+ smart_summary({ action: "get" })
482
+ // → retrieves last active session or returns "not found"
483
+
484
+ // After implementing auth middleware
485
+ smart_summary({
486
+ action: "append",
487
+ update: {
488
+ completed: ["auth middleware"],
489
+ decisions: ["JWT with 1h expiry, refresh tokens in Redis"],
490
+ touchedFiles: ["src/middleware/auth.js"],
491
+ nextStep: "add role-based access control"
492
+ }
493
+ })
494
+
495
+ // Monday after weekend - resume work
496
+ smart_summary({ action: "get" })
497
+ // → full context restored, continue from nextStep
498
+
499
+ // List all sessions
500
+ smart_summary({ action: "list_sessions" })
501
+ // → see all available sessions, pick one to resume
502
+ ```
503
+
358
504
  ### `build_index`
359
505
 
360
506
  - Builds a lightweight symbol index for the project (functions, classes, methods, types, etc.)
@@ -403,8 +549,9 @@ Metrics include: P@5, P@10, Recall, wrong-file rate, retrieval honesty, follow-u
403
549
  ## Notes
404
550
 
405
551
  - `@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.
552
+ - Metrics are written to `<projectRoot>/.devctx/metrics.jsonl` (override with `DEVCTX_METRICS_FILE` env var).
553
+ - Symbol index stored in `<projectRoot>/.devctx/index.json` when `build_index` is used.
554
+ - Conversation sessions stored in `<projectRoot>/.devctx/sessions/` when `smart_summary` is used.
408
555
  - This package is a navigation and diagnostics layer, not a full semantic code intelligence system.
409
556
 
410
557
  ## Repository
@@ -412,3 +559,13 @@ Metrics include: P@5, P@10, Recall, wrong-file rate, retrieval honesty, follow-u
412
559
  Source repository and full project documentation:
413
560
 
414
561
  - https://github.com/Arrayo/devctx-mcp-mvp
562
+
563
+ ## Author
564
+
565
+ **Francisco Caballero Portero**
566
+ Email: fcp1978@hotmail.com
567
+ GitHub: [@Arrayo](https://github.com/Arrayo)
568
+
569
+ ## License
570
+
571
+ 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.0",
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,
@@ -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
+ };