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 +168 -9
- package/package.json +1 -1
- package/scripts/init-clients.js +5 -2
- package/scripts/report-metrics.js +16 -4
- package/src/server.js +26 -0
- package/src/tools/smart-summary.js +585 -0
package/README.md
CHANGED
|
@@ -1,16 +1,78 @@
|
|
|
1
1
|
# smart-context-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/smart-context-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**MCP server that reduces AI agent token usage by 90% and improves response quality.**
|
|
6
7
|
|
|
7
|
-
|
|
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
|
|
10
|
-
- `smart_context`: one-call context planner
|
|
11
|
-
- `
|
|
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
|
|
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
|
|
407
|
-
- Symbol index stored in
|
|
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.
|
|
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",
|
package/scripts/init-clients.js
CHANGED
|
@@ -20,7 +20,7 @@ const parseArgs = (argv) => {
|
|
|
20
20
|
const options = {
|
|
21
21
|
target: process.cwd(),
|
|
22
22
|
name: 'devctx',
|
|
23
|
-
command:
|
|
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 +=
|
|
112
|
-
current.savedTokens +=
|
|
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 +=
|
|
117
|
-
savedTokens +=
|
|
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
|
+
};
|