smart-context-mcp 1.10.0 → 1.13.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
@@ -56,7 +56,7 @@ Restart your AI client. Done.
56
56
  # Check installed version
57
57
  npm list -g smart-context-mcp
58
58
 
59
- # Should show: smart-context-mcp@1.10.0 (or later)
59
+ # Should show: smart-context-mcp@1.13.0 (or later)
60
60
 
61
61
  # Update to latest version
62
62
  npm update -g smart-context-mcp
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
3
  "mcpName": "io.github.Arrayo/smart-context-mcp",
4
- "version": "1.10.0",
4
+ "version": "1.13.0",
5
5
  "description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
6
6
  "author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
7
7
  "type": "module",
@@ -461,11 +461,9 @@ const updateCursorRule = (targetDir, dryRun) => {
461
461
  const rulesDir = path.join(targetDir, '.cursor', 'rules');
462
462
  const profilesDir = path.join(rulesDir, 'profiles-compact');
463
463
 
464
- // Write base rule (always active)
465
464
  const baseFilePath = path.join(rulesDir, 'devctx.mdc');
466
465
  writeFile(baseFilePath, cursorRuleContent, dryRun);
467
-
468
- // Write profiles README
466
+
469
467
  const profilesReadmePath = path.join(profilesDir, 'README.md');
470
468
  writeFile(profilesReadmePath, cursorProfilesNote, dryRun);
471
469
 
@@ -73,7 +73,6 @@ const calculateAdoptionMetrics = (days = 30) => {
73
73
  return withStateDb((db) => {
74
74
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
75
75
 
76
- // Get all sessions since cutoff
77
76
  const sessions = db
78
77
  .prepare(
79
78
  `
@@ -94,7 +93,6 @@ const calculateAdoptionMetrics = (days = 30) => {
94
93
  toolUsage: {},
95
94
  };
96
95
 
97
- // Initialize workflow stats
98
96
  Object.keys(WORKFLOW_DEFINITIONS).forEach((type) => {
99
97
  results.byWorkflow[type] = {
100
98
  total: 0,
@@ -103,12 +101,10 @@ const calculateAdoptionMetrics = (days = 30) => {
103
101
  };
104
102
  });
105
103
 
106
- // Analyze each session
107
104
  sessions.forEach((session) => {
108
105
  const snapshot = JSON.parse(session.snapshot_json || '{}');
109
106
  const sessionId = session.session_id;
110
107
 
111
- // Get events for this session
112
108
  const sessionEvents = db
113
109
  .prepare('SELECT * FROM session_events WHERE session_id = ?')
114
110
  .all(sessionId);
@@ -117,25 +113,21 @@ const calculateAdoptionMetrics = (days = 30) => {
117
113
  .prepare('SELECT * FROM metrics_events WHERE session_id = ?')
118
114
  .all(sessionId);
119
115
 
120
- // Check if non-trivial
121
116
  if (!isNonTrivialTask(sessionEvents, metricsEvents)) {
122
117
  return;
123
118
  }
124
119
 
125
120
  results.nonTrivialTasks++;
126
121
 
127
- // Check if used devctx
128
122
  const hasDevctx = usedDevctx(metricsEvents);
129
123
  if (hasDevctx) {
130
124
  results.tasksWithDevctx++;
131
125
  }
132
126
 
133
- // Track tool usage
134
127
  metricsEvents.forEach((m) => {
135
128
  results.toolUsage[m.tool] = (results.toolUsage[m.tool] || 0) + 1;
136
129
  });
137
130
 
138
- // Classify by workflow if possible
139
131
  const goal = snapshot.goal || '';
140
132
  let workflowType = null;
141
133
 
@@ -154,7 +146,6 @@ const calculateAdoptionMetrics = (days = 30) => {
154
146
  }
155
147
  });
156
148
 
157
- // Calculate rates
158
149
  if (results.nonTrivialTasks > 0) {
159
150
  results.adoptionRate = (results.tasksWithDevctx / results.nonTrivialTasks) * 100;
160
151
  }
@@ -171,7 +171,6 @@ const printSummary = (summary) => {
171
171
  console.log('─'.repeat(120));
172
172
  console.log('');
173
173
 
174
- // Show detailed breakdown for each workflow type
175
174
  console.log('Detailed Breakdown:');
176
175
  console.log('');
177
176
 
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/Arrayo/smart-context-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.10.0",
9
+ "version": "1.13.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "smart-context-mcp",
14
- "version": "1.10.0",
14
+ "version": "1.13.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { execFile as execFileCallback } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { projectRoot } from './utils/paths.js';
6
- import { loadIndex, buildIndex as buildIndexCore } from './index.js';
6
+ import { loadIndex, buildIndexIncremental, persistIndex } from './index.js';
7
7
 
8
8
  const execFile = promisify(execFileCallback);
9
9
 
@@ -83,23 +83,28 @@ export const ensureIndexReady = async (options = {}) => {
83
83
  }
84
84
 
85
85
  log('Building search index...');
86
-
86
+
87
87
  try {
88
- const buildPromise = buildIndexCore({ root, incremental: true });
88
+ const buildPromise = (async () => {
89
+ const { index, stats } = buildIndexIncremental(root);
90
+ await persistIndex(index, root);
91
+ return { stats, fileCount: Object.keys(index.files).length, version: index.version };
92
+ })();
93
+
89
94
  const result = await Promise.race([
90
95
  buildPromise,
91
- timeout(timeoutMs, 'Index build timeout')
96
+ timeout(timeoutMs, 'Index build timeout'),
92
97
  ]);
93
-
98
+
94
99
  saveIndexMetadata({
95
100
  builtAt: Date.now(),
96
101
  gitHead: getGitHead(root),
97
- fileCount: result?.files?.length || 0,
98
- version: result?.version
102
+ fileCount: result.fileCount,
103
+ version: result.version,
99
104
  }, root);
100
-
105
+
101
106
  log('Index ready');
102
- return { status: 'built', cached: false, fileCount: result?.files?.length || 0 };
107
+ return { status: 'built', cached: false, fileCount: result.fileCount };
103
108
  } catch (error) {
104
109
  log(`Index build failed: ${error.message}`);
105
110
  return { status: 'fallback', error: error.message };
package/src/index.js CHANGED
@@ -726,9 +726,7 @@ export const buildIndex = (root, progress = null) => {
726
726
  if (sym.snippet) entry.snippet = sym.snippet;
727
727
  invertedIndex[key].push(entry);
728
728
  }
729
- } catch {
730
- // skip unreadable files
731
- }
729
+ } catch { /* unreadable */ }
732
730
 
733
731
  processed++;
734
732
 
@@ -141,7 +141,6 @@ export const formatMissedOpportunities = () => {
141
141
  lines.push('⚠️ **Missed devctx opportunities detected:**');
142
142
  lines.push('');
143
143
 
144
- // Show session stats
145
144
  lines.push(`**Session stats:**`);
146
145
  lines.push(`- Duration: ${analysis.sessionDuration}s`);
147
146
  lines.push(`- devctx operations: ${analysis.devctxOperations}`);
package/src/server.js CHANGED
@@ -39,7 +39,7 @@ export const asTextResult = (result) => ({
39
39
  content: [
40
40
  {
41
41
  type: 'text',
42
- text: JSON.stringify(result, null, 2),
42
+ text: JSON.stringify(result),
43
43
  },
44
44
  ],
45
45
  });
@@ -57,13 +57,11 @@ export const createDevctxServer = () => {
57
57
  version,
58
58
  });
59
59
 
60
- // Enable streaming progress notifications
61
60
  setServerForStreaming(server);
62
61
 
63
- // Register prompts
64
62
  server.prompt(
65
63
  'use-devctx',
66
- 'Force the agent to use devctx tools for the current task. Use this prompt at the start of your message to ensure devctx is used instead of native tools.',
64
+ 'Force the agent to use devctx tools for the current task.',
67
65
  {},
68
66
  async () => ({
69
67
  messages: [
@@ -71,7 +69,7 @@ export const createDevctxServer = () => {
71
69
  role: 'user',
72
70
  content: {
73
71
  type: 'text',
74
- text: 'Use devctx: smart_turn(start) smart_context/smart_search smart_read → smart_turn(end)',
72
+ text: 'Use devctx MCP tools for this task. Start with smart_context(task) for multi-file context. Use smart_read(outline)smart_read(symbol) cascade for individual files. Never use native Read on large files.',
75
73
  },
76
74
  },
77
75
  ],
@@ -80,7 +78,7 @@ export const createDevctxServer = () => {
80
78
 
81
79
  server.prompt(
82
80
  'devctx-workflow',
83
- 'Complete devctx workflow template with all recommended steps. Includes session start, context building, file reading, and session end.',
81
+ 'Complete devctx workflow for complex tasks with session continuity.',
84
82
  {},
85
83
  async () => ({
86
84
  messages: [
@@ -88,15 +86,7 @@ export const createDevctxServer = () => {
88
86
  role: 'user',
89
87
  content: {
90
88
  type: 'text',
91
- text: `Follow this devctx workflow:
92
-
93
- 1. smart_turn(start) - Start session and recover previous context
94
- 2. smart_context(task) - Build complete context for the task
95
- 3. smart_search(query) - Search for specific patterns if needed
96
- 4. smart_read(file) - Read files with appropriate mode (outline/signatures/symbol)
97
- 5. smart_turn(end) - Save checkpoint for next session
98
-
99
- Use devctx tools instead of native Read/Grep/Shell when possible.`,
89
+ text: 'Follow devctx workflow: 1) smart_turn(start) to recover session 2) smart_context(task) for curated context (replaces search+read cycle) 3) smart_read(symbol) only for specific functions not covered by smart_context 4) smart_turn(end) to checkpoint. Never skip to smart_read(full) — use the cascade: outline → signatures → symbol → full.',
100
90
  },
101
91
  },
102
92
  ],
@@ -105,7 +95,7 @@ Use devctx tools instead of native Read/Grep/Shell when possible.`,
105
95
 
106
96
  server.prompt(
107
97
  'devctx-preflight',
108
- 'Preflight checklist before starting work. Ensures index is built and session is initialized.',
98
+ 'Preflight: build index and initialize session before work.',
109
99
  {},
110
100
  async () => ({
111
101
  messages: [
@@ -113,13 +103,7 @@ Use devctx tools instead of native Read/Grep/Shell when possible.`,
113
103
  role: 'user',
114
104
  content: {
115
105
  type: 'text',
116
- text: `Preflight checklist:
117
-
118
- 1. build_index(incremental=true) - Build/update symbol index
119
- 2. smart_turn(start) - Initialize session and recover context
120
- 3. Proceed with your task using devctx tools
121
-
122
- This ensures optimal performance and context recovery.`,
106
+ text: 'Preflight: 1) build_index(incremental=true) 2) smart_turn(start) 3) Proceed with devctx tools.',
123
107
  },
124
108
  },
125
109
  ],
@@ -128,7 +112,7 @@ This ensures optimal performance and context recovery.`,
128
112
 
129
113
  server.tool(
130
114
  'smart_read',
131
- 'Read a file with token-efficient modes. outline/signatures: compact structure (~90% savings). range: specific line range with line numbers. symbol: extract function/class/method by name (string or array for batch). full: file content capped at 12k chars. maxTokens: token budget auto-selects the most detailed mode that fits (full -> outline -> signatures -> truncated). context=true (symbol mode only): includes callers, tests, and referenced types from the dependency graph; returns graphCoverage (imports/tests: full|partial|none) so the agent knows how reliable the cross-file context is. Responses are cached in memory per session and invalidated by file mtime; cached=true when served from cache. Every response includes a unified confidence block: { parser, truncated, cached, graphCoverage? }. Supports JS/TS, Python, Go, Rust, Java, C#, Kotlin, PHP, Swift, shell, Terraform, Dockerfile, SQL, JSON, TOML, YAML.',
115
+ 'Read a file with token-efficient modes. ALWAYS prefer outline/signatures/symbol over full. Reading cascade: outline signatures → symbol → range → full (last resort). Mode guide: outline (~90% savings): file structure, exports, top-level symbols — use first for orientation. signatures (~85% savings): function signatures with parameters and return types use when you need the API surface. symbol: extract specific functions/classes by name (string or array) use when you know what to read; add context=true for callers, tests, and dependencies. range: specific line range use only when you need exact lines. full: raw content, no savings only for config/lock files. maxTokens: token budget auto-cascades to fit (outline signatures truncated). Supports JS/TS, Python, Go, Rust, Java, C#, Kotlin, PHP, Swift, shell, Terraform, Dockerfile, SQL, JSON, TOML, YAML.',
132
116
  {
133
117
  filePath: z.string(),
134
118
  mode: z.enum(['full', 'outline', 'signatures', 'range', 'symbol']).optional(),
@@ -144,7 +128,7 @@ This ensures optimal performance and context recovery.`,
144
128
 
145
129
  server.tool(
146
130
  'smart_read_batch',
147
- 'Read multiple files in one call. Each item accepts path, mode, symbol, startLine, endLine, maxTokens (per-file budget). Optional global maxTokens budget with early stop when exceeded. Max 20 files per call.',
131
+ 'Read multiple files in one call. Each item accepts path, mode (prefer outline/signatures/symbol — full saves 0 tokens), symbol, startLine, endLine, maxTokens (per-file budget). Optional global maxTokens budget with early stop when exceeded. Max 20 files per call.',
148
132
  {
149
133
  files: z.array(z.object({
150
134
  path: z.string(),
@@ -162,7 +146,7 @@ This ensures optimal performance and context recovery.`,
162
146
 
163
147
  server.tool(
164
148
  'smart_search',
165
- 'Search code across the project using ripgrep (with filesystem fallback). Returns grouped, ranked results. Optional intent (implementation/debug/tests/config/docs/explore) adjusts ranking: tests boosts test files, config boosts config files, docs reduces penalty on READMEs. Includes a unified confidence block: { level, indexFreshness } plus retrievalConfidence and provenance metadata.',
149
+ 'Search code across the project using ripgrep (with filesystem fallback). Returns grouped, ranked results. Optional intent (implementation/debug/tests/config/docs/explore) adjusts ranking. Use instead of native Grep for ranked, deduplicated results with index boosting.',
166
150
  {
167
151
  query: z.string(),
168
152
  cwd: z.string().optional(),
@@ -173,7 +157,7 @@ This ensures optimal performance and context recovery.`,
173
157
 
174
158
  server.tool(
175
159
  'smart_context',
176
- 'Get curated context for a task in one call. Combines smart_search + smart_read + graph expansion. Returns relevant files, evidence for why each file was included, related tests, dependencies, symbol previews from the index, and symbol details — optimized for tokens. Includes a unified confidence block: { indexFreshness, graphCoverage } indicating index state and how complete the relational context is. Replaces the manual search → read → read cycle. Optional intent override, token budget, diff mode (pass diff=true for HEAD or diff="main" to scope context to changed files only), detail mode (minimal=index+signatures+snippets, balanced=default, deep=full content), include array to control which fields are returned (["content","graph","hints","symbolDetail"]), and prefetch=true to enable intelligent context prediction based on historical patterns (reduces round-trips by 40-60%).',
160
+ 'PREFERRED for multi-file tasks. Gets curated context in one call replaces the manual search read read cycle. Combines search + graph expansion + selective reading. Returns relevant files with symbols and content, optimized for tokens. Options: intent, maxTokens (budget), diff (true for HEAD or branch name), detail (minimal/balanced/deep), include (content/graph/hints/symbolDetail), prefetch (true for predictive loading). Call this FIRST before individual smart_read/smart_search calls.',
177
161
  {
178
162
  task: z.string(),
179
163
  intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
@@ -190,7 +174,7 @@ This ensures optimal performance and context recovery.`,
190
174
 
191
175
  server.tool(
192
176
  'smart_shell',
193
- 'Run a diagnostic shell command from an allowlist. Allowed: pwd, ls, find, rg, git (status/diff/show/log/branch/rev-parse), npm/pnpm/yarn/bun (test/run/lint/build/typecheck/check). Blocks shell operators, pipes, and unsafe commands. Includes a unified confidence block: { blocked, timedOut }.',
177
+ 'Run a diagnostic shell command from an allowlist. Allowed: pwd, ls, find, rg, git (status/diff/show/log/branch/rev-parse), npm/pnpm/yarn/bun (test/run/lint/build/typecheck/check). Blocks shell operators, pipes, and unsafe commands. For large diffs: output is split by file (up to 8 files, 60 lines each); prefer git diff --stat first, then git show -- <file> per file.',
194
178
  {
195
179
  command: z.string(),
196
180
  },
@@ -487,7 +471,7 @@ This ensures optimal performance and context recovery.`,
487
471
 
488
472
  server.tool(
489
473
  'smart_turn',
490
- 'Orchestrate start/end of a meaningful agent turn so context usage becomes almost mandatory with low token overhead. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, returns `recommendedPath` guidance for the next safe devctx actions, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary, returns follow-up `recommendedPath` guidance, and can optionally include compact metrics. Both phases expose `mutationSafety` when repo-safety blocks persisted writes and now surface `storageHealth` when SQLite state is missing, oversized, locked, or corrupted. Use this instead of manually chaining `smart_summary(get)` and `smart_summary(checkpoint)` when you want a single context-first turn workflow.',
474
+ 'Orchestrate start/end of a meaningful agent turn for multi-session tasks where context continuity matters. SKIP for single-session point-in-time tasks (reviewing a specific commit, answering a quick question, one-off lookup) — the setup overhead exceeds the benefit if the session will never be resumed. USE when: the task spans multiple chat sessions, you may return to it the next day, or the codebase context is large enough that re-reading is expensive. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, returns `recommendedPath` guidance for the next safe devctx actions, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary, returns follow-up `recommendedPath` guidance, and can optionally include compact metrics. Both phases expose `mutationSafety` when repo-safety blocks persisted writes and surface `storageHealth` when SQLite state is missing, oversized, locked, or corrupted.',
491
475
  {
492
476
  phase: z.enum(['start', 'end']),
493
477
  sessionId: z.string().optional(),
@@ -1,12 +1,35 @@
1
1
  import { encodingForModel } from 'js-tiktoken';
2
2
 
3
- const fallbackModel = 'gpt-4o-mini';
4
- const encoder = encodingForModel(fallbackModel);
3
+ const CLAUDE_ALIASES = new Set(['claude', 'anthropic']);
5
4
 
6
- export const countTokens = (text = '') => {
7
- if (!text) {
8
- return 0;
5
+ // js-tiktoken does not ship Claude's tokenizer; gpt-4o (o200k_base) is the
6
+ // closest available encoding. Accuracy for Claude models: ±15-20%.
7
+ const CLAUDE_FALLBACK = 'gpt-4o';
8
+ const DEFAULT_MODEL = 'gpt-4o-mini';
9
+
10
+ const resolveModel = () => {
11
+ const requested = (process.env.DEVCTX_TOKEN_MODEL || '').toLowerCase().trim();
12
+ if (!requested) return DEFAULT_MODEL;
13
+ if (CLAUDE_ALIASES.has(requested) || requested.startsWith('claude')) {
14
+ return CLAUDE_FALLBACK;
15
+ }
16
+ return requested;
17
+ };
18
+
19
+ const buildEncoder = () => {
20
+ const model = resolveModel();
21
+ try {
22
+ return encodingForModel(model);
23
+ } catch {
24
+ return encodingForModel(DEFAULT_MODEL);
9
25
  }
26
+ };
10
27
 
28
+ // Encoder is initialised once; if the env var changes at runtime the process
29
+ // must be restarted (acceptable for a CLI/MCP server).
30
+ const encoder = buildEncoder();
31
+
32
+ export const countTokens = (text = '') => {
33
+ if (!text) return 0;
11
34
  return encoder.encode(String(text)).length;
12
35
  };
@@ -15,7 +15,6 @@ import { predictContextFiles, recordContextAccess } from '../context-patterns.js
15
15
  import { recordToolUsage } from '../usage-feedback.js';
16
16
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
17
17
  import { recordDevctxOperation } from '../missed-opportunities.js';
18
- import { buildMetricsDisplay } from '../utils/metrics-display.js';
19
18
  import { createProgressReporter } from '../streaming.js';
20
19
  import {
21
20
  getDetailedDiff,
@@ -35,7 +34,6 @@ import {
35
34
  } from '../utils/query-extraction.js';
36
35
  import {
37
36
  dedupeEvidence,
38
- formatReasonIncluded,
39
37
  buildSymbolPreviews,
40
38
  attachSymbolEvidence,
41
39
  computeStaticUtility,
@@ -226,29 +224,6 @@ const getSymbolSignatureLimit = (item, detailMode, readMode) => {
226
224
  const getSymbolSignatures = (entries, maxItems = 10) =>
227
225
  entries.filter((entry) => entry.signature).slice(0, maxItems).map((entry) => entry.signature);
228
226
 
229
- const serializeEvidencePayload = (item) => {
230
- const evidence = dedupeEvidence(item.evidence ?? []);
231
- if (evidence.length === 0) return [];
232
-
233
- const limit = item.role === 'primary' ? 2 : 1;
234
- const preferred = item.role === 'primary'
235
- ? evidence
236
- : [
237
- evidence.find((entry) => ['testOf', 'dependencyOf', 'dependentOf'].includes(entry.type)),
238
- evidence[0],
239
- ].filter(Boolean);
240
-
241
- return uniqueList(preferred)
242
- .slice(0, limit)
243
- .map((entry) => ({
244
- type: entry.type,
245
- ...(entry.via ? { via: entry.via } : {}),
246
- ...(entry.query && item.role === 'primary' ? { query: entry.query } : {}),
247
- ...(entry.ref && item.role === 'primary' ? { ref: entry.ref } : {}),
248
- ...(Array.isArray(entry.symbols) && entry.symbols.length > 0 ? { symbols: entry.symbols.slice(0, 2) } : {}),
249
- }));
250
- };
251
-
252
227
  const shouldIncludeSymbolNames = (item, symbolPreviews, readMode) => {
253
228
  if (item.role === 'primary') return true;
254
229
  if (readMode === 'full') return true;
@@ -273,14 +248,10 @@ const buildContextItemPayload = (item, index, detailMode, readMode = 'index-only
273
248
  const symbolSignatures = shouldIncludeSymbolSignatures(item, symbolPreviews)
274
249
  ? getSymbolSignatures(fileSymbolEntries, getSymbolSignatureLimit(item, detailMode, readMode))
275
250
  : [];
276
- const evidence = serializeEvidencePayload(item);
277
251
 
278
252
  return {
279
253
  file: item.rel,
280
254
  role: item.role,
281
- readMode,
282
- reasonIncluded: formatReasonIncluded(item.evidence),
283
- evidence,
284
255
  ...(fileSymbols.length > 0 ? { symbols: fileSymbols } : {}),
285
256
  ...(symbolSignatures.length > 0 ? { symbolSignatures } : {}),
286
257
  ...(symbolPreviews.length > 0 ? { symbolPreviews } : {}),
@@ -412,17 +383,11 @@ export const smartContext = async ({
412
383
 
413
384
  await ensureIndexReady({ root });
414
385
 
415
- // Get detailed diff stats
416
386
  const detailedChanges = await getDetailedDiff(changed.ref, root);
417
387
  const index = loadIndex(root);
418
-
419
- // Analyze impact and prioritize
420
388
  const prioritized = analyzeChangeImpact(detailedChanges, index);
421
-
422
- // Expand to include related files (importers, dependencies, tests)
423
389
  const expandedFiles = expandChangedContext(changed.files, index, 10);
424
-
425
- // Build primary seeds with priority and impact data
390
+
426
391
  primarySeeds = Array.from(expandedFiles).map(rel => {
427
392
  const changeInfo = prioritized.find(c => c.file === rel);
428
393
  const evidence = [{
@@ -432,7 +397,6 @@ export const smartContext = async ({
432
397
  impact: changeInfo?.impactScore || 0,
433
398
  }];
434
399
 
435
- // Mark files that were expanded (not directly changed)
436
400
  if (!changed.files.includes(rel)) {
437
401
  evidence[0].expanded = true;
438
402
  }
@@ -444,7 +408,6 @@ export const smartContext = async ({
444
408
  };
445
409
  });
446
410
 
447
- // Sort by impact (critical changes first)
448
411
  primarySeeds.sort((a, b) => {
449
412
  const impactA = a.evidence[0].impact || 0;
450
413
  const impactB = b.evidence[0].impact || 0;
@@ -677,16 +640,9 @@ export const smartContext = async ({
677
640
 
678
641
  const filtered = filterFoundSymbols(symbolResult.content, symbolCandidates);
679
642
  if (filtered) {
680
- const symbolEvidence = dedupeEvidence([{
681
- type: 'symbolDetail',
682
- symbols: symbolCandidates.slice(0, 3),
683
- }]);
684
643
  const symbolPayload = {
685
644
  file: topPrimary.rel,
686
645
  role: 'symbolDetail',
687
- readMode: 'symbol',
688
- reasonIncluded: formatReasonIncluded(symbolEvidence),
689
- evidence: symbolEvidence,
690
646
  content: filtered,
691
647
  };
692
648
  const symbolTokens = countTokens(JSON.stringify(symbolPayload));
@@ -700,7 +656,6 @@ export const smartContext = async ({
700
656
  const existing = context[existingIdx];
701
657
  const signaturesOnly = {
702
658
  ...existing,
703
- readMode: 'signatures-only',
704
659
  content: '(omitted — see symbolDetail)',
705
660
  };
706
661
  const oldTokens = countTokens(JSON.stringify(existing));
@@ -755,7 +710,6 @@ export const smartContext = async ({
755
710
 
756
711
  const contentTokens = countTokens(context.map((c) => c.content).join('\n'));
757
712
  const previewTokens = context.reduce((sum, item) => sum + countTokens(JSON.stringify(item.symbolPreviews ?? [])), 0);
758
- const indexOnlyItems = context.filter((item) => item.readMode === 'index-only').length;
759
713
  const contentItems = context.filter((item) => typeof item.content === 'string' && item.content.length > 0).length;
760
714
  const primaryItem = context.find((item) => item.role === 'primary');
761
715
 
@@ -771,17 +725,13 @@ export const smartContext = async ({
771
725
  timestamp: new Date().toISOString(),
772
726
  });
773
727
 
774
- // Record usage for feedback
775
728
  recordToolUsage({
776
729
  tool: 'smart_context',
777
730
  savedTokens,
778
731
  target: task,
779
732
  });
780
-
781
- // Record devctx operation for missed opportunity detection
782
733
  recordDevctxOperation();
783
-
784
- // Record decision explanation
734
+
785
735
  let reason = DECISION_REASONS.TASK_CONTEXT;
786
736
  if (diff) {
787
737
  reason = DECISION_REASONS.DIFF_ANALYSIS;
@@ -828,17 +778,6 @@ export const smartContext = async ({
828
778
  };
829
779
 
830
780
  const filesIncluded = new Set(context.map((c) => c.file)).size;
831
- const metricsDisplay = buildMetricsDisplay({
832
- tool: 'smart_context',
833
- target: task,
834
- metrics: {
835
- rawTokens: totalRawTokens,
836
- compressedTokens: totalCompressedTokens,
837
- savedTokens,
838
- },
839
- startTime: enableProgress ? startTime : null,
840
- filesCount: filesIncluded,
841
- });
842
781
 
843
782
  if (progress) {
844
783
  progress.complete({
@@ -857,28 +796,20 @@ export const smartContext = async ({
857
796
  confidence: { indexFreshness, graphCoverage: graphCov },
858
797
  context,
859
798
  ...(includeSet.has('graph') ? { graph: graphSummary, graphCoverage: graphCov } : {}),
860
- metrics: {
861
- contentTokens,
862
- totalTokens: 0,
799
+ stats: {
863
800
  filesIncluded,
864
801
  filesEvaluated: expanded.size,
865
- savingsPct,
866
802
  detailMode,
867
- include: [...includeSet],
868
- previewTokens,
869
- indexOnlyItems,
870
- contentItems,
871
- primaryReadMode: primaryItem?.readMode ?? null,
803
+ totalTokens: countTokens(context.map((c) => c.content || '').join('')),
872
804
  ...(prefetchResult ? {
873
805
  prefetch: {
874
806
  enabled: true,
875
807
  confidence: prefetchResult.confidence || 0,
876
808
  predictedFiles: prefetchResult.predicted?.length || 0,
877
- matchedPattern: prefetchResult.matchedPattern || null
878
- }
879
- } : {})
809
+ matchedPattern: prefetchResult.matchedPattern || null,
810
+ },
811
+ } : {}),
880
812
  },
881
- metricsDisplay,
882
813
  ...(includeSet.has('hints') ? { hints } : {}),
883
814
  };
884
815
 
@@ -887,7 +818,5 @@ export const smartContext = async ({
887
818
  result.diffSummary = diffSummary;
888
819
  }
889
820
 
890
- result.metrics.totalTokens = countTokens(JSON.stringify(result));
891
-
892
821
  return result;
893
822
  };
@@ -105,15 +105,25 @@ const formatDeclarationName = (name) => {
105
105
 
106
106
  const collectVariableNames = (declarationList) => declarationList.declarations.map((declaration) => formatDeclarationName(declaration.name));
107
107
 
108
- const formatTopLevelStatement = (statement, sourceFile) => {
108
+ const getFunctionSignature = (statement, sourceFile) => {
109
+ const body = statement.body;
110
+ if (!body) return statement.getText(sourceFile).split('\n')[0];
111
+ const fullText = statement.getText(sourceFile);
112
+ const bodyOffset = body.getStart(sourceFile) - statement.getStart(sourceFile);
113
+ const sig = fullText.slice(0, bodyOffset).replace(/\s+$/, '');
114
+ return sig.length > 120 ? `${sig.slice(0, 120)}...` : sig;
115
+ };
116
+
117
+ const formatTopLevelStatement = (statement, sourceFile, mode = 'outline') => {
109
118
  const exported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
110
119
  const prefix = exported ? 'export ' : '';
111
120
 
112
121
  if (ts.isImportDeclaration(statement)) {
113
- return formatImport(statement);
122
+ return null;
114
123
  }
115
124
 
116
125
  if (ts.isFunctionDeclaration(statement)) {
126
+ if (mode === 'signatures') return getFunctionSignature(statement, sourceFile);
117
127
  return `${prefix}function ${getNodeName(statement)}()`;
118
128
  }
119
129
 
@@ -143,7 +153,8 @@ const formatTopLevelStatement = (statement, sourceFile) => {
143
153
  }
144
154
 
145
155
  if (ts.isExportAssignment(statement)) {
146
- return `export default ${statement.expression.getText(sourceFile)}`;
156
+ const text = statement.expression.getText(sourceFile);
157
+ return `export default ${text.length > 60 ? `${text.slice(0, 60)}...` : text}`;
147
158
  }
148
159
 
149
160
  return statement.getText(sourceFile).split('\n')[0];
@@ -248,7 +259,8 @@ export const summarizeCode = (fullPath, content, mode) => {
248
259
  const sourceFile = parseSource(fullPath, content);
249
260
  const topLevel = sourceFile.statements.flatMap((statement) => {
250
261
  if (isIIFE(statement)) return extractIIFEMembers(statement, sourceFile);
251
- return [formatTopLevelStatement(statement, sourceFile)];
262
+ const formatted = formatTopLevelStatement(statement, sourceFile, mode);
263
+ return formatted !== null ? [formatted] : [];
252
264
  });
253
265
  const hooks = collectHooks(sourceFile);
254
266
 
@@ -4,7 +4,6 @@ import { countTokens } from '../tokenCounter.js';
4
4
  export const smartReadBatch = async ({ files, maxTokens }) => {
5
5
  const results = [];
6
6
  let totalTokens = 0;
7
- let totalRawTokens = 0;
8
7
  let filesSkipped = 0;
9
8
 
10
9
  for (const item of files) {
@@ -40,13 +39,11 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
40
39
  parser: readResult.parser,
41
40
  truncated: readResult.truncated,
42
41
  content: readResult.content,
43
- ...(readResult.confidence ? { confidence: readResult.confidence } : {}),
44
42
  ...(readResult.indexHint !== undefined ? { indexHint: readResult.indexHint } : {}),
45
43
  ...(readResult.chosenMode ? { chosenMode: readResult.chosenMode, budgetApplied: true } : {}),
46
44
  });
47
45
 
48
46
  totalTokens += itemTokens;
49
- totalRawTokens += readResult.metrics?.rawTokens ?? 0;
50
47
  } catch (err) {
51
48
  results.push({
52
49
  filePath: item.path,
@@ -56,17 +53,12 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
56
53
  }
57
54
  }
58
55
 
59
- const totalSavingsPct = totalRawTokens > 0
60
- ? Math.max(0, Math.round(((totalRawTokens - totalTokens) / totalRawTokens) * 100))
61
- : 0;
62
-
63
56
  return {
64
57
  results,
65
58
  metrics: {
66
59
  totalTokens,
67
60
  filesRead: results.length,
68
61
  filesSkipped,
69
- totalSavingsPct,
70
62
  },
71
63
  };
72
64
  };
@@ -12,7 +12,6 @@ import { countTokens } from '../tokenCounter.js';
12
12
  import { recordToolUsage } from '../usage-feedback.js';
13
13
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
14
14
  import { recordDevctxOperation } from '../missed-opportunities.js';
15
- import { buildMetricsDisplay } from '../utils/metrics-display.js';
16
15
  import { createProgressReporter } from '../streaming.js';
17
16
 
18
17
  const execFile = promisify(execFileCb);
@@ -536,17 +535,12 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
536
535
 
537
536
  await persistMetrics(metrics);
538
537
 
539
- // Record usage for feedback
540
538
  recordToolUsage({
541
539
  tool: 'smart_read',
542
540
  savedTokens: metrics.savedTokens,
543
541
  target: path.relative(effectiveRoot, fullPath),
544
542
  });
545
-
546
- // Record devctx operation for missed opportunity detection
547
543
  recordDevctxOperation();
548
-
549
- // Record decision explanation
550
544
  const lineCount = content.split('\n').length;
551
545
  let reason = DECISION_REASONS.LARGE_FILE;
552
546
  let expectedBenefit = EXPECTED_BENEFITS.TOKEN_SAVINGS(metrics.savedTokens);
@@ -568,16 +562,6 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
568
562
  context: `${lineCount} lines, ${metrics.rawTokens} tokens → ${metrics.compressedTokens} tokens`,
569
563
  });
570
564
 
571
- const confidence = { parser, truncated, cached: cacheHit && !contextResult };
572
- if (contextResult) confidence.graphCoverage = contextResult.graphCoverage;
573
-
574
- const metricsDisplay = buildMetricsDisplay({
575
- tool: 'smart_read',
576
- target: path.relative(effectiveRoot, fullPath),
577
- metrics,
578
- startTime: enableProgress ? startTime : null,
579
- });
580
-
581
565
  if (progress) {
582
566
  progress.complete({
583
567
  file: path.relative(effectiveRoot, fullPath),
@@ -593,12 +577,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
593
577
  parser,
594
578
  truncated,
595
579
  content: compressedText,
596
- confidence,
597
- metrics,
598
- metricsDisplay,
599
580
  };
600
-
601
- if (cacheHit && !contextResult) result.cached = true;
602
581
  if (mode === 'symbol') result.indexHint = indexHintUsed;
603
582
  if (validBudget && effectiveMode !== mode) {
604
583
  result.chosenMode = effectiveMode;
@@ -12,7 +12,6 @@ import { recordToolUsage } from '../usage-feedback.js';
12
12
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
13
13
  import { recordDevctxOperation } from '../missed-opportunities.js';
14
14
  import { IGNORED_DIRS, IGNORED_FILE_NAMES, IGNORED_FILE_PATTERNS } from '../config/ignored-paths.js';
15
- import { buildMetricsDisplay } from '../utils/metrics-display.js';
16
15
  import { createProgressReporter } from '../streaming.js';
17
16
  import { ensureIndexReady } from '../index-manager.js';
18
17
 
@@ -475,17 +474,13 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
475
474
 
476
475
  await persistMetrics(metrics);
477
476
 
478
- // Record usage for feedback
479
477
  recordToolUsage({
480
478
  tool: 'smart_search',
481
479
  savedTokens: metrics.savedTokens,
482
480
  target: query,
483
481
  });
484
-
485
- // Record devctx operation for missed opportunity detection
486
482
  recordDevctxOperation();
487
-
488
- // Record decision explanation
483
+
489
484
  let reason = DECISION_REASONS.MULTIPLE_FILES;
490
485
  if (validIntent) {
491
486
  reason = DECISION_REASONS.INTENT_AWARE;
@@ -510,16 +505,6 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
510
505
  else if (usedFallback) retrievalConfidence = provenance?.skippedItemsTotal > 0 ? 'low' : 'medium';
511
506
  else if (provenance?.skippedItemsTotal > 0) retrievalConfidence = 'low';
512
507
 
513
- const confidence = { level: retrievalConfidence, indexFreshness };
514
-
515
- const metricsDisplay = buildMetricsDisplay({
516
- tool: 'smart_search',
517
- target: query,
518
- metrics,
519
- startTime: enableProgress ? startTime : null,
520
- filesCount: groups.length,
521
- });
522
-
523
508
  if (progress) {
524
509
  progress.complete({
525
510
  query,
@@ -532,23 +517,17 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
532
517
 
533
518
  const result = {
534
519
  query,
535
- root,
536
- engine,
537
- retrievalConfidence,
538
520
  indexFreshness,
539
- sourceBreakdown: breakdown,
540
- confidence,
541
521
  ...(validIntent ? { intent: validIntent } : {}),
542
522
  ...(indexHits ? { indexBoosted: indexHits.size } : {}),
543
523
  totalMatches: dedupedMatches.length,
544
524
  matchedFiles: groups.length,
545
525
  topFiles: groups.slice(0, 10).map((group) => ({ file: group.file, count: group.count, score: group.score })),
546
526
  matches: compressedText,
547
- metrics,
548
- metricsDisplay,
549
527
  };
550
528
 
551
- if (provenance) result.provenance = provenance;
529
+ if (provenance?.fallbackReason) result.searchMode = provenance.fallbackReason;
530
+ if (retrievalConfidence !== 'high') result.retrievalConfidence = retrievalConfidence;
552
531
 
553
532
  return result;
554
533
  };
@@ -7,10 +7,13 @@ import { pickRelevantLines, truncate, uniqueLines } from '../utils/text.js';
7
7
  import { recordToolUsage } from '../usage-feedback.js';
8
8
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
9
9
  import { recordDevctxOperation } from '../missed-opportunities.js';
10
- import { buildMetricsDisplay } from '../utils/metrics-display.js';
11
-
12
10
  const execFile = promisify(execFileCallback);
13
11
  const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
12
+ const DEFAULT_TIMEOUT_MS = 15000;
13
+ const getTimeoutMs = () => {
14
+ const env = parseInt(process.env.DEVCTX_SHELL_TIMEOUT_MS, 10);
15
+ return Number.isFinite(env) && env > 0 ? env : DEFAULT_TIMEOUT_MS;
16
+ };
14
17
  const allowedCommands = new Set(['pwd', 'ls', 'find', 'rg', 'git', 'npm', 'pnpm', 'yarn', 'bun']);
15
18
  const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'blame']);
16
19
  const allowedPackageManagerSubcommands = new Set(['test', 'run', 'lint', 'build', 'typecheck', 'check']);
@@ -176,6 +179,52 @@ const validateCommand = (command, tokens) => {
176
179
  return null;
177
180
  };
178
181
 
182
+ const DIFF_FILE_HEADER = /^diff --git a\/.+ b\/.+/;
183
+ const DIFF_HUNK_HEADER = /^@@ /;
184
+ const MAX_DIFF_FILES = 8;
185
+ const MAX_LINES_PER_FILE = 60;
186
+ const DIFF_TOTAL_LIMIT = 4000;
187
+
188
+ const splitDiffByFile = (text) => {
189
+ const files = [];
190
+ let current = null;
191
+
192
+ for (const line of text.split('\n')) {
193
+ if (DIFF_FILE_HEADER.test(line)) {
194
+ if (current) files.push(current);
195
+ current = { header: line, lines: [] };
196
+ } else if (current) {
197
+ current.lines.push(line);
198
+ }
199
+ }
200
+ if (current) files.push(current);
201
+ return files;
202
+ };
203
+
204
+ const compressDiff = (text) => {
205
+ if (!DIFF_FILE_HEADER.test(text)) return text;
206
+
207
+ const files = splitDiffByFile(text);
208
+ if (files.length === 0) return text;
209
+
210
+ const shown = files.slice(0, MAX_DIFF_FILES);
211
+ const skipped = files.length - shown.length;
212
+
213
+ const parts = shown.map(({ header, lines }) => {
214
+ const truncatedLines = lines.slice(0, MAX_LINES_PER_FILE);
215
+ const skippedLines = lines.length - truncatedLines.length;
216
+ const hunkCount = lines.filter((l) => DIFF_HUNK_HEADER.test(l)).length;
217
+ const suffix = skippedLines > 0 ? [`... (${skippedLines} more lines — use smart_read(symbol) for full body)`] : [];
218
+ return [header, `# ${hunkCount} hunk(s)`, ...truncatedLines, ...suffix].join('\n');
219
+ });
220
+
221
+ const footer = skipped > 0
222
+ ? `\n# ${skipped} more file(s) not shown — run git show -- <file> for each`
223
+ : '';
224
+
225
+ return truncate(parts.join('\n\n'), DIFF_TOTAL_LIMIT) + footer;
226
+ };
227
+
179
228
  const buildBlockedResult = async (command, message) => {
180
229
  const metrics = buildMetrics({
181
230
  tool: 'smart_shell',
@@ -191,8 +240,6 @@ const buildBlockedResult = async (command, message) => {
191
240
  exitCode: 126,
192
241
  blocked: true,
193
242
  output: message,
194
- confidence: { blocked: true, timedOut: false },
195
- metrics,
196
243
  };
197
244
  };
198
245
 
@@ -227,16 +274,17 @@ export const smartShell = async ({ command }) => {
227
274
  }
228
275
 
229
276
  const resolvedFile = file === 'rg' ? rgPath : file;
277
+ const timeoutMs = getTimeoutMs();
230
278
  const execution = await execFile(resolvedFile, args, {
231
279
  cwd: projectRoot,
232
280
  maxBuffer: 1024 * 1024 * 10,
233
- timeout: 15000,
281
+ timeout: timeoutMs,
234
282
  }).then(
235
283
  ({ stdout, stderr }) => ({ stdout, stderr, code: 0 }),
236
284
  (error) => ({
237
285
  stdout: error.stdout ?? '',
238
286
  stderr: error.killed
239
- ? `Command timed out after 15s: ${command}`
287
+ ? `Command timed out after ${timeoutMs / 1000}s: ${command}`
240
288
  : (error.stderr ?? error.message ?? ''),
241
289
  code: Number.isInteger(error.code) ? error.code : 1,
242
290
  timedOut: !!error.killed,
@@ -254,7 +302,7 @@ export const smartShell = async ({ command }) => {
254
302
  ]);
255
303
  const shouldPrioritizeRelevant = execution.code !== 0 || execution.timedOut;
256
304
  const compressedSource = shouldPrioritizeRelevant && relevant ? relevant : rawText;
257
- const compressedText = truncate(uniqueLines(compressedSource), 5000);
305
+ const compressedText = truncate(compressDiff(uniqueLines(compressedSource)), 5000);
258
306
  const metrics = buildMetrics({
259
307
  tool: 'smart_shell',
260
308
  target: command,
@@ -264,17 +312,12 @@ export const smartShell = async ({ command }) => {
264
312
 
265
313
  await persistMetrics(metrics);
266
314
 
267
- // Record usage for feedback
268
315
  recordToolUsage({
269
316
  tool: 'smart_shell',
270
317
  savedTokens: metrics.savedTokens,
271
318
  target: command,
272
319
  });
273
-
274
- // Record devctx operation for missed opportunity detection
275
320
  recordDevctxOperation();
276
-
277
- // Record decision explanation
278
321
  const outputLines = rawText.split('\n').length;
279
322
  let reason = DECISION_REASONS.COMMAND_OUTPUT;
280
323
  if (shouldPrioritizeRelevant && relevant) {
@@ -290,24 +333,13 @@ export const smartShell = async ({ command }) => {
290
333
  context: `${outputLines} lines → ${compressedText.split('\n').length} lines (relevant only)`,
291
334
  });
292
335
 
293
- const metricsDisplay = buildMetricsDisplay({
294
- tool: 'smart_shell',
295
- target: command,
296
- metrics,
297
- startTime: null,
298
- });
299
-
300
336
  const result = {
301
337
  command,
302
338
  exitCode: execution.code,
303
339
  blocked: false,
304
340
  output: compressedText,
305
- confidence: { blocked: false, timedOut: !!execution.timedOut },
306
- metrics,
307
- metricsDisplay,
341
+ ...(execution.timedOut ? { timedOut: true } : {}),
308
342
  };
309
343
 
310
- if (execution.timedOut) result.timedOut = true;
311
-
312
344
  return result;
313
345
  };
@@ -13,6 +13,9 @@ import { smartContext } from './smart-context.js';
13
13
  import { smartMetrics } from './smart-metrics.js';
14
14
  import { smartSummary } from './smart-summary.js';
15
15
 
16
+ const isStorageUnhealthy = (health) =>
17
+ health && health.status !== 'ok' && health.status !== null && health.status !== undefined;
18
+
16
19
  const DEFAULT_START_MAX_TOKENS = 400;
17
20
  const DEFAULT_END_MAX_TOKENS = 500;
18
21
  const DEFAULT_END_EVENT = 'milestone';
@@ -129,10 +132,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
129
132
  state: 'resume',
130
133
  shouldReuseContext: true,
131
134
  reason: 'A persisted session was found and no prompt terms were available for comparison.',
132
- sharedTerms: [],
133
- promptTermCount: 0,
134
- summaryTermCount: 0,
135
- matchScore: 1,
136
135
  };
137
136
  }
138
137
 
@@ -147,10 +146,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
147
146
  state: 'aligned',
148
147
  shouldReuseContext: true,
149
148
  reason: 'Prompt terms align with persisted task context.',
150
- sharedTerms: sharedTerms.slice(0, 8),
151
- promptTermCount: promptTerms.length,
152
- summaryTermCount: summaryTerms.length,
153
- matchScore,
154
149
  };
155
150
  }
156
151
 
@@ -159,10 +154,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
159
154
  state: 'possible_shift',
160
155
  shouldReuseContext: true,
161
156
  reason: 'Prompt partially overlaps the persisted context; review before continuing.',
162
- sharedTerms: sharedTerms.slice(0, 8),
163
- promptTermCount: promptTerms.length,
164
- summaryTermCount: summaryTerms.length,
165
- matchScore,
166
157
  };
167
158
  }
168
159
 
@@ -170,10 +161,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
170
161
  state: 'context_mismatch',
171
162
  shouldReuseContext: false,
172
163
  reason: 'Prompt terms do not align with the persisted session summary.',
173
- sharedTerms: [],
174
- promptTermCount: promptTerms.length,
175
- summaryTermCount: summaryTerms.length,
176
- matchScore,
177
164
  };
178
165
  };
179
166
 
@@ -342,7 +329,7 @@ const buildStartRecommendedPath = ({
342
329
  autoCreated,
343
330
  isolatedSession,
344
331
  nextTools: [...new Set(nextTools)],
345
- steps,
332
+ instructions: steps.map((s) => `${s.tool}: ${s.instruction}`).join(' | '),
346
333
  };
347
334
  };
348
335
 
@@ -389,7 +376,7 @@ const buildEndRecommendedPath = ({ event, checkpoint, mutationSafety, workflow }
389
376
  : 'checkpointed',
390
377
  checkpointEvent: event,
391
378
  nextTools: [...new Set(nextTools)],
392
- steps,
379
+ instructions: steps.map((s) => `${s.tool}: ${s.instruction}`).join(' | '),
393
380
  };
394
381
  };
395
382
 
@@ -579,7 +566,7 @@ const startTurn = async ({
579
566
  ...(summaryResult.candidates ? { candidates: summaryResult.candidates } : {}),
580
567
  ...(summaryResult.recommendedSessionId ? { recommendedSessionId: summaryResult.recommendedSessionId } : {}),
581
568
  ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
582
- storageHealth: summaryResult.storageHealth ?? metrics?.storageHealth ?? null,
569
+ ...(isStorageUnhealthy(summaryResult.storageHealth ?? metrics?.storageHealth) ? { storageHealth: summaryResult.storageHealth ?? metrics?.storageHealth } : {}),
583
570
  recommendedPath,
584
571
  message: mutationSafety?.blocked
585
572
  ? mutationSafety.message
@@ -694,7 +681,7 @@ const endTurn = async ({
694
681
  checkpoint,
695
682
  ...(workflow ? { workflow } : {}),
696
683
  ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
697
- storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth ?? null,
684
+ ...(isStorageUnhealthy(checkpoint.storageHealth ?? metrics?.storageHealth) ? { storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth } : {}),
698
685
  recommendedPath,
699
686
  message: mutationSafety?.blocked ? mutationSafety.message : checkpoint.message,
700
687
  }, {
@@ -60,7 +60,6 @@ export const formatUsageFeedback = () => {
60
60
  lines.push('');
61
61
  lines.push('📊 **devctx usage this session:**');
62
62
 
63
- // Sort by count descending
64
63
  const sorted = usage.tools.sort((a, b) => b.count - a.count);
65
64
 
66
65
  for (const { tool, count, savedTokens, targets } of sorted) {
@@ -77,9 +77,9 @@ export const attachSafetyMetadata = (
77
77
 
78
78
  return {
79
79
  ...result,
80
- ...(mutationSafety ? { mutationSafety } : {}),
81
- repoSafety,
82
- sideEffectsSuppressed: Boolean(sideEffectsSuppressed),
80
+ ...(mutationSafety?.blocked ? { mutationSafety } : {}),
81
+ ...(repoSafety && (mutationSafety?.blocked || sideEffectsSuppressed) ? { repoSafety } : {}),
82
+ ...(sideEffectsSuppressed ? { sideEffectsSuppressed: true } : {}),
83
83
  ...(degraded ? { degradedMode: degraded } : {}),
84
84
  };
85
85
  };
package/src/utils/text.js CHANGED
@@ -8,6 +8,7 @@ export const truncate = (text = '', maxChars = 4000) => {
8
8
 
9
9
  export const uniqueLines = (text = '') => {
10
10
  const seen = new Set();
11
+ let prevEmpty = false;
11
12
 
12
13
  return text
13
14
  .split('\n')
@@ -15,9 +16,13 @@ export const uniqueLines = (text = '') => {
15
16
  const key = line.trim();
16
17
 
17
18
  if (!key) {
19
+ if (prevEmpty) return false;
20
+ prevEmpty = true;
18
21
  return true;
19
22
  }
20
23
 
24
+ prevEmpty = false;
25
+
21
26
  if (seen.has(key)) {
22
27
  return false;
23
28
  }
@@ -183,7 +183,6 @@ export const endWorkflow = (workflowId) => {
183
183
  return null;
184
184
  }
185
185
 
186
- // Get workflow start time and session
187
186
  const workflow = db
188
187
  .prepare(
189
188
  `
@@ -203,7 +202,6 @@ export const endWorkflow = (workflowId) => {
203
202
  const endTime = new Date(now);
204
203
  const durationMs = endTime - startTime;
205
204
 
206
- // Get all metrics for this session since workflow start
207
205
  const metrics = db
208
206
  .prepare(
209
207
  `
@@ -215,7 +213,6 @@ export const endWorkflow = (workflowId) => {
215
213
  )
216
214
  .all(workflow.session_id, workflow.start_time);
217
215
 
218
- // Calculate totals
219
216
  const rawTokens = metrics.reduce((sum, m) => sum + (m.raw_tokens || 0), 0);
220
217
  const compressedTokens = metrics.reduce((sum, m) => sum + (m.compressed_tokens || 0), 0);
221
218
  const savedTokens = metrics.reduce((sum, m) => sum + (m.saved_tokens || 0), 0);
@@ -231,7 +228,6 @@ export const endWorkflow = (workflowId) => {
231
228
  const savingsPct = rawTokens > 0 ? ((savedTokens / rawTokens) * 100).toFixed(2) : 0;
232
229
  const netSavingsPct = rawTokens > 0 ? ((netSavedTokens / rawTokens) * 100).toFixed(2) : 0;
233
230
 
234
- // Calculate vs baseline
235
231
  const baselineTokens = workflow.baseline_tokens || 0;
236
232
  const vsBaselinePct = baselineTokens > 0 ? (((baselineTokens - compressedTokens) / baselineTokens) * 100).toFixed(2) : 0;
237
233
  const vsBaselineNetPct = baselineTokens > 0 ? (((baselineTokens - (compressedTokens + overheadTokens)) / baselineTokens) * 100).toFixed(2) : 0;
@@ -247,10 +243,7 @@ export const endWorkflow = (workflowId) => {
247
243
  },
248
244
  };
249
245
 
250
- // Get unique tools used
251
246
  const toolsUsed = [...new Set(metrics.map((m) => m.tool))];
252
-
253
- // Update workflow
254
247
  const stmt = db.prepare(`
255
248
  UPDATE workflow_metrics
256
249
  SET end_time = ?,
@@ -584,7 +577,6 @@ export const autoTrackWorkflow = (sessionId, sessionGoal) => {
584
577
  return null;
585
578
  }
586
579
 
587
- // Check if workflow already tracked for this session
588
580
  const existing = db
589
581
  .prepare(
590
582
  `
@@ -601,7 +593,6 @@ export const autoTrackWorkflow = (sessionId, sessionGoal) => {
601
593
  return existing.workflow_id;
602
594
  }
603
595
 
604
- // Get tools used so far in this session
605
596
  const metrics = db
606
597
  .prepare(
607
598
  `
@@ -621,7 +612,6 @@ export const autoTrackWorkflow = (sessionId, sessionGoal) => {
621
612
  return null;
622
613
  }
623
614
 
624
- // Start tracking
625
615
  return startWorkflow(workflowType, sessionId, { autoDetected: true, goal: sessionGoal });
626
616
  });
627
617
  } catch {