smart-context-mcp 1.19.0 → 1.20.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.19.0 (or later)
59
+ # Should show: smart-context-mcp@1.20.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.19.0",
4
+ "version": "1.20.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",
@@ -69,8 +69,10 @@
69
69
  "eval:context": "node ./evals/harness.js --tool=context",
70
70
  "eval:both": "node ./evals/harness.js --tool=both",
71
71
  "eval:self": "node ./evals/harness.js --root=../.. --corpus=./evals/corpus/self-tasks.json",
72
+ "eval:self:json": "node ./evals/harness.js --root=../.. --corpus=./evals/corpus/self-tasks.json --json",
72
73
  "eval:realworld": "node ./evals/realworld-eval.js",
73
74
  "eval:realworld:json": "node ./evals/realworld-eval.js --json",
75
+ "eval:kpi:baseline": "node ./evals/kpi-baseline.js",
74
76
  "eval:report": "node ./evals/report.js",
75
77
  "report:metrics": "node ./scripts/report-metrics.js",
76
78
  "report:workflows": "node ./scripts/report-workflow-metrics.js",
@@ -83,5 +85,6 @@
83
85
  "js-tiktoken": "^1.0.21",
84
86
  "typescript": "^6.0.2",
85
87
  "zod": "^4.1.5"
86
- }
88
+ },
89
+ "packageManager": "pnpm@10.33.3+sha512.a19744364a7e248b92657a4ca5973f9354d21caf982579674b1c539f32c7420c47138ad8b1254df07aba9bc782d9b3029e3db34d5dbff974326eb74dac8ff489"
87
90
  }
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.19.0",
9
+ "version": "1.20.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "smart-context-mcp",
14
- "version": "1.19.0",
14
+ "version": "1.20.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -6,7 +6,7 @@ import { embed, cosineSimilarity, buildCorpusIdf } from '../embeddings/hashing.j
6
6
 
7
7
  const DEFAULT_GLOBAL_DIR = path.join(os.homedir(), '.devctx');
8
8
  const DEFAULT_GLOBAL_DB = path.join(DEFAULT_GLOBAL_DIR, 'global.db');
9
- const SCHEMA_VERSION = 1;
9
+ const SCHEMA_VERSION = 2;
10
10
 
11
11
  let sqliteModulePromise = null;
12
12
 
@@ -56,6 +56,18 @@ CREATE TABLE IF NOT EXISTS entries (
56
56
  CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
57
57
  CREATE INDEX IF NOT EXISTS idx_entries_project ON entries(project_hash);
58
58
  CREATE INDEX IF NOT EXISTS idx_entries_created ON entries(created_at DESC);
59
+
60
+ CREATE TABLE IF NOT EXISTS noise_hints (
61
+ project_hash TEXT NOT NULL,
62
+ hint_key TEXT NOT NULL,
63
+ reason TEXT NOT NULL DEFAULT 'search_noise',
64
+ hits INTEGER NOT NULL DEFAULT 1,
65
+ created_at INTEGER NOT NULL,
66
+ updated_at INTEGER NOT NULL,
67
+ PRIMARY KEY(project_hash, hint_key)
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_noise_hints_project ON noise_hints(project_hash, hits DESC, updated_at DESC);
59
71
  `;
60
72
 
61
73
  const VALID_KINDS = new Set(['decision', 'pattern', 'playbook', 'note']);
@@ -245,6 +257,94 @@ export const listKinds = async ({ filePath = getGlobalDbPath() } = {}) => {
245
257
  }, { filePath, readOnly: true });
246
258
  };
247
259
 
260
+ export const recordNoiseHint = async ({
261
+ projectPath,
262
+ hintKey,
263
+ reason = 'search_noise',
264
+ filePath = getGlobalDbPath(),
265
+ } = {}) => {
266
+ if (!projectPath || !hintKey) {
267
+ return null;
268
+ }
269
+
270
+ const projectHash = hashProjectPath(projectPath);
271
+ const now = Date.now();
272
+ return withDb((db) => {
273
+ db.prepare(`
274
+ INSERT INTO noise_hints(project_hash, hint_key, reason, hits, created_at, updated_at)
275
+ VALUES(?, ?, ?, 1, ?, ?)
276
+ ON CONFLICT(project_hash, hint_key) DO UPDATE SET
277
+ reason = excluded.reason,
278
+ hits = noise_hints.hits + 1,
279
+ updated_at = excluded.updated_at
280
+ `).run(projectHash, hintKey, reason, now, now);
281
+
282
+ const row = db.prepare(`
283
+ SELECT hits, updated_at
284
+ FROM noise_hints
285
+ WHERE project_hash = ? AND hint_key = ?
286
+ `).get(projectHash, hintKey);
287
+
288
+ return {
289
+ projectHash,
290
+ hintKey,
291
+ hits: Number(row?.hits ?? 0),
292
+ updatedAt: Number(row?.updated_at ?? now),
293
+ };
294
+ }, { filePath });
295
+ };
296
+
297
+ export const getNoiseHints = async ({
298
+ projectPath,
299
+ limit = 50,
300
+ filePath = getGlobalDbPath(),
301
+ } = {}) => {
302
+ if (!projectPath) {
303
+ return { hints: [], total: 0 };
304
+ }
305
+
306
+ const projectHash = hashProjectPath(projectPath);
307
+ return withDb((db) => {
308
+ if (!db) return { hints: [], total: 0 };
309
+ const rows = db.prepare(`
310
+ SELECT hint_key, reason, hits, updated_at
311
+ FROM noise_hints
312
+ WHERE project_hash = ?
313
+ ORDER BY hits DESC, updated_at DESC
314
+ LIMIT ?
315
+ `).all(projectHash, limit);
316
+
317
+ return {
318
+ hints: rows.map((row) => ({
319
+ hintKey: row.hint_key,
320
+ reason: row.reason,
321
+ hits: Number(row.hits),
322
+ penalty: Math.min(Number(row.hits) * 2, 12),
323
+ updatedAt: Number(row.updated_at),
324
+ })),
325
+ total: rows.length,
326
+ };
327
+ }, { filePath, readOnly: true });
328
+ };
329
+
330
+ export const resetNoiseHints = async ({
331
+ projectPath,
332
+ hintKey,
333
+ filePath = getGlobalDbPath(),
334
+ } = {}) => {
335
+ if (!projectPath) {
336
+ return { deleted: 0 };
337
+ }
338
+
339
+ const projectHash = hashProjectPath(projectPath);
340
+ return withDb((db) => {
341
+ const result = hintKey
342
+ ? db.prepare('DELETE FROM noise_hints WHERE project_hash = ? AND hint_key = ?').run(projectHash, hintKey)
343
+ : db.prepare('DELETE FROM noise_hints WHERE project_hash = ?').run(projectHash);
344
+ return { deleted: Number(result.changes) };
345
+ }, { filePath });
346
+ };
347
+
248
348
  export const getStats = async ({ filePath = getGlobalDbPath() } = {}) => {
249
349
  return withDb((db) => {
250
350
  if (!db) {
@@ -16,6 +16,7 @@ import {
16
16
  export const DEFAULT_ORCHESTRATION_EVENT = 'session_end';
17
17
  export const DEFAULT_START_MAX_TOKENS = 350;
18
18
  export const DEFAULT_END_MAX_TOKENS = 350;
19
+ const SIMPLE_TASK_SKIP_MAX_LENGTH = 40;
19
20
 
20
21
  const buildContextLines = (startResult) => {
21
22
  const context = buildOperationalContextLines(startResult, {
@@ -53,11 +54,31 @@ const buildFreshSessionUpdate = (prompt) => {
53
54
  };
54
55
  };
55
56
 
57
+ const buildSimpleTaskStartResult = (prompt) => ({
58
+ phase: 'start',
59
+ skipSmartTurn: true,
60
+ continuity: {
61
+ state: 'simple_task_skip',
62
+ shouldReuseContext: false,
63
+ reason: 'Simple task heuristic skipped persisted continuity setup to avoid overhead.',
64
+ },
65
+ recommendedPath: {
66
+ phase: 'start',
67
+ mode: 'simple_task_skip',
68
+ nextTools: ['smart_read', 'smart_search'],
69
+ nextActions: [],
70
+ next: 'smart_read: Skip smart_turn for this simple task and use lightweight read/search directly.',
71
+ },
72
+ message: 'Simple task heuristic skipped smart_turn(start); use lightweight read/search flow unless the task grows.',
73
+ ...(prompt ? { promptPreview: truncate(prompt, MAX_FOCUS_LENGTH) } : {}),
74
+ });
75
+
56
76
  const ensureIsolatedSession = async ({
57
77
  prompt,
58
78
  sessionId,
59
79
  startResult,
60
80
  startMaxTokens = DEFAULT_START_MAX_TOKENS,
81
+ tokenBudget,
61
82
  summaryTool = smartSummary,
62
83
  startTurn = smartTurn,
63
84
  }) => {
@@ -96,6 +117,7 @@ const ensureIsolatedSession = async ({
96
117
  prompt,
97
118
  ensureSession: false,
98
119
  maxTokens: startMaxTokens,
120
+ tokenBudget,
99
121
  });
100
122
 
101
123
  return {
@@ -112,11 +134,23 @@ export const resolveManagedStart = async ({
112
134
  ensureSession = true,
113
135
  allowIsolation = false,
114
136
  startMaxTokens = DEFAULT_START_MAX_TOKENS,
137
+ tokenBudget,
115
138
  startTurn = smartTurn,
116
139
  summaryTool = smartSummary,
117
140
  enableFastPath = true,
118
141
  }) => {
119
- const simpleTask = enableFastPath && isSimpleTask(prompt);
142
+ const simpleTask = enableFastPath && isSimpleTask(prompt) && normalizeWhitespace(prompt).length <= SIMPLE_TASK_SKIP_MAX_LENGTH;
143
+
144
+ if (simpleTask && !preparedStartResult && !sessionId) {
145
+ const startResult = buildSimpleTaskStartResult(prompt);
146
+ return {
147
+ startResult,
148
+ isolated: false,
149
+ previousSessionId: null,
150
+ autoStarted: false,
151
+ fastPath: true,
152
+ };
153
+ }
120
154
 
121
155
  const startResult = preparedStartResult ?? await startTurn({
122
156
  phase: 'start',
@@ -124,6 +158,7 @@ export const resolveManagedStart = async ({
124
158
  prompt,
125
159
  ensureSession: simpleTask ? false : ensureSession,
126
160
  maxTokens: startMaxTokens,
161
+ tokenBudget,
127
162
  });
128
163
 
129
164
  if (!allowIsolation || simpleTask) {
@@ -141,6 +176,7 @@ export const resolveManagedStart = async ({
141
176
  sessionId,
142
177
  startResult,
143
178
  startMaxTokens,
179
+ tokenBudget,
144
180
  summaryTool,
145
181
  startTurn,
146
182
  });
package/src/server.js CHANGED
@@ -129,7 +129,7 @@ export const createDevctxServer = () => {
129
129
 
130
130
  server.tool(
131
131
  'smart_read',
132
- 'Read a file with token-efficient modes. ALWAYS prefer outline/signatures/symbol/explain over full. Reading cascade: outline → signatures → symbol → explain → 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. explain (~95% savings): one-shot compact summary of a symbol (signature, docstring, first body line, side effects, caller count). Cached in SQLite by content hash — second call is free. Requires symbol. 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
+ 'Read a file with token-efficient modes. ALWAYS prefer outline/signatures/symbol/explain over full. Reading cascade: outline → signatures → symbol → explain → 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. explain (~95% savings): one-shot compact summary of a symbol (signature, docstring, first body line, side effects, caller count). Cached in SQLite by content hash — second call is free. Requires symbol. range: specific line range — use only when you need exact lines. full: raw content, no savings — explicit last resort; with a token budget it degrades to lighter modes first and reports `fullMode` metadata explaining whether full was actually used. maxTokens: token budget — auto-degrades to lighter modes before truncation; when the budget changes the result, `budgetDetails` reports the final mode, truncation actions, and marks `scope="content"`. Supports JS/TS, Python, Go, Rust, Java, C#, Kotlin, PHP, Swift, shell, Terraform, Dockerfile, SQL, JSON, TOML, YAML.',
133
133
  {
134
134
  filePath: z.string(),
135
135
  mode: z.enum(['full', 'outline', 'signatures', 'range', 'symbol', 'explain']).optional(),
@@ -137,15 +137,23 @@ export const createDevctxServer = () => {
137
137
  endLine: z.number().optional(),
138
138
  symbol: z.union([z.string(), z.array(z.string())]).optional(),
139
139
  maxTokens: z.number().int().min(1).optional(),
140
+ tokenBudget: z.union([
141
+ z.number().int().min(1),
142
+ z.object({
143
+ id: z.string().optional(),
144
+ maxTokens: z.number().int().min(1),
145
+ shared: z.boolean().optional(),
146
+ }),
147
+ ]).optional(),
140
148
  context: z.boolean().optional(),
141
149
  },
142
- async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context }) =>
143
- asTextResult(await smartRead({ filePath, mode, startLine, endLine, symbol, maxTokens, context })),
150
+ async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, tokenBudget, context }) =>
151
+ asTextResult(await smartRead({ filePath, mode, startLine, endLine, symbol, maxTokens, tokenBudget, context })),
144
152
  );
145
153
 
146
154
  server.tool(
147
155
  'smart_read_batch',
148
- 'Read multiple files in one call. Each item accepts path, mode (prefer outline/signatures/symbol/explain — 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.',
156
+ 'Read multiple files in one call. Each item accepts path, mode (prefer outline/signatures/symbol/explain — full saves 0 tokens), symbol, startLine, endLine, maxTokens (per-file budget). Optional global maxTokens budget with early stop when exceeded; when that happens, `budgetDetails` reports the batch-level stop point, marks `scope="batch"`, and includes `actions`. Max 20 files per call.',
149
157
  {
150
158
  files: z.array(z.object({
151
159
  path: z.string(),
@@ -156,25 +164,35 @@ export const createDevctxServer = () => {
156
164
  maxTokens: z.number().int().min(1).optional(),
157
165
  })).min(1).max(20),
158
166
  maxTokens: z.number().int().min(1).optional(),
167
+ tokenBudget: z.union([
168
+ z.number().int().min(1),
169
+ z.object({
170
+ id: z.string().optional(),
171
+ maxTokens: z.number().int().min(1),
172
+ shared: z.boolean().optional(),
173
+ }),
174
+ ]).optional(),
159
175
  },
160
- async ({ files, maxTokens }) =>
161
- asTextResult(await smartReadBatch({ files, maxTokens })),
176
+ async ({ files, maxTokens, tokenBudget }) =>
177
+ asTextResult(await smartReadBatch({ files, maxTokens, tokenBudget })),
162
178
  );
163
179
 
164
180
  server.tool(
165
181
  'smart_search',
166
- 'Search code with ranked, deduplicated results and index boosting. Best for: finding where a symbol is defined/used, understanding call chains, locating implementations. NOT ideal for: exact string matching (use Grep), finding files by name (use Glob), broad multi-word queries (generates noise). Optional intent adjusts ranking. maxFiles caps the number of files returned (default 15). kinds filters results by symbol kind from the index — e.g. ["adr","adr-section"] returns only architecture decision docs; ["class","function"] returns only those declarations; use to scope a query to a domain. Pass semantic=true to additionally include a local semantic re-rank (hashing-v1 embedder, TF-IDF over symbol signatures + file paths) — useful when the query is conceptual ("user registration flow", "rate limit middleware") rather than literal. semanticLimit caps the semantic block (default 8). Semantic block adds zero deps and runs in <5ms even on large indexes. When >30 files match, results include a hint suggesting Grep instead.',
182
+ 'Search code with ranked, deduplicated results and index boosting. Best for: finding where a symbol is defined/used, understanding call chains, locating implementations. NOT ideal for: exact string matching (use Grep), finding files by name (use Glob), broad multi-word queries (generates noise). Optional intent adjusts ranking. `mode` controls search strategy: `needle` = exact literal only (no regex or term expansion), `balanced` = exact + regex + term expansion (default), `semantic` = exact-first plus a local semantic block only when exact signal is weak. maxFiles caps the number of files returned (default 5). maxTokens caps the overall response payload: `matches` is truncated first, then optional diagnostics and semantic blocks are compacted or omitted if needed. When budgeting happens, `budgetDetails` reports `actions`, which sections were compacted, and marks `scope="response"`. kinds filters results by symbol kind from the index — e.g. ["adr","adr-section"] returns only architecture decision docs; ["class","function"] returns only those declarations; use to scope a query to a domain. `semantic=true` remains supported as a legacy alias for `mode="semantic"`. semanticLimit caps the semantic block (default 8). Top ranked files include `matchedBy`, `boostSource`, `scoreBreakdown`, and `whyRanked` so ranking decisions are inspectable. Semantic block adds zero deps and runs in <5ms even on large indexes. When more files exist beyond the initial window, the response includes `hasMore`, `totalFiles`, and `nextSuggestedMaxFiles` to support expansion on demand. When the search is too broad or returns nothing useful, the response also includes actionable `suggestions` for refining the query, mode, or kinds.',
167
183
  {
168
184
  query: z.string(),
169
185
  cwd: z.string().optional(),
170
186
  intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
171
187
  maxFiles: z.number().int().min(1).max(50).optional(),
188
+ maxTokens: z.number().int().min(1).optional(),
172
189
  kinds: z.array(z.string()).optional(),
190
+ mode: z.enum(['needle', 'balanced', 'semantic']).optional(),
173
191
  semantic: z.boolean().optional(),
174
192
  semanticLimit: z.number().int().min(1).max(50).optional(),
175
193
  },
176
- async ({ query, cwd = '.', intent, maxFiles, kinds, semantic, semanticLimit }) =>
177
- asTextResult(await smartSearch({ query, cwd, intent, maxFiles, kinds, semantic, semanticLimit })),
194
+ async ({ query, cwd = '.', intent, maxFiles, maxTokens, kinds, mode, semantic, semanticLimit }) =>
195
+ asTextResult(await smartSearch({ query, cwd, intent, maxFiles, maxTokens, kinds, mode, semantic, semanticLimit })),
178
196
  );
179
197
 
180
198
  server.tool(
@@ -184,6 +202,14 @@ export const createDevctxServer = () => {
184
202
  task: z.string().optional(),
185
203
  intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
186
204
  maxTokens: z.number().optional(),
205
+ tokenBudget: z.union([
206
+ z.number().int().min(1),
207
+ z.object({
208
+ id: z.string().optional(),
209
+ maxTokens: z.number().int().min(1),
210
+ shared: z.boolean().optional(),
211
+ }),
212
+ ]).optional(),
187
213
  entryFile: z.string().optional(),
188
214
  diff: z.union([z.boolean(), z.string()]).optional(),
189
215
  detail: z.enum(['minimal', 'balanced', 'deep']).optional(),
@@ -196,8 +222,8 @@ export const createDevctxServer = () => {
196
222
  pathMaxHops: z.number().int().min(1).max(10).optional(),
197
223
  pathDirected: z.boolean().optional(),
198
224
  },
199
- async ({ task, intent, maxTokens, entryFile, diff, detail, include, prefetch, paths, pathMaxHops, pathDirected }) =>
200
- asTextResult(await smartContext({ task, intent, maxTokens, entryFile, diff, detail, include, prefetch, paths, pathMaxHops, pathDirected })),
225
+ async ({ task, intent, maxTokens, tokenBudget, entryFile, diff, detail, include, prefetch, paths, pathMaxHops, pathDirected }) =>
226
+ asTextResult(await smartContext({ task, intent, maxTokens, tokenBudget, entryFile, diff, detail, include, prefetch, paths, pathMaxHops, pathDirected })),
201
227
  );
202
228
 
203
229
  server.tool(
@@ -256,9 +282,9 @@ export const createDevctxServer = () => {
256
282
 
257
283
  server.tool(
258
284
  'global_memory',
259
- 'Opt-in cross-project memory persisted to ~/.devctx/global.db (override with DEVCTX_GLOBAL_DB). Enable via DEVCTX_GLOBAL_MEMORY=true. Stores canonical decisions, recurring patterns, playbook drafts, and notes across projects so an agent can carry insights between repos without re-deriving them. Content is scrubbed for likely secrets/JWTs/API keys/emails/home paths before being persisted. Project paths are stored hashed (FNV-1a) instead of raw. Actions: save (kind+content+tags?), recall (kind?+query?+limit? — uses local hashing/TF-IDF embedder for ranking, zero deps), list (counts per kind), delete (id), mark_used (id), stats (db size + per-kind totals). Valid kinds: decision, pattern, playbook, note. projectScope=true (default) hashes the current project so recall can be filtered per-project; set false for repo-agnostic access.',
285
+ 'Opt-in cross-project memory persisted to ~/.devctx/global.db (override with DEVCTX_GLOBAL_DB). Enable via DEVCTX_GLOBAL_MEMORY=true. Stores canonical decisions, recurring patterns, playbook drafts, notes, and repo-local noise hints so an agent can carry insights between repos without re-deriving them. Content is scrubbed for likely secrets/JWTs/API keys/emails/home paths before being persisted. Project paths are stored hashed (FNV-1a) instead of raw. Actions: save (kind+content+tags?), recall (kind?+query?+limit? — uses local hashing/TF-IDF embedder for ranking, zero deps), list (counts per kind), delete (id), mark_used (id), stats (db size + per-kind totals), noise_stats (inspect repo noise hints), noise_reset (reset repo noise hints or one hint via query). Valid kinds: decision, pattern, playbook, note. projectScope=true (default) hashes the current project so recall can be filtered per-project; set false for repo-agnostic access.',
260
286
  {
261
- action: z.enum(['save', 'recall', 'list', 'delete', 'stats', 'mark_used']),
287
+ action: z.enum(['save', 'recall', 'list', 'delete', 'stats', 'mark_used', 'noise_stats', 'noise_reset']),
262
288
  kind: z.enum(['decision', 'pattern', 'playbook', 'note']).optional(),
263
289
  content: z.string().optional(),
264
290
  tags: z.array(z.string()).optional(),
@@ -582,13 +608,21 @@ export const createDevctxServer = () => {
582
608
  event: z.enum(['manual', 'milestone', 'decision', 'blocker', 'status_change', 'file_change', 'task_switch', 'task_complete', 'session_end', 'read_only', 'heartbeat']).optional(),
583
609
  force: z.boolean().optional(),
584
610
  maxTokens: z.number().int().min(100).max(2000).optional(),
611
+ tokenBudget: z.union([
612
+ z.number().int().min(1),
613
+ z.object({
614
+ id: z.string().optional(),
615
+ maxTokens: z.number().int().min(1),
616
+ shared: z.boolean().optional(),
617
+ }),
618
+ ]).optional(),
585
619
  ensureSession: z.boolean().optional(),
586
620
  includeMetrics: z.boolean().optional(),
587
621
  metricsWindow: z.enum(['24h', '7d', '30d', 'all']).optional(),
588
622
  latestMetrics: z.number().int().min(1).max(20).optional(),
589
623
  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.'),
590
624
  },
591
- async ({ phase, sessionId, prompt, update, event, force, maxTokens, ensureSession, includeMetrics, metricsWindow, latestMetrics, verbosity }) =>
625
+ async ({ phase, sessionId, prompt, update, event, force, maxTokens, tokenBudget, ensureSession, includeMetrics, metricsWindow, latestMetrics, verbosity }) =>
592
626
  asTextResult(await smartTurn({
593
627
  phase,
594
628
  sessionId,
@@ -597,6 +631,7 @@ export const createDevctxServer = () => {
597
631
  event,
598
632
  force,
599
633
  maxTokens,
634
+ tokenBudget,
600
635
  ensureSession,
601
636
  includeMetrics,
602
637
  metricsWindow,
@@ -613,15 +648,24 @@ export const createDevctxServer = () => {
613
648
  sessionId: z.string().optional(),
614
649
  taskId: z.string().optional(),
615
650
  maxTokens: z.number().int().min(100).max(2000).optional(),
651
+ tokenBudget: z.union([
652
+ z.number().int().min(1),
653
+ z.object({
654
+ id: z.string().optional(),
655
+ maxTokens: z.number().int().min(1),
656
+ shared: z.boolean().optional(),
657
+ }),
658
+ ]).optional(),
616
659
  verbosity: z.enum(['minimal', 'standard', 'full']).optional(),
617
660
  },
618
- async ({ prompt, sessionId, taskId, maxTokens, verbosity }) =>
661
+ async ({ prompt, sessionId, taskId, maxTokens, tokenBudget, verbosity }) =>
619
662
  asTextResult(await smartTurn({
620
663
  phase: 'start',
621
664
  prompt,
622
665
  sessionId,
623
666
  taskId,
624
667
  maxTokens,
668
+ tokenBudget,
625
669
  ensureSession: true,
626
670
  verbosity: verbosity ?? 'minimal',
627
671
  })),
@@ -6,7 +6,7 @@ import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { projectRoot } from '../utils/runtime-config.js';
7
7
 
8
8
  export const STATE_DB_FILENAME = 'state.sqlite';
9
- export const SQLITE_SCHEMA_VERSION = 7;
9
+ export const SQLITE_SCHEMA_VERSION = 8;
10
10
  export const ACTIVE_SESSION_SCOPE = 'project';
11
11
  export const STATE_DB_SOFT_MAX_BYTES = 32 * 1024 * 1024;
12
12
  const STATE_DB_BUSY_TIMEOUT_MS = 1000;
@@ -20,6 +20,7 @@ export const EXPECTED_TABLES = [
20
20
  'hook_turn_state',
21
21
  'meta',
22
22
  'metrics_events',
23
+ 'read_cache',
23
24
  'session_events',
24
25
  'sessions',
25
26
  'summary_cache',
@@ -266,6 +267,26 @@ const MIGRATIONS = [
266
267
  ON explain_cache(updated_at DESC)`,
267
268
  ],
268
269
  },
270
+ {
271
+ version: 8,
272
+ statements: [
273
+ `CREATE TABLE IF NOT EXISTS read_cache (
274
+ cache_key TEXT PRIMARY KEY,
275
+ file_path TEXT NOT NULL,
276
+ mode TEXT NOT NULL,
277
+ selector TEXT NOT NULL DEFAULT '',
278
+ content_hash TEXT NOT NULL,
279
+ payload_json TEXT NOT NULL,
280
+ tokens INTEGER NOT NULL DEFAULT 0,
281
+ created_at TEXT NOT NULL,
282
+ updated_at TEXT NOT NULL
283
+ )`,
284
+ `CREATE INDEX IF NOT EXISTS idx_read_cache_file_mode
285
+ ON read_cache(file_path, mode, updated_at DESC)`,
286
+ `CREATE INDEX IF NOT EXISTS idx_read_cache_updated
287
+ ON read_cache(updated_at DESC)`,
288
+ ],
289
+ },
269
290
  ];
270
291
 
271
292
  let sqliteModulePromise = null;
@@ -1512,6 +1533,7 @@ export const runStorageMaintenance = async ({
1512
1533
  workflowMetrics: removeOlder('DELETE FROM workflow_metrics WHERE created_at < ?'),
1513
1534
  contextAccess: removeOlder('DELETE FROM context_access WHERE timestamp < ?'),
1514
1535
  explainCache: removeOlder('DELETE FROM explain_cache WHERE updated_at < ?'),
1536
+ readCache: removeOlder('DELETE FROM read_cache WHERE updated_at < ?'),
1515
1537
  };
1516
1538
 
1517
1539
  setMeta(db, STORAGE_GC_META_KEY, String(now));
@@ -1955,6 +1977,9 @@ export const cleanupLegacyState = async ({
1955
1977
  const buildExplainCacheKey = ({ filePath, symbol, contentHash }) =>
1956
1978
  createHash('sha256').update(`${filePath}\u241F${symbol}\u241F${contentHash}`).digest('hex');
1957
1979
 
1980
+ const buildReadCacheKey = ({ filePath, mode, selector = '', contentHash }) =>
1981
+ createHash('sha256').update(`${filePath}\u241F${mode}\u241F${selector}\u241F${contentHash}`).digest('hex');
1982
+
1958
1983
  export const getExplainCache = async ({
1959
1984
  filePath: dbPath = getStateDbPath(),
1960
1985
  relPath,
@@ -2005,6 +2030,55 @@ export const clearExplainCache = async ({ filePath = getStateDbPath() } = {}) =>
2005
2030
  return db.prepare('DELETE FROM explain_cache').run().changes;
2006
2031
  }, { filePath });
2007
2032
 
2033
+ export const getReadCache = async ({
2034
+ filePath: dbPath = getStateDbPath(),
2035
+ relPath,
2036
+ mode,
2037
+ selector = '',
2038
+ contentHash,
2039
+ } = {}) => withStateDb((db) => {
2040
+ if (!relPath || !mode || !contentHash) return null;
2041
+ const cacheKey = buildReadCacheKey({ filePath: relPath, mode, selector, contentHash });
2042
+ const row = db.prepare(`
2043
+ SELECT payload_json, tokens, updated_at
2044
+ FROM read_cache
2045
+ WHERE cache_key = ?
2046
+ `).get(cacheKey);
2047
+ if (!row) return null;
2048
+ return {
2049
+ payload: parseJsonText(row.payload_json, null),
2050
+ tokens: row.tokens,
2051
+ updatedAt: row.updated_at,
2052
+ };
2053
+ }, { filePath: dbPath });
2054
+
2055
+ export const setReadCache = async ({
2056
+ filePath: dbPath = getStateDbPath(),
2057
+ relPath,
2058
+ mode,
2059
+ selector = '',
2060
+ contentHash,
2061
+ payload,
2062
+ tokens = 0,
2063
+ } = {}) => withStateDb((db) => {
2064
+ if (!relPath || !mode || !contentHash || !payload) return null;
2065
+ const cacheKey = buildReadCacheKey({ filePath: relPath, mode, selector, contentHash });
2066
+ const now = new Date().toISOString();
2067
+ db.prepare(`
2068
+ INSERT INTO read_cache(cache_key, file_path, mode, selector, content_hash, payload_json, tokens, created_at, updated_at)
2069
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
2070
+ ON CONFLICT(cache_key) DO UPDATE SET
2071
+ payload_json = excluded.payload_json,
2072
+ tokens = excluded.tokens,
2073
+ updated_at = excluded.updated_at
2074
+ `).run(cacheKey, relPath, mode, selector, contentHash, toJsonText(payload), tokens, now, now);
2075
+ return { cacheKey, updatedAt: now };
2076
+ }, { filePath: dbPath });
2077
+
2078
+ export const clearReadCachePersistent = async ({ filePath = getStateDbPath() } = {}) => withStateDb((db) => {
2079
+ return db.prepare('DELETE FROM read_cache').run().changes;
2080
+ }, { filePath });
2081
+
2008
2082
  const LAST_TEST_FAILURE_META_KEY = 'last_test_failure';
2009
2083
 
2010
2084
  export const setLastTestFailure = async ({
@@ -129,6 +129,7 @@ const runWorkflowCommand = async ({
129
129
  client,
130
130
  prompt,
131
131
  sessionId,
132
+ tokenBudget,
132
133
  event,
133
134
  stdinPrompt = false,
134
135
  dryRun = false,
@@ -147,6 +148,7 @@ const runWorkflowCommand = async ({
147
148
  const startResolution = await withRunnerLockRetry(() => resolveManagedStart({
148
149
  prompt: requestedPrompt,
149
150
  sessionId,
151
+ tokenBudget,
150
152
  ensureSession: true,
151
153
  allowIsolation: false,
152
154
  startMaxTokens: DEFAULT_START_MAX_TOKENS,
@@ -425,6 +427,7 @@ export const runTaskRunner = async ({
425
427
  client = null,
426
428
  prompt = '',
427
429
  sessionId,
430
+ tokenBudget,
428
431
  event,
429
432
  stdinPrompt = false,
430
433
  dryRun = false,
@@ -456,6 +459,7 @@ export const runTaskRunner = async ({
456
459
  client: resolvedClient,
457
460
  prompt,
458
461
  sessionId,
462
+ tokenBudget,
459
463
  event,
460
464
  stdinPrompt,
461
465
  dryRun,
@@ -5,6 +5,9 @@ import {
5
5
  deleteEntry,
6
6
  listKinds,
7
7
  getStats,
8
+ recordNoiseHint,
9
+ getNoiseHints,
10
+ resetNoiseHints,
8
11
  isGlobalMemoryEnabled,
9
12
  VALID_GLOBAL_KINDS,
10
13
  } from '../global-memory/store.js';
@@ -13,7 +16,7 @@ import { projectRoot } from '../utils/paths.js';
13
16
  import { recordDevctxOperation } from '../missed-opportunities.js';
14
17
  import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
15
18
 
16
- const VALID_ACTIONS = new Set(['save', 'recall', 'list', 'delete', 'stats', 'mark_used']);
19
+ const VALID_ACTIONS = new Set(['save', 'recall', 'list', 'delete', 'stats', 'mark_used', 'noise_stats', 'noise_reset']);
17
20
 
18
21
  export const globalMemory = async ({
19
22
  action = 'stats',
@@ -92,6 +95,14 @@ export const globalMemory = async ({
92
95
  const stats = await getStats();
93
96
  return { success: true, action: 'stats', ...stats };
94
97
  }
98
+ case 'noise_stats': {
99
+ const result = await getNoiseHints({ projectPath: projectScope ? projectRoot : null, limit: limit ?? 50 });
100
+ return { success: true, action, ...result };
101
+ }
102
+ case 'noise_reset': {
103
+ const result = await resetNoiseHints({ projectPath: projectScope ? projectRoot : null, hintKey: query });
104
+ return { success: true, action, ...result };
105
+ }
95
106
  }
96
107
  } catch (err) {
97
108
  return { success: false, error: err?.message ?? String(err) };