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 +1 -1
- package/package.json +5 -2
- package/server.json +2 -2
- package/src/global-memory/store.js +101 -1
- package/src/orchestration/base-orchestrator.js +37 -1
- package/src/server.js +59 -15
- package/src/storage/sqlite.js +75 -1
- package/src/task-runner.js +4 -0
- package/src/tools/global-memory.js +12 -1
- package/src/tools/smart-context.js +18 -4
- package/src/tools/smart-read-batch.js +26 -3
- package/src/tools/smart-read.js +128 -15
- package/src/tools/smart-search.js +665 -57
- package/src/tools/smart-turn.js +88 -4
- package/src/utils/task-budget.js +116 -0
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.20.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "smart-context-mcp",
|
|
14
|
-
"version": "1.
|
|
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 =
|
|
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 —
|
|
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
|
|
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
|
|
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
|
|
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
|
})),
|
package/src/storage/sqlite.js
CHANGED
|
@@ -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 =
|
|
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 ({
|
package/src/task-runner.js
CHANGED
|
@@ -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) };
|