smart-context-mcp 1.16.4 → 1.17.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.16.4 (or later)
59
+ # Should show: smart-context-mcp@1.17.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.16.4",
4
+ "version": "1.17.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",
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.16.4",
9
+ "version": "1.17.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "smart-context-mcp",
14
- "version": "1.16.4",
14
+ "version": "1.17.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -52,8 +52,11 @@ export const buildRecommendedPathLines = (
52
52
  lines.push(`${nextToolsLabel}: ${recommendedPath.nextTools.slice(0, 3).join(' -> ')}`);
53
53
  }
54
54
 
55
- if (includePath && recommendedPath.steps?.[0]?.instruction) {
56
- lines.push(`${pathLabel}: ${truncate(recommendedPath.steps[0].instruction, maxLength)}`);
55
+ if (includePath) {
56
+ const pathInstruction = recommendedPath.steps?.[0]?.instruction ?? recommendedPath.next ?? null;
57
+ if (pathInstruction) {
58
+ lines.push(`${pathLabel}: ${truncate(pathInstruction, maxLength)}`);
59
+ }
57
60
  }
58
61
 
59
62
  return lines;
@@ -128,3 +128,24 @@ export const getIndexStatus = (root = projectRoot) => {
128
128
  age: meta?.builtAt ? Date.now() - meta.builtAt : null
129
129
  };
130
130
  };
131
+
132
+ let backgroundBuildPromise = null;
133
+
134
+ export const triggerBackgroundIndexBuild = ({ root = projectRoot, timeoutMs = INDEX_BUILD_TIMEOUT_MS } = {}) => {
135
+ if (backgroundBuildPromise) {
136
+ return backgroundBuildPromise;
137
+ }
138
+
139
+ const status = getIndexStatus(root);
140
+ if (status.available && status.fresh) {
141
+ return Promise.resolve({ status: 'fresh', cached: true });
142
+ }
143
+
144
+ backgroundBuildPromise = ensureIndexReady({ root, timeoutMs })
145
+ .catch((error) => ({ status: 'error', error: error?.message ?? String(error) }))
146
+ .finally(() => {
147
+ backgroundBuildPromise = null;
148
+ });
149
+
150
+ return backgroundBuildPromise;
151
+ };
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ import ts from 'typescript';
5
5
  import { isBinaryBuffer } from './utils/fs.js';
6
6
  import { IGNORED_DIRS } from './config/ignored-paths.js';
7
7
 
8
- const INDEX_VERSION = 4;
8
+ const INDEX_VERSION = 5;
9
9
 
10
10
  const MAX_SIGNATURE_LEN = 200;
11
11
  const MAX_SNIPPET_LEN = 280;
@@ -722,8 +722,6 @@ export const buildIndex = (root, progress = null) => {
722
722
  if (!invertedIndex[key]) invertedIndex[key] = [];
723
723
  const entry = { path: relPath, line: sym.line, kind: sym.kind };
724
724
  if (sym.parent) entry.parent = sym.parent;
725
- if (sym.signature) entry.signature = sym.signature;
726
- if (sym.snippet) entry.snippet = sym.snippet;
727
725
  invertedIndex[key].push(entry);
728
726
  }
729
727
  } catch { /* unreadable */ }
@@ -783,10 +781,24 @@ export const buildIndex = (root, progress = null) => {
783
781
  // Query helpers
784
782
  // ---------------------------------------------------------------------------
785
783
 
784
+ const findSymbolForHit = (index, key, hit) => {
785
+ const symbols = index.files?.[hit.path]?.symbols;
786
+ if (!Array.isArray(symbols)) return null;
787
+ return symbols.find((sym) => sym.name?.toLowerCase() === key && sym.line === hit.line) ?? null;
788
+ };
789
+
786
790
  export const queryIndex = (index, symbolName) => {
787
791
  if (!index?.invertedIndex) return [];
788
792
  const key = symbolName.toLowerCase();
789
- return index.invertedIndex[key] ?? [];
793
+ const hits = index.invertedIndex[key] ?? [];
794
+ return hits.map((hit) => {
795
+ const sym = findSymbolForHit(index, key, hit);
796
+ if (!sym) return hit;
797
+ const enriched = { ...hit };
798
+ if (sym.signature && !enriched.signature) enriched.signature = sym.signature;
799
+ if (sym.snippet && !enriched.snippet) enriched.snippet = sym.snippet;
800
+ return enriched;
801
+ });
790
802
  };
791
803
 
792
804
  export const queryRelated = (index, relPath) => {
@@ -873,8 +885,6 @@ export const reindexFile = (index, root, relPath) => {
873
885
  if (!index.invertedIndex[key]) index.invertedIndex[key] = [];
874
886
  const invEntry = { path: relPath, line: sym.line, kind: sym.kind };
875
887
  if (sym.parent) invEntry.parent = sym.parent;
876
- if (sym.signature) invEntry.signature = sym.signature;
877
- if (sym.snippet) invEntry.snippet = sym.snippet;
878
888
  index.invertedIndex[key].push(invEntry);
879
889
  }
880
890
 
@@ -12,7 +12,15 @@ import {
12
12
  upsertAgentRun,
13
13
  } from '../../storage/sqlite.js';
14
14
  import { DEFAULT_START_MAX_TOKENS, resolveManagedStart } from '../base-orchestrator.js';
15
- import { extractNextStep, normalizeWhitespace, truncate } from '../policy/event-policy.js';
15
+ import {
16
+ MAX_READ_FILES,
17
+ READ_CHECKPOINT_THRESHOLD,
18
+ READ_CHECKPOINT_THROTTLE_MS,
19
+ extractNextStep,
20
+ extractReadPathsFromToolUse,
21
+ normalizeWhitespace,
22
+ truncate,
23
+ } from '../policy/event-policy.js';
16
24
 
17
25
  export const HOOK_CLIENT = 'claude';
18
26
  export const STOP_MAX_TOKENS = 300;
@@ -204,6 +212,11 @@ export const createClaudeAdapter = ({
204
212
  const autoStartTriggered = action === 'SessionStart' || action === 'UserPromptSubmit';
205
213
  const autoCheckpointTriggered = action === 'Stop' && autoAppended;
206
214
 
215
+ const shouldPersist = autoStartTriggered || autoCheckpointTriggered || blocked || overheadTokens > 0;
216
+ if (!shouldPersist) {
217
+ return;
218
+ }
219
+
207
220
  await persistMetric({
208
221
  tool: 'claude_hook',
209
222
  action,
@@ -266,10 +279,88 @@ export const createClaudeAdapter = ({
266
279
  checkpointEvent: null,
267
280
  touchedFiles: [],
268
281
  meaningfulWriteCount: 0,
282
+ readFiles: [],
283
+ meaningfulReadCount: 0,
284
+ lastReadCheckpointAt: 0,
269
285
  },
270
286
  });
271
287
  };
272
288
 
289
+ const maybeAutoCheckpointFromState = async (state, { trigger = 'post_tool_use', mode = 'milestone' } = {}) => {
290
+ if (!state?.projectSessionId || getMutationSafety().shouldBlock) {
291
+ return false;
292
+ }
293
+
294
+ if (mode === 'read_progress') {
295
+ const filesForUpdate = state.readFiles.slice(-MAX_READ_FILES);
296
+ if (filesForUpdate.length === 0) {
297
+ return false;
298
+ }
299
+
300
+ await summaryTool({
301
+ action: 'auto_append',
302
+ sessionId: state.projectSessionId,
303
+ update: {
304
+ ...(state.promptPreview ? { currentFocus: state.promptPreview } : {}),
305
+ touchedFiles: filesForUpdate,
306
+ },
307
+ maxTokens: STOP_MAX_TOKENS,
308
+ });
309
+
310
+ if (state.taskId) {
311
+ await writeTaskHandoff({
312
+ taskId: state.taskId,
313
+ sessionId: state.projectSessionId,
314
+ fromAgentId: state.agentId ?? null,
315
+ toAgentId: null,
316
+ trigger: 'read_progress',
317
+ summary: {
318
+ currentFocus: state.promptPreview,
319
+ touchedFiles: filesForUpdate,
320
+ pending: [],
321
+ nextStep: null,
322
+ evidence: [`Exploration: ${filesForUpdate.length} file(s) read`],
323
+ },
324
+ });
325
+ }
326
+
327
+ return true;
328
+ }
329
+
330
+ const update = {
331
+ ...(state.promptPreview ? { currentFocus: state.promptPreview } : {}),
332
+ ...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
333
+ };
334
+
335
+ await summaryTool({
336
+ action: 'checkpoint',
337
+ sessionId: state.projectSessionId,
338
+ event: 'milestone',
339
+ update,
340
+ maxTokens: STOP_MAX_TOKENS,
341
+ force: true,
342
+ });
343
+
344
+ if (state.taskId) {
345
+ await writeTaskHandoff({
346
+ taskId: state.taskId,
347
+ sessionId: state.projectSessionId,
348
+ fromAgentId: state.agentId ?? null,
349
+ toAgentId: null,
350
+ trigger,
351
+ summary: {
352
+ currentFocus: state.promptPreview,
353
+ touchedFiles: state.touchedFiles,
354
+ pending: [],
355
+ nextStep: null,
356
+ evidence: state.touchedFiles.length > 0 ? [`Touched files: ${state.touchedFiles.join(', ')}`] : [],
357
+ },
358
+ });
359
+ }
360
+
361
+ return true;
362
+ };
363
+
273
364
  const handleSessionStart = async () => {
274
365
  const result = await startTurn({
275
366
  phase: 'start',
@@ -349,18 +440,60 @@ export const createClaudeAdapter = ({
349
440
  toolInput: input.tool_input,
350
441
  toolResponse: input.tool_response,
351
442
  });
352
-
353
- const nextState = {
443
+ const readPaths = touchedFiles.length === 0 && !checkpoint.matched
444
+ ? extractReadPathsFromToolUse({ toolName: input.tool_name, toolInput: input.tool_input })
445
+ : [];
446
+
447
+ const previousReadFiles = Array.isArray(existing.readFiles) ? existing.readFiles : [];
448
+ const previousReadCount = Number.isFinite(existing.meaningfulReadCount) ? existing.meaningfulReadCount : 0;
449
+ const readFiles = readPaths.length > 0
450
+ ? uniq([...previousReadFiles, ...readPaths]).slice(-MAX_READ_FILES)
451
+ : previousReadFiles;
452
+ const meaningfulReadCount = readPaths.length > 0 ? previousReadCount + 1 : previousReadCount;
453
+
454
+ let nextState = {
354
455
  ...existing,
355
456
  checkpointed: checkpoint.matched ? true : existing.checkpointed,
356
457
  checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
357
458
  touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]).slice(0, MAX_TOUCHED_FILES),
358
459
  meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
460
+ readFiles,
461
+ meaningfulReadCount,
462
+ lastReadCheckpointAt: existing.lastReadCheckpointAt ?? 0,
359
463
  updatedAt: new Date().toISOString(),
360
464
  };
361
465
 
466
+ if (!checkpoint.matched && touchedFiles.length > 0) {
467
+ const autoCheckpointed = await maybeAutoCheckpointFromState(nextState, { trigger: 'post_tool_use' });
468
+ if (autoCheckpointed) {
469
+ nextState = {
470
+ ...nextState,
471
+ checkpointed: true,
472
+ checkpointEvent: 'milestone',
473
+ };
474
+ }
475
+ } else if (
476
+ !checkpoint.matched
477
+ && touchedFiles.length === 0
478
+ && meaningfulReadCount >= READ_CHECKPOINT_THRESHOLD
479
+ && Date.now() - (nextState.lastReadCheckpointAt ?? 0) >= READ_CHECKPOINT_THROTTLE_MS
480
+ ) {
481
+ const autoAppended = await maybeAutoCheckpointFromState(nextState, {
482
+ trigger: 'read_progress',
483
+ mode: 'read_progress',
484
+ });
485
+ if (autoAppended) {
486
+ nextState = {
487
+ ...nextState,
488
+ readFiles: [],
489
+ meaningfulReadCount: 0,
490
+ lastReadCheckpointAt: Date.now(),
491
+ };
492
+ }
493
+ }
494
+
362
495
  await maybeSetTrackedTurnState({ hookKey, state: nextState });
363
- if (checkpoint.matched || touchedFiles.length > 0) {
496
+ if (checkpoint.matched || touchedFiles.length > 0 || readPaths.length > 0) {
364
497
  await recordHookMetrics({
365
498
  action: 'PostToolUse',
366
499
  sessionId: existing.projectSessionId,
@@ -12,7 +12,15 @@ import {
12
12
  upsertAgentRun,
13
13
  } from '../../storage/sqlite.js';
14
14
  import { DEFAULT_START_MAX_TOKENS, resolveManagedStart } from '../base-orchestrator.js';
15
- import { extractNextStep, normalizeWhitespace, truncate } from '../policy/event-policy.js';
15
+ import {
16
+ MAX_READ_FILES,
17
+ READ_CHECKPOINT_THRESHOLD,
18
+ READ_CHECKPOINT_THROTTLE_MS,
19
+ extractNextStep,
20
+ extractReadPathsFromToolUse,
21
+ normalizeWhitespace,
22
+ truncate,
23
+ } from '../policy/event-policy.js';
16
24
 
17
25
  export const HOOK_CLIENT = 'cursor';
18
26
  export const STOP_MAX_TOKENS = 300;
@@ -207,6 +215,11 @@ export const createCursorAdapter = ({
207
215
  const autoStartTriggered = action === 'ConversationStart' || action === 'UserMessageSubmit';
208
216
  const autoCheckpointTriggered = action === 'ConversationEnd' && autoAppended;
209
217
 
218
+ const shouldPersist = autoStartTriggered || autoCheckpointTriggered || blocked || overheadTokens > 0;
219
+ if (!shouldPersist) {
220
+ return;
221
+ }
222
+
210
223
  await persistMetric({
211
224
  tool: 'cursor_hook',
212
225
  action,
@@ -269,10 +282,88 @@ export const createCursorAdapter = ({
269
282
  checkpointEvent: null,
270
283
  touchedFiles: [],
271
284
  meaningfulWriteCount: 0,
285
+ readFiles: [],
286
+ meaningfulReadCount: 0,
287
+ lastReadCheckpointAt: 0,
272
288
  },
273
289
  });
274
290
  };
275
291
 
292
+ const maybeAutoCheckpointFromState = async (state, { trigger = 'post_tool_use', mode = 'milestone' } = {}) => {
293
+ if (!state?.projectSessionId || getMutationSafety().shouldBlock) {
294
+ return false;
295
+ }
296
+
297
+ if (mode === 'read_progress') {
298
+ const filesForUpdate = state.readFiles.slice(-MAX_READ_FILES);
299
+ if (filesForUpdate.length === 0) {
300
+ return false;
301
+ }
302
+
303
+ await summaryTool({
304
+ action: 'auto_append',
305
+ sessionId: state.projectSessionId,
306
+ update: {
307
+ ...(state.promptPreview ? { currentFocus: state.promptPreview } : {}),
308
+ touchedFiles: filesForUpdate,
309
+ },
310
+ maxTokens: STOP_MAX_TOKENS,
311
+ });
312
+
313
+ if (state.taskId) {
314
+ await writeTaskHandoff({
315
+ taskId: state.taskId,
316
+ sessionId: state.projectSessionId,
317
+ fromAgentId: state.agentId ?? null,
318
+ toAgentId: null,
319
+ trigger: 'read_progress',
320
+ summary: {
321
+ currentFocus: state.promptPreview,
322
+ touchedFiles: filesForUpdate,
323
+ pending: [],
324
+ nextStep: null,
325
+ evidence: [`Exploration: ${filesForUpdate.length} file(s) read`],
326
+ },
327
+ });
328
+ }
329
+
330
+ return true;
331
+ }
332
+
333
+ const update = {
334
+ ...(state.promptPreview ? { currentFocus: state.promptPreview } : {}),
335
+ ...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
336
+ };
337
+
338
+ await summaryTool({
339
+ action: 'checkpoint',
340
+ sessionId: state.projectSessionId,
341
+ event: 'milestone',
342
+ update,
343
+ maxTokens: STOP_MAX_TOKENS,
344
+ force: true,
345
+ });
346
+
347
+ if (state.taskId) {
348
+ await writeTaskHandoff({
349
+ taskId: state.taskId,
350
+ sessionId: state.projectSessionId,
351
+ fromAgentId: state.agentId ?? null,
352
+ toAgentId: null,
353
+ trigger,
354
+ summary: {
355
+ currentFocus: state.promptPreview,
356
+ touchedFiles: state.touchedFiles,
357
+ pending: [],
358
+ nextStep: null,
359
+ evidence: state.touchedFiles.length > 0 ? [`Touched files: ${state.touchedFiles.join(', ')}`] : [],
360
+ },
361
+ });
362
+ }
363
+
364
+ return true;
365
+ };
366
+
276
367
  const handleConversationStart = async () => {
277
368
  const result = await startTurn({
278
369
  phase: 'start',
@@ -352,18 +443,60 @@ export const createCursorAdapter = ({
352
443
  toolInput: input.tool_input,
353
444
  toolResponse: input.tool_response,
354
445
  });
355
-
356
- const nextState = {
446
+ const readPaths = touchedFiles.length === 0 && !checkpoint.matched
447
+ ? extractReadPathsFromToolUse({ toolName: input.tool_name, toolInput: input.tool_input })
448
+ : [];
449
+
450
+ const previousReadFiles = Array.isArray(existing.readFiles) ? existing.readFiles : [];
451
+ const previousReadCount = Number.isFinite(existing.meaningfulReadCount) ? existing.meaningfulReadCount : 0;
452
+ const readFiles = readPaths.length > 0
453
+ ? uniq([...previousReadFiles, ...readPaths]).slice(-MAX_READ_FILES)
454
+ : previousReadFiles;
455
+ const meaningfulReadCount = readPaths.length > 0 ? previousReadCount + 1 : previousReadCount;
456
+
457
+ let nextState = {
357
458
  ...existing,
358
459
  checkpointed: checkpoint.matched ? true : existing.checkpointed,
359
460
  checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
360
461
  touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]).slice(0, MAX_TOUCHED_FILES),
361
462
  meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
463
+ readFiles,
464
+ meaningfulReadCount,
465
+ lastReadCheckpointAt: existing.lastReadCheckpointAt ?? 0,
362
466
  updatedAt: new Date().toISOString(),
363
467
  };
364
468
 
469
+ if (!checkpoint.matched && touchedFiles.length > 0) {
470
+ const autoCheckpointed = await maybeAutoCheckpointFromState(nextState, { trigger: 'post_tool_use' });
471
+ if (autoCheckpointed) {
472
+ nextState = {
473
+ ...nextState,
474
+ checkpointed: true,
475
+ checkpointEvent: 'milestone',
476
+ };
477
+ }
478
+ } else if (
479
+ !checkpoint.matched
480
+ && touchedFiles.length === 0
481
+ && meaningfulReadCount >= READ_CHECKPOINT_THRESHOLD
482
+ && Date.now() - (nextState.lastReadCheckpointAt ?? 0) >= READ_CHECKPOINT_THROTTLE_MS
483
+ ) {
484
+ const autoAppended = await maybeAutoCheckpointFromState(nextState, {
485
+ trigger: 'read_progress',
486
+ mode: 'read_progress',
487
+ });
488
+ if (autoAppended) {
489
+ nextState = {
490
+ ...nextState,
491
+ readFiles: [],
492
+ meaningfulReadCount: 0,
493
+ lastReadCheckpointAt: Date.now(),
494
+ };
495
+ }
496
+ }
497
+
365
498
  await maybeSetTrackedTurnState({ hookKey, state: nextState });
366
- if (checkpoint.matched || touchedFiles.length > 0) {
499
+ if (checkpoint.matched || touchedFiles.length > 0 || readPaths.length > 0) {
367
500
  await recordHookMetrics({
368
501
  action: 'PostToolUse',
369
502
  sessionId: existing.projectSessionId,
@@ -3,6 +3,11 @@ import { smartSearch } from '../../tools/smart-search.js';
3
3
 
4
4
  export const SAFE_CONTINUITY_STATES = new Set(['aligned', 'resume']);
5
5
 
6
+ export const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'SemanticSearch']);
7
+ export const READ_CHECKPOINT_THRESHOLD = 8;
8
+ export const READ_CHECKPOINT_THROTTLE_MS = 60_000;
9
+ export const MAX_READ_FILES = 12;
10
+
6
11
  export const MAX_TOP_FILES = 3;
7
12
  export const MAX_PREFLIGHT_HINTS = 2;
8
13
  export const MAX_FOCUS_LENGTH = 140;
@@ -63,6 +68,29 @@ export const truncate = (value, maxLength = DEFAULT_TRUNCATE_LENGTH) => {
63
68
 
64
69
  const asArray = (value) => Array.isArray(value) ? value : [];
65
70
 
71
+ const isMeaningfulPath = (value) => typeof value === 'string' && value.trim().length > 1;
72
+
73
+ export const extractReadPathsFromToolUse = ({ toolName, toolInput } = {}) => {
74
+ if (!toolName || !READ_TOOLS.has(toolName) || !toolInput || typeof toolInput !== 'object') {
75
+ return [];
76
+ }
77
+
78
+ const candidates = [];
79
+
80
+ if (toolName === 'Read') {
81
+ candidates.push(toolInput.path, toolInput.file_path, toolInput.filePath);
82
+ } else if (toolName === 'Grep' || toolName === 'SemanticSearch') {
83
+ candidates.push(toolInput.path);
84
+ if (Array.isArray(toolInput.target_directories)) {
85
+ candidates.push(...toolInput.target_directories);
86
+ }
87
+ } else if (toolName === 'Glob') {
88
+ candidates.push(toolInput.target_directory);
89
+ }
90
+
91
+ return [...new Set(candidates.filter(isMeaningfulPath))];
92
+ };
93
+
66
94
  export const uniqueCompact = (values) => [...new Set(
67
95
  asArray(values)
68
96
  .map((value) => normalizeWhitespace(value))
package/src/server.js CHANGED
@@ -41,6 +41,7 @@ smart_turn (session continuity — read this before calling):
41
41
  - START: phase "start". Pass userPrompt (current goal). ensureSession true recommended when you want persistence. Use at the beginning of substantial work or when resuming after a break — not for one-line fixes or single-shot questions.
42
42
  - END: phase "end". Pass event: milestone | blocker | task_complete. Pass sessionId if you have it; include update (nextStep, completed, etc.) when checkpointing progress. Call after a meaningful slice of work (close a phase), not after every trivial edit.
43
43
  - SKIP smart_turn entirely for trivial or same-session point tasks (the tool schema also warns about this).
44
+ - smart_resume is the cheap shortcut for the first prompt of a substantial task — equivalent to smart_turn(start, ensureSession=true, verbosity=minimal).
44
45
 
45
46
  Source of truth: devctx does not replace git history, PRs, or repo docs (e.g. MIGRATION.md). If end was not called or work was not committed, those remain authoritative.
46
47
 
@@ -508,8 +509,9 @@ export const createDevctxServer = () => {
508
509
  includeMetrics: z.boolean().optional(),
509
510
  metricsWindow: z.enum(['24h', '7d', '30d', 'all']).optional(),
510
511
  latestMetrics: z.number().int().min(1).max(20).optional(),
512
+ verbosity: z.enum(['minimal', 'standard', 'full']).optional().describe('Default "minimal" — returns compact recommendedPath/continuity/task. Use "standard" or "full" only when you need long instructions, candidates, or full checkpoint diagnostics.'),
511
513
  },
512
- async ({ phase, sessionId, prompt, update, event, force, maxTokens, ensureSession, includeMetrics, metricsWindow, latestMetrics }) =>
514
+ async ({ phase, sessionId, prompt, update, event, force, maxTokens, ensureSession, includeMetrics, metricsWindow, latestMetrics, verbosity }) =>
513
515
  asTextResult(await smartTurn({
514
516
  phase,
515
517
  sessionId,
@@ -522,6 +524,29 @@ export const createDevctxServer = () => {
522
524
  includeMetrics,
523
525
  metricsWindow,
524
526
  latestMetrics,
527
+ verbosity,
528
+ })),
529
+ );
530
+
531
+ server.tool(
532
+ 'smart_resume',
533
+ 'Lightweight entry point for the first prompt of a substantial task. Equivalent to smart_turn(phase=start, ensureSession=true, verbosity=minimal): rehydrates the most recent persisted session for this project, classifies prompt continuity, and returns a compact recommendedPath. Prefer this over smart_turn(start) when you just want to recover context cheaply at the beginning of a session. SKIP for one-off lookups, single-line fixes, or trivial questions where re-reading is faster than rehydration.',
534
+ {
535
+ prompt: z.string().optional(),
536
+ sessionId: z.string().optional(),
537
+ taskId: z.string().optional(),
538
+ maxTokens: z.number().int().min(100).max(2000).optional(),
539
+ verbosity: z.enum(['minimal', 'standard', 'full']).optional(),
540
+ },
541
+ async ({ prompt, sessionId, taskId, maxTokens, verbosity }) =>
542
+ asTextResult(await smartTurn({
543
+ phase: 'start',
544
+ prompt,
545
+ sessionId,
546
+ taskId,
547
+ maxTokens,
548
+ ensureSession: true,
549
+ verbosity: verbosity ?? 'minimal',
525
550
  })),
526
551
  );
527
552
 
@@ -511,6 +511,16 @@ export const getMeta = (db, key) => {
511
511
  return row?.value ?? null;
512
512
  };
513
513
 
514
+ const DEFAULT_GC_RETENTION_DAYS = 30;
515
+ const DEFAULT_GC_THROTTLE_MS = 24 * 60 * 60 * 1000;
516
+ const STORAGE_GC_META_KEY = 'last_storage_gc_at';
517
+
518
+ const sanitizeRetentionDays = (value) => {
519
+ const parsed = Number(value);
520
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_GC_RETENTION_DAYS;
521
+ return Math.min(365, Math.max(1, Math.floor(parsed)));
522
+ };
523
+
514
524
  const getSchemaVersion = (db) => Number(getMeta(db, 'schema_version') ?? 0);
515
525
  const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
516
526
 
@@ -1456,6 +1466,42 @@ export const upsertAgentRun = async ({ filePath = getStateDbPath(), runId, taskI
1456
1466
  };
1457
1467
  }, { filePath });
1458
1468
 
1469
+ export const runStorageMaintenance = async ({
1470
+ filePath = getStateDbPath(),
1471
+ retentionDays = DEFAULT_GC_RETENTION_DAYS,
1472
+ throttleMs = DEFAULT_GC_THROTTLE_MS,
1473
+ force = false,
1474
+ } = {}) => withStateDb((db) => {
1475
+ const now = Date.now();
1476
+ const lastRun = Number(getMeta(db, STORAGE_GC_META_KEY) ?? 0);
1477
+ if (!force && now - lastRun < throttleMs) {
1478
+ return { skipped: true, reason: 'throttled', lastRunAt: lastRun || null };
1479
+ }
1480
+
1481
+ const days = sanitizeRetentionDays(retentionDays);
1482
+ const cutoffIso = new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
1483
+
1484
+ const removeOlder = (sql) => db.prepare(sql).run(cutoffIso).changes;
1485
+
1486
+ const removed = {
1487
+ metricsEvents: removeOlder('DELETE FROM metrics_events WHERE created_at < ?'),
1488
+ sessionEvents: removeOlder('DELETE FROM session_events WHERE created_at < ?'),
1489
+ taskHandoffs: removeOlder('DELETE FROM task_handoffs WHERE created_at < ?'),
1490
+ agentRuns: removeOlder('DELETE FROM agent_runs WHERE updated_at < ?'),
1491
+ workflowMetrics: removeOlder('DELETE FROM workflow_metrics WHERE created_at < ?'),
1492
+ contextAccess: removeOlder('DELETE FROM context_access WHERE timestamp < ?'),
1493
+ };
1494
+
1495
+ setMeta(db, STORAGE_GC_META_KEY, String(now));
1496
+
1497
+ return {
1498
+ skipped: false,
1499
+ runAt: now,
1500
+ retentionDays: days,
1501
+ removed,
1502
+ };
1503
+ }, { filePath });
1504
+
1459
1505
  const listLegacySessionFiles = (sessionsDir) => {
1460
1506
  if (!fs.existsSync(sessionsDir)) {
1461
1507
  return [];
@@ -332,30 +332,21 @@ const buildCompactResult = (groups, totalMatches, query, root, searchMode, prove
332
332
  }
333
333
 
334
334
  const modeLabel = searchMode === 'exact' ? '' : searchMode === 'regex' ? ' [regex fallback]' : ` [term expansion: ${(provenance?.expandedTerms ?? []).join(', ')}]`;
335
-
336
335
  const topGroups = groups.slice(0, MAX_RESULT_FILES);
337
336
 
338
337
  if (totalMatches <= 20) {
339
- const header = modeLabel ? `# Search mode:${modeLabel}\n\n` : '';
338
+ const header = modeLabel ? `# Search mode:${modeLabel}\n` : '';
340
339
  return header + topGroups
341
340
  .flatMap((group) => group.matches)
342
341
  .map(formatMatch)
343
342
  .join('\n');
344
343
  }
345
344
 
346
- const lines = [
347
- `query: ${query}${modeLabel}`,
348
- `total: ${totalMatches} matches in ${totalFiles ?? groups.length} files${totalFiles && totalFiles > groups.length ? ` (showing top ${groups.length})` : ''}`,
349
- '',
350
- '# Top files',
351
- ];
352
-
353
- for (const group of topGroups.slice(0, 10)) {
354
- lines.push(`${group.count} match(es), score ${group.score} :: ${group.file}`);
345
+ const lines = [];
346
+ if (modeLabel) {
347
+ lines.push(`# Search mode:${modeLabel}`);
355
348
  }
356
349
 
357
- lines.push('', '# Sample matches');
358
-
359
350
  const topScore = topGroups[0]?.score ?? 0;
360
351
  for (const group of topGroups.slice(0, 5)) {
361
352
  const linesPerFile = group.score >= topScore * 0.7 ? 5 : 2;
@@ -366,7 +357,7 @@ const buildCompactResult = (groups, totalMatches, query, root, searchMode, prove
366
357
 
367
358
  const fileCount = totalFiles ?? groups.length;
368
359
  if (fileCount > 30) {
369
- lines.push('', `# Note: ${fileCount} files matched — query may be too broad. Use Grep for exact pattern matching.`);
360
+ lines.push(`# Note: ${fileCount} files matched — query may be too broad. Use Grep for exact pattern matching.`);
370
361
  }
371
362
 
372
363
  return lines.join('\n');
@@ -535,7 +526,7 @@ export const smartSearch = async ({ query, cwd = '.', intent, maxFiles, _testFor
535
526
  totalMatches: dedupedMatches.length,
536
527
  matchedFiles: cappedGroups.length,
537
528
  ...(groups.length > cappedGroups.length ? { totalFiles: groups.length } : {}),
538
- topFiles: cappedGroups.slice(0, 10).map((group) => ({ file: group.file, count: group.count, score: group.score })),
529
+ topFiles: cappedGroups.slice(0, 5).map((group) => ({ file: group.file, count: group.count, score: group.score })),
539
530
  matches: compressedText,
540
531
  };
541
532
 
@@ -185,6 +185,19 @@ const MAX_DIFF_FILES = 8;
185
185
  const MAX_LINES_PER_FILE = 60;
186
186
  const DIFF_TOTAL_LIMIT = 4000;
187
187
 
188
+ const TAP_HEADER = /^TAP version/;
189
+ const TAP_OK_LINE = /^\s*ok\s+\d+/;
190
+ const TAP_NOT_OK_LINE = /^\s*not ok\s+\d+/;
191
+ const TAP_SUBTEST_LINE = /^\s*#\s+Subtest:/;
192
+ const TAP_YAML_FENCE = /^\s*(---|\.\.\.)\s*$/;
193
+ const TAP_SUMMARY_LINE = /^\s*#\s+(tests|pass|fail|skipped|todo|duration_ms|cancelled|suites)\b/;
194
+ const TAP_DETECT = /(^|\n)(TAP version |# tests \d+)/;
195
+ const TAP_FAIL_CONTEXT = 4;
196
+
197
+ const GIT_LOG_COMMIT_HEADER = /^commit [0-9a-f]{40}/;
198
+ const GIT_LOG_DETECT = /^commit [0-9a-f]{40}\nAuthor:/m;
199
+ const MAX_GIT_LOG_COMMITS = 40;
200
+
188
201
  const splitDiffByFile = (text) => {
189
202
  const files = [];
190
203
  let current = null;
@@ -201,6 +214,95 @@ const splitDiffByFile = (text) => {
201
214
  return files;
202
215
  };
203
216
 
217
+ export const compressTapOutput = (text) => {
218
+ const lines = text.split('\n');
219
+ const summary = [];
220
+ const failures = [];
221
+ const headerLines = [];
222
+ let inYaml = false;
223
+ let yamlFailureBuffer = null;
224
+
225
+ for (let i = 0; i < lines.length; i++) {
226
+ const line = lines[i];
227
+
228
+ if (TAP_HEADER.test(line) && headerLines.length === 0) {
229
+ headerLines.push(line);
230
+ continue;
231
+ }
232
+
233
+ if (TAP_SUMMARY_LINE.test(line)) {
234
+ summary.push(line);
235
+ continue;
236
+ }
237
+
238
+ if (TAP_NOT_OK_LINE.test(line)) {
239
+ failures.push(line);
240
+ yamlFailureBuffer = failures;
241
+ inYaml = false;
242
+ continue;
243
+ }
244
+
245
+ if (TAP_YAML_FENCE.test(line)) {
246
+ if (yamlFailureBuffer) yamlFailureBuffer.push(line);
247
+ inYaml = !inYaml;
248
+ if (!inYaml) yamlFailureBuffer = null;
249
+ continue;
250
+ }
251
+
252
+ if (inYaml && yamlFailureBuffer) {
253
+ if (yamlFailureBuffer.length - failures.indexOf(yamlFailureBuffer[0]) <= TAP_FAIL_CONTEXT + 6) {
254
+ yamlFailureBuffer.push(line);
255
+ }
256
+ continue;
257
+ }
258
+
259
+ if (TAP_OK_LINE.test(line) || TAP_SUBTEST_LINE.test(line)) {
260
+ continue;
261
+ }
262
+
263
+ if (line.trim().startsWith('#') && failures.length > 0 && failures.length < 200) {
264
+ failures.push(line);
265
+ }
266
+ }
267
+
268
+ const parts = [];
269
+ if (headerLines.length > 0) parts.push(headerLines.join('\n'));
270
+ if (failures.length > 0) {
271
+ parts.push(`# ${failures.filter((l) => TAP_NOT_OK_LINE.test(l)).length} failure(s):`);
272
+ parts.push(failures.join('\n'));
273
+ } else {
274
+ parts.push('# all tests passed (ok lines collapsed)');
275
+ }
276
+ if (summary.length > 0) parts.push(summary.join('\n'));
277
+
278
+ return parts.join('\n');
279
+ };
280
+
281
+ export const compressGitLog = (text) => {
282
+ const lines = text.split('\n');
283
+ const commits = [];
284
+ let current = null;
285
+
286
+ for (const line of lines) {
287
+ if (GIT_LOG_COMMIT_HEADER.test(line)) {
288
+ if (current) commits.push(current);
289
+ current = { sha: line.split(' ')[1], subject: '' };
290
+ } else if (current && !current.subject && line.trim() && !/^(Author|Date|Merge|commit):/i.test(line)) {
291
+ current.subject = line.trim();
292
+ }
293
+ }
294
+ if (current) commits.push(current);
295
+
296
+ if (commits.length === 0) return text;
297
+
298
+ const shown = commits.slice(0, MAX_GIT_LOG_COMMITS);
299
+ const skipped = commits.length - shown.length;
300
+ const body = shown.map(({ sha, subject }) => `${sha.slice(0, 7)} ${subject}`).join('\n');
301
+ return skipped > 0
302
+ ? `${body}\n# ${skipped} more commit(s) not shown — narrow with --since/--until or git log -n <N>`
303
+ : body;
304
+ };
305
+
204
306
  const compressDiff = (text) => {
205
307
  if (!DIFF_FILE_HEADER.test(text)) return text;
206
308
 
@@ -301,8 +403,20 @@ export const smartShell = async ({ command }) => {
301
403
  'entity not found',
302
404
  ]);
303
405
  const shouldPrioritizeRelevant = execution.code !== 0 || execution.timedOut;
304
- const compressedSource = shouldPrioritizeRelevant && relevant ? relevant : rawText;
305
- const compressedText = truncate(compressDiff(uniqueLines(compressedSource)), 5000);
406
+ const isTap = TAP_DETECT.test(rawText);
407
+ const isGitLog = !isTap && GIT_LOG_DETECT.test(rawText);
408
+ let stagedCompression;
409
+
410
+ if (isTap) {
411
+ stagedCompression = compressTapOutput(rawText);
412
+ } else if (isGitLog) {
413
+ stagedCompression = compressGitLog(rawText);
414
+ } else {
415
+ const compressedSource = shouldPrioritizeRelevant && relevant ? relevant : rawText;
416
+ stagedCompression = compressDiff(uniqueLines(compressedSource));
417
+ }
418
+
419
+ const compressedText = truncate(stagedCompression, 5000);
306
420
  const metrics = buildMetrics({
307
421
  tool: 'smart_shell',
308
422
  target: command,
@@ -19,6 +19,16 @@ import {
19
19
 
20
20
  const MAX_SESSION_AGE_MS = 30 * 24 * 60 * 60 * 1000;
21
21
  const DEFAULT_MAX_TOKENS = 500;
22
+ const ROLLING_LIMIT = 30;
23
+ const ROLLING_KEEP = 20;
24
+
25
+ const applyRollingWindow = (items) => {
26
+ if (!Array.isArray(items) || items.length <= ROLLING_LIMIT) {
27
+ return { items: Array.isArray(items) ? items : [], archived: 0 };
28
+ }
29
+ const archived = items.length - ROLLING_KEEP;
30
+ return { items: items.slice(-ROLLING_KEEP), archived };
31
+ };
22
32
  const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
23
33
  const ACTIVE_STATUSES = new Set(['planning', 'in_progress', 'blocked']);
24
34
  const DEFAULT_STATUS = 'in_progress';
@@ -735,6 +745,65 @@ const getLatestTaskHandoffRow = (db, taskId) => db.prepare(`
735
745
  LIMIT 1
736
746
  `).get(taskId);
737
747
 
748
+ const HANDOFF_FALLBACK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
749
+
750
+ const getMostRecentTaskHandoffRow = (db) => db.prepare(`
751
+ SELECT
752
+ handoff_id,
753
+ task_id,
754
+ session_id,
755
+ from_agent_id,
756
+ to_agent_id,
757
+ trigger,
758
+ summary_json,
759
+ created_at
760
+ FROM task_handoffs
761
+ ORDER BY datetime(created_at) DESC, handoff_id DESC
762
+ LIMIT 1
763
+ `).get();
764
+
765
+ const buildSummaryFromHandoff = (handoff, taskRecord = null) => {
766
+ const summary = handoff?.summary ?? {};
767
+ const touchedFiles = mergeUniqueStrings(summary.touchedFiles);
768
+ const pending = mergeUniqueStrings(summary.pending);
769
+
770
+ return pruneEmptyFields({
771
+ status: normalizeStatus(taskRecord?.status, 'in_progress'),
772
+ nextStep: isMeaningfulString(summary.nextStep) ? summary.nextStep : undefined,
773
+ currentFocus: isMeaningfulString(summary.currentFocus) ? summary.currentFocus : undefined,
774
+ goal: isMeaningfulString(taskRecord?.canonicalGoal) ? taskRecord.canonicalGoal : undefined,
775
+ pinnedContext: pending.slice(0, 3),
776
+ hotFiles: uniqueTail(touchedFiles.map(compactFilePath), 5),
777
+ touchedFilesCount: touchedFiles.length,
778
+ completedCount: 0,
779
+ decisionsCount: 0,
780
+ });
781
+ };
782
+
783
+ const buildHandoffFallback = (db) => {
784
+ const handoffRow = getMostRecentTaskHandoffRow(db);
785
+ if (!handoffRow) {
786
+ return null;
787
+ }
788
+
789
+ const ageMs = Date.now() - getTimestamp(handoffRow.created_at, 0);
790
+ if (!Number.isFinite(ageMs) || ageMs > HANDOFF_FALLBACK_MAX_AGE_MS) {
791
+ return null;
792
+ }
793
+
794
+ const handoff = normalizeTaskHandoff(handoffRow);
795
+ const taskRecord = handoff?.taskId ? normalizeTaskRow(getTaskRow(db, handoff.taskId)) : null;
796
+ const summary = buildSummaryFromHandoff(handoff, taskRecord);
797
+ const tokens = countTokens(JSON.stringify(summary));
798
+
799
+ return {
800
+ summary,
801
+ tokens,
802
+ handoff,
803
+ task: taskRecord,
804
+ };
805
+ };
806
+
738
807
  const normalizeTaskHandoff = (row) => {
739
808
  if (!row) {
740
809
  return null;
@@ -761,6 +830,13 @@ const hydrateSession = (row) => {
761
830
  const completed = mergeUniqueStrings(snapshot.completed);
762
831
  const decisions = mergeUniqueStrings(snapshot.decisions);
763
832
  const touchedFiles = mergeUniqueStrings(snapshot.touchedFiles);
833
+ const archivedCounts = snapshot.archivedCounts && typeof snapshot.archivedCounts === 'object'
834
+ ? {
835
+ completed: Number(snapshot.archivedCounts.completed ?? 0) || 0,
836
+ decisions: Number(snapshot.archivedCounts.decisions ?? 0) || 0,
837
+ touchedFiles: Number(snapshot.archivedCounts.touchedFiles ?? 0) || 0,
838
+ }
839
+ : { completed: 0, decisions: 0, touchedFiles: 0 };
764
840
  const pinnedContext = mergeUniqueStrings(parseJsonText(row.pinned_context_json, []), snapshot.pinnedContext);
765
841
  const unresolvedQuestions = mergeUniqueStrings(parseJsonText(row.unresolved_questions_json, []), snapshot.unresolvedQuestions);
766
842
  const blockers = mergeUniqueStrings(parseJsonText(row.blockers_json, []), snapshot.blockers);
@@ -784,9 +860,10 @@ const hydrateSession = (row) => {
784
860
  completed,
785
861
  decisions,
786
862
  touchedFiles,
787
- completedCount: Number.isInteger(row.completed_count) ? row.completed_count : completed.length,
788
- decisionsCount: Number.isInteger(row.decisions_count) ? row.decisions_count : decisions.length,
789
- touchedFilesCount: Number.isInteger(row.touched_files_count) ? row.touched_files_count : touchedFiles.length,
863
+ archivedCounts,
864
+ completedCount: Number.isInteger(row.completed_count) ? row.completed_count : completed.length + archivedCounts.completed,
865
+ decisionsCount: Number.isInteger(row.decisions_count) ? row.decisions_count : decisions.length + archivedCounts.decisions,
866
+ touchedFilesCount: Number.isInteger(row.touched_files_count) ? row.touched_files_count : touchedFiles.length + archivedCounts.touchedFiles,
790
867
  createdAt: row.created_at,
791
868
  updatedAt: row.updated_at,
792
869
  };
@@ -821,7 +898,7 @@ const buildSessionSummary = (session) => {
821
898
  const compressSummary = (data, maxTokens) => {
822
899
  const baseSummary = buildSessionSummary(data);
823
900
  let compressed = baseSummary;
824
- let summary = JSON.stringify(compressed, null, 2);
901
+ let summary = JSON.stringify(compressed);
825
902
  let tokens = countTokens(summary);
826
903
 
827
904
  if (tokens <= maxTokens) {
@@ -830,7 +907,7 @@ const compressSummary = (data, maxTokens) => {
830
907
 
831
908
  const recomputeTokens = () => {
832
909
  compressed = pruneEmptyFields(compressed);
833
- summary = JSON.stringify(compressed, null, 2);
910
+ summary = JSON.stringify(compressed);
834
911
  tokens = countTokens(summary);
835
912
  };
836
913
 
@@ -1005,13 +1082,23 @@ const saveSession = (db, sessionId, data, { action, eventPayload } = {}) => {
1005
1082
  branchName: data.branchName ?? existing?.branchName ?? null,
1006
1083
  worktreePath: data.worktreePath ?? existing?.worktreePath ?? null,
1007
1084
  });
1008
- const completed = mergeUniqueStrings(data.completed);
1009
- const decisions = mergeUniqueStrings(data.decisions);
1010
- const touchedFiles = mergeUniqueStrings(data.touchedFiles);
1085
+ const completedRoll = applyRollingWindow(mergeUniqueStrings(data.completed));
1086
+ const decisionsRoll = applyRollingWindow(mergeUniqueStrings(data.decisions));
1087
+ const touchedRoll = applyRollingWindow(mergeUniqueStrings(data.touchedFiles));
1088
+ const completed = completedRoll.items;
1089
+ const decisions = decisionsRoll.items;
1090
+ const touchedFiles = touchedRoll.items;
1011
1091
  const pinnedContext = mergeUniqueStrings(data.pinnedContext);
1012
1092
  const unresolvedQuestions = mergeUniqueStrings(data.unresolvedQuestions);
1013
1093
  const blockers = mergeUniqueStrings(data.blockers);
1014
1094
 
1095
+ const previousArchived = existing?.archivedCounts ?? {};
1096
+ const archivedCounts = {
1097
+ completed: (previousArchived.completed ?? 0) + completedRoll.archived,
1098
+ decisions: (previousArchived.decisions ?? 0) + decisionsRoll.archived,
1099
+ touchedFiles: (previousArchived.touchedFiles ?? 0) + touchedRoll.archived,
1100
+ };
1101
+
1015
1102
  const snapshot = {
1016
1103
  taskId: task.taskId,
1017
1104
  agentId: data.agentId ?? existing?.agentId ?? null,
@@ -1028,9 +1115,10 @@ const saveSession = (db, sessionId, data, { action, eventPayload } = {}) => {
1028
1115
  blockers,
1029
1116
  nextStep: data.nextStep ?? '',
1030
1117
  touchedFiles,
1031
- completedCount: completed.length,
1032
- decisionsCount: decisions.length,
1033
- touchedFilesCount: touchedFiles.length,
1118
+ archivedCounts,
1119
+ completedCount: completed.length + archivedCounts.completed,
1120
+ decisionsCount: decisions.length + archivedCounts.decisions,
1121
+ touchedFilesCount: touchedFiles.length + archivedCounts.touchedFiles,
1034
1122
  schemaVersion: SQLITE_SCHEMA_VERSION,
1035
1123
  sessionId,
1036
1124
  createdAt,
@@ -1446,6 +1534,23 @@ export const smartSummary = async ({
1446
1534
  cleanup: allowReadSideEffects,
1447
1535
  });
1448
1536
  if (!resolution.found) {
1537
+ const handoffFallback = buildHandoffFallback(db);
1538
+ if (handoffFallback) {
1539
+ return addRepoSafety({
1540
+ action: 'get',
1541
+ sessionId: null,
1542
+ found: true,
1543
+ recoveredFromHandoff: true,
1544
+ autoResumed: false,
1545
+ ambiguous: false,
1546
+ summary: handoffFallback.summary,
1547
+ tokens: handoffFallback.tokens,
1548
+ compressionLevel: 'handoff',
1549
+ ...(handoffFallback.task ? { task: handoffFallback.task } : {}),
1550
+ handoff: handoffFallback.handoff,
1551
+ message: 'Recovered from latest task handoff (no live session).',
1552
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1553
+ }
1449
1554
  return addRepoSafety({
1450
1555
  action: 'get',
1451
1556
  sessionId: null,
@@ -1463,6 +1568,21 @@ export const smartSummary = async ({
1463
1568
  }
1464
1569
 
1465
1570
  if (!targetSessionId) {
1571
+ const handoffFallback = buildHandoffFallback(db);
1572
+ if (handoffFallback) {
1573
+ return addRepoSafety({
1574
+ action: 'get',
1575
+ sessionId: null,
1576
+ found: true,
1577
+ recoveredFromHandoff: true,
1578
+ summary: handoffFallback.summary,
1579
+ tokens: handoffFallback.tokens,
1580
+ compressionLevel: 'handoff',
1581
+ ...(handoffFallback.task ? { task: handoffFallback.task } : {}),
1582
+ handoff: handoffFallback.handoff,
1583
+ message: 'Recovered from latest task handoff (no live session).',
1584
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1585
+ }
1466
1586
  return addRepoSafety({
1467
1587
  action: 'get',
1468
1588
  sessionId: null,
@@ -1473,6 +1593,21 @@ export const smartSummary = async ({
1473
1593
 
1474
1594
  const session = hydrateSession(getSessionRow(db, targetSessionId));
1475
1595
  if (!session) {
1596
+ const handoffFallback = buildHandoffFallback(db);
1597
+ if (handoffFallback) {
1598
+ return addRepoSafety({
1599
+ action: 'get',
1600
+ sessionId: targetSessionId,
1601
+ found: true,
1602
+ recoveredFromHandoff: true,
1603
+ summary: handoffFallback.summary,
1604
+ tokens: handoffFallback.tokens,
1605
+ compressionLevel: 'handoff',
1606
+ ...(handoffFallback.task ? { task: handoffFallback.task } : {}),
1607
+ handoff: handoffFallback.handoff,
1608
+ message: 'Session not found; recovered context from latest task handoff.',
1609
+ }, mutationSafety.repoSafety, suppressReadSideEffects);
1610
+ }
1476
1611
  return addRepoSafety({
1477
1612
  action: 'get',
1478
1613
  sessionId: targetSessionId,
@@ -1,4 +1,5 @@
1
1
  import { buildIndexIncremental, persistIndex } from '../index.js';
2
+ import { triggerBackgroundIndexBuild } from '../index-manager.js';
2
3
  import { projectRoot } from '../utils/runtime-config.js';
3
4
  import {
4
5
  autoTrackWorkflow,
@@ -7,6 +8,7 @@ import {
7
8
  isWorkflowTrackingEnabled,
8
9
  } from '../workflow-tracker.js';
9
10
  import { persistMetrics } from '../metrics.js';
11
+ import { runStorageMaintenance } from '../storage/sqlite.js';
10
12
  import { PRODUCT_QUALITY_ANALYTICS_KIND } from '../analytics/product-quality.js';
11
13
  import { attachSafetyMetadata, buildMutationSafety } from '../utils/mutation-safety.js';
12
14
  import { smartContext } from './smart-context.js';
@@ -16,6 +18,12 @@ import { smartSummary } from './smart-summary.js';
16
18
  const isStorageUnhealthy = (health) =>
17
19
  health && health.status !== 'ok' && health.status !== null && health.status !== undefined;
18
20
 
21
+ const VALID_VERBOSITIES = new Set(['minimal', 'standard', 'full']);
22
+ const DEFAULT_VERBOSITY = 'minimal';
23
+
24
+ const normalizeVerbosity = (value) =>
25
+ typeof value === 'string' && VALID_VERBOSITIES.has(value) ? value : DEFAULT_VERBOSITY;
26
+
19
27
  const DEFAULT_START_MAX_TOKENS = 400;
20
28
  const DEFAULT_END_MAX_TOKENS = 500;
21
29
  const DEFAULT_END_EVENT = 'milestone';
@@ -253,6 +261,7 @@ const buildStartRecommendedPath = ({
253
261
  mutationSafety,
254
262
  autoCreated,
255
263
  isolatedSession,
264
+ verbosity = DEFAULT_VERBOSITY,
256
265
  }) => {
257
266
  const nextTools = [];
258
267
  const steps = [];
@@ -310,15 +319,23 @@ const buildStartRecommendedPath = ({
310
319
  ));
311
320
  }
312
321
 
322
+ const mode = mutationSafety?.blocked
323
+ ? 'blocked_guided'
324
+ : refreshedContext
325
+ ? 'guided_refresh'
326
+ : hasMeaningfulPrompt(prompt)
327
+ ? 'guided_context'
328
+ : 'lightweight';
329
+ const dedupedTools = [...new Set(nextTools)];
330
+ const next = steps[0] ? `${steps[0].tool}: ${steps[0].instruction}` : (dedupedTools[0] ?? '');
331
+
332
+ if (verbosity === 'minimal') {
333
+ return { phase: 'start', mode, nextTools: dedupedTools, next };
334
+ }
335
+
313
336
  return {
314
337
  phase: 'start',
315
- mode: mutationSafety?.blocked
316
- ? 'blocked_guided'
317
- : refreshedContext
318
- ? 'guided_refresh'
319
- : hasMeaningfulPrompt(prompt)
320
- ? 'guided_context'
321
- : 'lightweight',
338
+ mode,
322
339
  contextSource: refreshedContext
323
340
  ? 'refreshed_context'
324
341
  : continuity?.shouldReuseContext
@@ -328,12 +345,13 @@ const buildStartRecommendedPath = ({
328
345
  ensureSessionRecommended: Boolean(hasMeaningfulPrompt(prompt) && (ensureSession || !summaryResult?.found)),
329
346
  autoCreated,
330
347
  isolatedSession,
331
- nextTools: [...new Set(nextTools)],
348
+ nextTools: dedupedTools,
349
+ next,
332
350
  instructions: steps.map((s) => `${s.tool}: ${s.instruction}`).join(' | '),
333
351
  };
334
352
  };
335
353
 
336
- const buildEndRecommendedPath = ({ event, checkpoint, mutationSafety, workflow }) => {
354
+ const buildEndRecommendedPath = ({ event, checkpoint, mutationSafety, workflow, verbosity = DEFAULT_VERBOSITY }) => {
337
355
  const nextTools = [];
338
356
  const steps = [];
339
357
 
@@ -367,15 +385,24 @@ const buildEndRecommendedPath = ({ event, checkpoint, mutationSafety, workflow }
367
385
  ));
368
386
  }
369
387
 
388
+ const mode = mutationSafety?.blocked
389
+ ? 'blocked_guided'
390
+ : checkpoint?.skipped
391
+ ? 'continue_until_milestone'
392
+ : 'checkpointed';
393
+ const dedupedTools = [...new Set(nextTools)];
394
+ const next = steps[0] ? `${steps[0].tool}: ${steps[0].instruction}` : (dedupedTools[0] ?? '');
395
+
396
+ if (verbosity === 'minimal') {
397
+ return { phase: 'end', mode, nextTools: dedupedTools, next };
398
+ }
399
+
370
400
  return {
371
401
  phase: 'end',
372
- mode: mutationSafety?.blocked
373
- ? 'blocked_guided'
374
- : checkpoint?.skipped
375
- ? 'continue_until_milestone'
376
- : 'checkpointed',
402
+ mode,
377
403
  checkpointEvent: event,
378
- nextTools: [...new Set(nextTools)],
404
+ nextTools: dedupedTools,
405
+ next,
379
406
  instructions: steps.map((s) => `${s.tool}: ${s.instruction}`).join(' | '),
380
407
  };
381
408
  };
@@ -416,8 +443,15 @@ const startTurn = async ({
416
443
  includeMetrics = false,
417
444
  metricsWindow = '7d',
418
445
  latestMetrics = 5,
446
+ verbosity = DEFAULT_VERBOSITY,
419
447
  } = {}) => {
420
448
  const startTime = Date.now();
449
+
450
+ if (process.env.DEVCTX_DISABLE_BACKGROUND_TASKS !== 'true') {
451
+ triggerBackgroundIndexBuild({ root: projectRoot }).catch(() => {});
452
+ runStorageMaintenance().catch(() => {});
453
+ }
454
+
421
455
  let summaryResult = await smartSummary({
422
456
  action: 'get',
423
457
  sessionId,
@@ -526,6 +560,7 @@ const startTurn = async ({
526
560
  mutationSafety,
527
561
  autoCreated,
528
562
  isolatedSession,
563
+ verbosity,
529
564
  });
530
565
 
531
566
  await persistSmartTurnQualityMetrics({
@@ -555,32 +590,43 @@ const startTurn = async ({
555
590
  },
556
591
  });
557
592
 
593
+ const minimal = verbosity === 'minimal';
594
+ const compactContinuity = minimal
595
+ ? { state: continuity.state, shouldReuseContext: continuity.shouldReuseContext }
596
+ : continuity;
597
+ const compactTask = summaryResult.task && minimal
598
+ ? { taskId: summaryResult.task.taskId, status: summaryResult.task.status }
599
+ : summaryResult.task ?? null;
600
+ const includeMessage = !minimal || mutationSafety?.blocked;
601
+
558
602
  return attachSafetyMetadata({
559
603
  phase: 'start',
560
- promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW),
561
604
  sessionId: effectiveSessionId,
562
605
  found: summaryResult.found ?? false,
563
606
  autoCreated,
564
607
  isolatedSession,
608
+ ...(minimal ? {} : { promptPreview: truncate(prompt, MAX_PROMPT_PREVIEW) }),
565
609
  ...(previousSessionId ? { previousSessionId } : {}),
566
- continuity,
567
- summary: summaryResult.summary ?? null,
610
+ continuity: compactContinuity,
611
+ ...(summaryResult.summary ? { summary: summaryResult.summary } : {}),
568
612
  ...(refreshedContext ? { refreshedContext } : {}),
569
613
  ...(workflow ? { workflow } : {}),
570
- ...(summaryResult.candidates ? { candidates: summaryResult.candidates } : {}),
571
- ...(summaryResult.recommendedSessionId ? { recommendedSessionId: summaryResult.recommendedSessionId } : {}),
572
- ...(summaryResult.task ? { task: summaryResult.task } : {}),
614
+ ...(!minimal && summaryResult.candidates ? { candidates: summaryResult.candidates } : {}),
615
+ ...(summaryResult.ambiguous && summaryResult.recommendedSessionId ? { recommendedSessionId: summaryResult.recommendedSessionId } : {}),
616
+ ...(compactTask ? { task: compactTask } : {}),
573
617
  ...(summaryResult.handoff ? { handoff: summaryResult.handoff } : {}),
574
618
  ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
575
619
  ...(isStorageUnhealthy(summaryResult.storageHealth ?? metrics?.storageHealth) ? { storageHealth: summaryResult.storageHealth ?? metrics?.storageHealth } : {}),
576
620
  recommendedPath,
577
- message: mutationSafety?.blocked
578
- ? mutationSafety.message
579
- : summaryResult.found
580
- ? continuity.reason
581
- : autoCreated
582
- ? 'Created a new persisted session for this task prompt.'
583
- : continuity.reason,
621
+ ...(includeMessage ? {
622
+ message: mutationSafety?.blocked
623
+ ? mutationSafety.message
624
+ : summaryResult.found
625
+ ? continuity.reason
626
+ : autoCreated
627
+ ? 'Created a new persisted session for this task prompt.'
628
+ : continuity.reason,
629
+ } : {}),
584
630
  }, {
585
631
  repoSafety: summaryResult.repoSafety ?? metrics?.repoSafety ?? null,
586
632
  sideEffectsSuppressed: Boolean(summaryResult.sideEffectsSuppressed ?? metrics?.sideEffectsSuppressed),
@@ -601,6 +647,7 @@ const endTurn = async ({
601
647
  includeMetrics = false,
602
648
  metricsWindow = '7d',
603
649
  latestMetrics = 5,
650
+ verbosity = DEFAULT_VERBOSITY,
604
651
  } = {}) => {
605
652
  const startTime = Date.now();
606
653
  const checkpoint = await smartSummary({
@@ -660,6 +707,7 @@ const endTurn = async ({
660
707
  checkpoint,
661
708
  mutationSafety,
662
709
  workflow,
710
+ verbosity,
663
711
  });
664
712
 
665
713
  await persistSmartTurnQualityMetrics({
@@ -683,15 +731,34 @@ const endTurn = async ({
683
731
  },
684
732
  });
685
733
 
734
+ const minimal = verbosity === 'minimal';
735
+ const compactCheckpoint = minimal
736
+ ? {
737
+ skipped: Boolean(checkpoint.skipped),
738
+ ...(checkpoint.blocked !== undefined ? { blocked: checkpoint.blocked } : {}),
739
+ ...(checkpoint.summary ? { summary: checkpoint.summary } : {}),
740
+ ...(checkpoint.tokens !== undefined ? { tokens: checkpoint.tokens } : {}),
741
+ ...(checkpoint.checkpoint ? {
742
+ checkpoint: {
743
+ event: checkpoint.checkpoint.event,
744
+ shouldPersist: checkpoint.checkpoint.shouldPersist,
745
+ score: checkpoint.checkpoint.score,
746
+ threshold: checkpoint.checkpoint.threshold,
747
+ },
748
+ } : {}),
749
+ }
750
+ : checkpoint;
751
+ const includeMessage = !minimal || mutationSafety?.blocked;
752
+
686
753
  return attachSafetyMetadata({
687
754
  phase: 'end',
688
755
  sessionId: checkpoint.sessionId ?? sessionId ?? null,
689
- checkpoint,
756
+ checkpoint: compactCheckpoint,
690
757
  ...(workflow ? { workflow } : {}),
691
758
  ...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
692
759
  ...(isStorageUnhealthy(checkpoint.storageHealth ?? metrics?.storageHealth) ? { storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth } : {}),
693
760
  recommendedPath,
694
- message: mutationSafety?.blocked ? mutationSafety.message : checkpoint.message,
761
+ ...(includeMessage ? { message: mutationSafety?.blocked ? mutationSafety.message : checkpoint.message } : {}),
695
762
  }, {
696
763
  repoSafety: checkpoint.repoSafety ?? metrics?.repoSafety ?? null,
697
764
  sideEffectsSuppressed: Boolean(checkpoint.sideEffectsSuppressed ?? metrics?.sideEffectsSuppressed),
@@ -715,7 +782,10 @@ export const smartTurn = async ({
715
782
  includeMetrics = false,
716
783
  metricsWindow = '7d',
717
784
  latestMetrics = 5,
785
+ verbosity,
718
786
  } = {}) => {
787
+ const resolvedVerbosity = normalizeVerbosity(verbosity);
788
+
719
789
  if (phase === 'start') {
720
790
  return startTurn({
721
791
  sessionId,
@@ -726,6 +796,7 @@ export const smartTurn = async ({
726
796
  includeMetrics,
727
797
  metricsWindow,
728
798
  latestMetrics,
799
+ verbosity: resolvedVerbosity,
729
800
  });
730
801
  }
731
802
 
@@ -740,6 +811,7 @@ export const smartTurn = async ({
740
811
  includeMetrics,
741
812
  metricsWindow,
742
813
  latestMetrics,
814
+ verbosity: resolvedVerbosity,
743
815
  });
744
816
  }
745
817