smart-context-mcp 1.18.1 → 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 +24 -13
- package/package.json +5 -2
- package/server.json +2 -2
- package/src/embeddings/embedder.js +28 -0
- package/src/embeddings/hashing.js +77 -0
- package/src/embeddings/index.js +91 -0
- package/src/embeddings/tokenize.js +46 -0
- package/src/global-memory/scrub.js +46 -0
- package/src/global-memory/store.js +375 -0
- package/src/index-watcher.js +224 -0
- package/src/index.js +91 -9
- package/src/orchestration/base-orchestrator.js +37 -1
- package/src/parsers/registry.js +26 -0
- package/src/playbooks/builtin/debug-flake.yaml +17 -0
- package/src/playbooks/builtin/doc-sync.yaml +16 -0
- package/src/playbooks/builtin/preflight-merge.yaml +20 -0
- package/src/playbooks/builtin/ramp-up.yaml +14 -0
- package/src/playbooks/builtin/refactor-safe.yaml +18 -0
- package/src/playbooks/loader.js +123 -0
- package/src/playbooks/runner.js +182 -0
- package/src/playbooks/yaml-mini.js +162 -0
- package/src/server.js +108 -13
- package/src/storage/sqlite.js +75 -1
- package/src/task-runner.js +4 -0
- package/src/tools/global-memory.js +110 -0
- package/src/tools/smart-context.js +18 -4
- package/src/tools/smart-playbook.js +63 -0
- package/src/tools/smart-read-batch.js +26 -3
- package/src/tools/smart-read.js +128 -15
- package/src/tools/smart-search.js +692 -55
- package/src/tools/smart-status.js +13 -0
- package/src/tools/smart-turn.js +88 -4
- package/src/turn/next-actions.js +4 -1
- package/src/utils/task-budget.js +116 -0
package/src/index.js
CHANGED
|
@@ -4,8 +4,9 @@ import path from 'node:path';
|
|
|
4
4
|
import ts from 'typescript';
|
|
5
5
|
import { isBinaryBuffer } from './utils/fs.js';
|
|
6
6
|
import { IGNORED_DIRS } from './config/ignored-paths.js';
|
|
7
|
+
import { getParser } from './parsers/registry.js';
|
|
7
8
|
|
|
8
|
-
const INDEX_VERSION =
|
|
9
|
+
const INDEX_VERSION = 7;
|
|
9
10
|
|
|
10
11
|
const MAX_SIGNATURE_LEN = 200;
|
|
11
12
|
const MAX_SNIPPET_LEN = 280;
|
|
@@ -231,31 +232,78 @@ const extractJsImportsExports = (sourceFile) => {
|
|
|
231
232
|
// ---------------------------------------------------------------------------
|
|
232
233
|
|
|
233
234
|
const PYTHON_SYMBOL_RE = /^(class|def|async\s+def)\s+(\w+)/;
|
|
235
|
+
const PYTHON_TYPE_ALIAS_RE = /^(\w+)\s*:\s*(?:TypeAlias|"TypeAlias")\s*=/;
|
|
236
|
+
const PYTHON_TYPEVAR_RE = /^(\w+)\s*=\s*(?:TypeVar|NewType|ParamSpec|TypeVarTuple)\s*\(/;
|
|
234
237
|
|
|
235
238
|
const extractPySymbols = (content) => {
|
|
236
239
|
const symbols = [];
|
|
237
240
|
const lines = content.split('\n');
|
|
238
241
|
let currentClass = null;
|
|
242
|
+
let currentClassIndent = -1;
|
|
243
|
+
let pendingDecorators = [];
|
|
239
244
|
|
|
240
245
|
for (let i = 0; i < lines.length; i++) {
|
|
241
246
|
const trimmed = lines[i].trimStart();
|
|
242
247
|
const indent = lines[i].length - trimmed.length;
|
|
248
|
+
|
|
249
|
+
if (trimmed.startsWith('@')) {
|
|
250
|
+
pendingDecorators.push(trimmed.slice(1).split('(')[0].trim());
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (currentClass !== null && trimmed !== '' && indent <= currentClassIndent) {
|
|
255
|
+
currentClass = null;
|
|
256
|
+
currentClassIndent = -1;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const aliasMatch = PYTHON_TYPE_ALIAS_RE.exec(trimmed) ?? PYTHON_TYPEVAR_RE.exec(trimmed);
|
|
260
|
+
if (aliasMatch) {
|
|
261
|
+
symbols.push({
|
|
262
|
+
name: aliasMatch[1],
|
|
263
|
+
kind: 'type',
|
|
264
|
+
line: i + 1,
|
|
265
|
+
signature: trimSignature(trimmed),
|
|
266
|
+
});
|
|
267
|
+
pendingDecorators = [];
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
243
271
|
const match = PYTHON_SYMBOL_RE.exec(trimmed);
|
|
244
|
-
if (!match)
|
|
272
|
+
if (!match) {
|
|
273
|
+
if (trimmed !== '') pendingDecorators = [];
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
245
276
|
|
|
246
277
|
const keyword = match[1].replace(/\s+/g, ' ');
|
|
247
278
|
const name = match[2];
|
|
248
279
|
const line = i + 1;
|
|
249
280
|
const signature = trimSignature(trimmed.replace(/:$/, ''));
|
|
281
|
+
const decorators = pendingDecorators.length > 0 ? [...pendingDecorators] : undefined;
|
|
282
|
+
pendingDecorators = [];
|
|
250
283
|
|
|
251
284
|
if (keyword === 'class') {
|
|
252
285
|
currentClass = name;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
286
|
+
currentClassIndent = indent;
|
|
287
|
+
symbols.push({ name, kind: 'class', line, signature, ...(decorators && { decorators }) });
|
|
288
|
+
} else if (currentClass !== null && indent > currentClassIndent) {
|
|
289
|
+
symbols.push({
|
|
290
|
+
name,
|
|
291
|
+
kind: keyword === 'async def' ? 'async-method' : 'method',
|
|
292
|
+
line,
|
|
293
|
+
parent: currentClass,
|
|
294
|
+
signature,
|
|
295
|
+
...(decorators && { decorators }),
|
|
296
|
+
});
|
|
256
297
|
} else {
|
|
257
298
|
currentClass = null;
|
|
258
|
-
|
|
299
|
+
currentClassIndent = -1;
|
|
300
|
+
symbols.push({
|
|
301
|
+
name,
|
|
302
|
+
kind: keyword === 'async def' ? 'async-function' : 'function',
|
|
303
|
+
line,
|
|
304
|
+
signature,
|
|
305
|
+
...(decorators && { decorators }),
|
|
306
|
+
});
|
|
259
307
|
}
|
|
260
308
|
}
|
|
261
309
|
|
|
@@ -277,8 +325,10 @@ const extractPyImports = (content) => {
|
|
|
277
325
|
// Go extraction
|
|
278
326
|
// ---------------------------------------------------------------------------
|
|
279
327
|
|
|
280
|
-
const
|
|
281
|
-
const
|
|
328
|
+
const GO_METHOD_RE = /^func\s+\(\s*\w+\s+\*?(\w+)\s*\)\s+(\w+)\s*\(/;
|
|
329
|
+
const GO_FUNC_RE = /^func\s+(\w+)\s*\(/;
|
|
330
|
+
const GO_TYPE_RE = /^type\s+(\w+)\s+(struct|interface|\w+)/;
|
|
331
|
+
const GO_CONST_RE = /^(?:const|var)\s+(\w+)\s*(?:=|\s+\w)/;
|
|
282
332
|
|
|
283
333
|
const extractGoSymbols = (content) => {
|
|
284
334
|
const symbols = [];
|
|
@@ -286,6 +336,17 @@ const extractGoSymbols = (content) => {
|
|
|
286
336
|
|
|
287
337
|
for (let i = 0; i < lines.length; i++) {
|
|
288
338
|
const trimmed = lines[i].trimStart();
|
|
339
|
+
const methodMatch = GO_METHOD_RE.exec(trimmed);
|
|
340
|
+
if (methodMatch) {
|
|
341
|
+
symbols.push({
|
|
342
|
+
name: methodMatch[2],
|
|
343
|
+
kind: 'method',
|
|
344
|
+
line: i + 1,
|
|
345
|
+
parent: methodMatch[1],
|
|
346
|
+
signature: trimSignature(trimmed),
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
289
350
|
const funcMatch = GO_FUNC_RE.exec(trimmed);
|
|
290
351
|
if (funcMatch) {
|
|
291
352
|
symbols.push({ name: funcMatch[1], kind: 'function', line: i + 1, signature: trimSignature(trimmed) });
|
|
@@ -293,7 +354,14 @@ const extractGoSymbols = (content) => {
|
|
|
293
354
|
}
|
|
294
355
|
const typeMatch = GO_TYPE_RE.exec(trimmed);
|
|
295
356
|
if (typeMatch) {
|
|
296
|
-
|
|
357
|
+
const kind = typeMatch[2] === 'interface' ? 'interface' : 'type';
|
|
358
|
+
symbols.push({ name: typeMatch[1], kind, line: i + 1, signature: trimSignature(trimmed) });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const constMatch = GO_CONST_RE.exec(trimmed);
|
|
362
|
+
if (constMatch) {
|
|
363
|
+
const kind = trimmed.startsWith('const') ? 'const' : 'var';
|
|
364
|
+
symbols.push({ name: constMatch[1], kind, line: i + 1, signature: trimSignature(trimmed) });
|
|
297
365
|
}
|
|
298
366
|
}
|
|
299
367
|
|
|
@@ -677,6 +745,20 @@ export const extractAdrSymbols = (content, fullPath) => {
|
|
|
677
745
|
const extractFileInfo = (fullPath, content) => {
|
|
678
746
|
const ext = path.extname(fullPath).toLowerCase();
|
|
679
747
|
|
|
748
|
+
const pluggable = getParser(ext);
|
|
749
|
+
if (pluggable) {
|
|
750
|
+
try {
|
|
751
|
+
const result = pluggable({ fullPath, content }) ?? {};
|
|
752
|
+
return {
|
|
753
|
+
symbols: enrichSymbolsWithSnippets(content, result.symbols ?? []),
|
|
754
|
+
imports: result.imports ?? [],
|
|
755
|
+
exports: result.exports ?? [],
|
|
756
|
+
};
|
|
757
|
+
} catch {
|
|
758
|
+
return { symbols: [], imports: [], exports: [] };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
680
762
|
let info;
|
|
681
763
|
|
|
682
764
|
if (['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].includes(ext)) {
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const parsers = new Map();
|
|
2
|
+
const importExtractors = new Map();
|
|
3
|
+
|
|
4
|
+
export const registerParser = (extension, parser) => {
|
|
5
|
+
if (!extension || typeof parser !== 'function') return;
|
|
6
|
+
parsers.set(extension.toLowerCase(), parser);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const registerImportExtractor = (extension, extractor) => {
|
|
10
|
+
if (!extension || typeof extractor !== 'function') return;
|
|
11
|
+
importExtractors.set(extension.toLowerCase(), extractor);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const getParser = (extension) => parsers.get(extension?.toLowerCase()) ?? null;
|
|
15
|
+
|
|
16
|
+
export const getImportExtractor = (extension) => importExtractors.get(extension?.toLowerCase()) ?? null;
|
|
17
|
+
|
|
18
|
+
export const listRegisteredExtensions = () => ({
|
|
19
|
+
symbols: [...parsers.keys()],
|
|
20
|
+
imports: [...importExtractors.keys()],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const clearRegistry = () => {
|
|
24
|
+
parsers.clear();
|
|
25
|
+
importExtractors.clear();
|
|
26
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: debug-flake
|
|
2
|
+
description: Recover last red test, gather curated debug context, list affected tests
|
|
3
|
+
defaults: {}
|
|
4
|
+
stopOnFail: false
|
|
5
|
+
steps:
|
|
6
|
+
- tool: smart_test
|
|
7
|
+
label: lastFailure
|
|
8
|
+
args:
|
|
9
|
+
action: last_failure
|
|
10
|
+
- tool: smart_context
|
|
11
|
+
args:
|
|
12
|
+
task: "Debug last failing test"
|
|
13
|
+
intent: debug
|
|
14
|
+
maxTokens: 8000
|
|
15
|
+
- tool: smart_test
|
|
16
|
+
args:
|
|
17
|
+
action: affected
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: doc-sync
|
|
2
|
+
description: Surface ADR/spec sections then build curated docs context
|
|
3
|
+
defaults:
|
|
4
|
+
topic: "architecture"
|
|
5
|
+
stopOnFail: false
|
|
6
|
+
steps:
|
|
7
|
+
- tool: smart_search
|
|
8
|
+
args:
|
|
9
|
+
query: "{{args.topic}}"
|
|
10
|
+
kinds:
|
|
11
|
+
- adr
|
|
12
|
+
- adr-section
|
|
13
|
+
- tool: smart_context
|
|
14
|
+
args:
|
|
15
|
+
task: "Sync docs for {{args.topic}}"
|
|
16
|
+
intent: docs
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: preflight-merge
|
|
2
|
+
description: Review preflight + affected tests + milestone checkpoint
|
|
3
|
+
defaults:
|
|
4
|
+
ref: HEAD
|
|
5
|
+
stopOnFail: true
|
|
6
|
+
steps:
|
|
7
|
+
- tool: smart_review
|
|
8
|
+
label: review
|
|
9
|
+
args:
|
|
10
|
+
ref: "{{args.ref}}"
|
|
11
|
+
includeBlame: false
|
|
12
|
+
- tool: smart_test
|
|
13
|
+
label: affected
|
|
14
|
+
args:
|
|
15
|
+
action: affected
|
|
16
|
+
diff: "{{args.ref}}"
|
|
17
|
+
- tool: smart_turn
|
|
18
|
+
args:
|
|
19
|
+
phase: end
|
|
20
|
+
event: milestone
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: ramp-up
|
|
2
|
+
description: First contact with the project — status, doctor, and ADR overview
|
|
3
|
+
defaults: {}
|
|
4
|
+
stopOnFail: false
|
|
5
|
+
steps:
|
|
6
|
+
- tool: smart_status
|
|
7
|
+
args: {}
|
|
8
|
+
- tool: smart_doctor
|
|
9
|
+
args: {}
|
|
10
|
+
- tool: smart_search
|
|
11
|
+
args:
|
|
12
|
+
query: "architecture decisions"
|
|
13
|
+
kinds:
|
|
14
|
+
- adr
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: refactor-safe
|
|
2
|
+
description: Curated context + outline reads + affected tests, ending with checkpoint
|
|
3
|
+
defaults:
|
|
4
|
+
task: "Refactor target"
|
|
5
|
+
stopOnFail: true
|
|
6
|
+
steps:
|
|
7
|
+
- tool: smart_context
|
|
8
|
+
args:
|
|
9
|
+
task: "{{args.task}}"
|
|
10
|
+
intent: refactor
|
|
11
|
+
detail: balanced
|
|
12
|
+
- tool: smart_test
|
|
13
|
+
args:
|
|
14
|
+
action: affected
|
|
15
|
+
- tool: smart_turn
|
|
16
|
+
args:
|
|
17
|
+
phase: end
|
|
18
|
+
event: milestone
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { projectRoot } from '../utils/runtime-config.js';
|
|
5
|
+
import { parseYamlMini } from './yaml-mini.js';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const BUILTIN_DIR = path.join(__dirname, 'builtin');
|
|
9
|
+
const PROJECT_DIR_NAME = '.devctx/playbooks';
|
|
10
|
+
|
|
11
|
+
const SUPPORTED_EXT = new Set(['.yaml', '.yml', '.json']);
|
|
12
|
+
const SAFE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
13
|
+
|
|
14
|
+
const listFilesIn = (dir) => {
|
|
15
|
+
if (!fs.existsSync(dir)) return [];
|
|
16
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
17
|
+
.filter((entry) => entry.isFile() && SUPPORTED_EXT.has(path.extname(entry.name).toLowerCase()))
|
|
18
|
+
.map((entry) => path.join(dir, entry.name));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const parseContent = (content, ext) => {
|
|
22
|
+
if (ext === '.json') return JSON.parse(content);
|
|
23
|
+
return parseYamlMini(content);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const nameFromFile = (file) => {
|
|
27
|
+
const base = path.basename(file).replace(/\.(ya?ml|json)$/i, '');
|
|
28
|
+
return base;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const validatePlaybook = (raw, sourceFile) => {
|
|
32
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
33
|
+
throw new Error(`Playbook ${sourceFile}: root must be a mapping`);
|
|
34
|
+
}
|
|
35
|
+
if (!raw.name || typeof raw.name !== 'string' || !SAFE_NAME_RE.test(raw.name)) {
|
|
36
|
+
throw new Error(`Playbook ${sourceFile}: missing/invalid 'name'`);
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
|
|
39
|
+
throw new Error(`Playbook ${raw.name}: 'steps' must be a non-empty array`);
|
|
40
|
+
}
|
|
41
|
+
for (const [i, step] of raw.steps.entries()) {
|
|
42
|
+
if (!step || typeof step !== 'object') {
|
|
43
|
+
throw new Error(`Playbook ${raw.name}: step ${i} must be an object`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof step.tool !== 'string' || step.tool.length === 0) {
|
|
46
|
+
throw new Error(`Playbook ${raw.name}: step ${i} missing 'tool'`);
|
|
47
|
+
}
|
|
48
|
+
if (step.args !== undefined && step.args !== null && (typeof step.args !== 'object' || Array.isArray(step.args))) {
|
|
49
|
+
throw new Error(`Playbook ${raw.name}: step ${i} 'args' must be an object`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
name: raw.name,
|
|
54
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
55
|
+
defaults: raw.defaults && typeof raw.defaults === 'object' && !Array.isArray(raw.defaults) ? raw.defaults : {},
|
|
56
|
+
stopOnFail: raw.stopOnFail !== false,
|
|
57
|
+
steps: raw.steps.map((step) => ({
|
|
58
|
+
tool: step.tool,
|
|
59
|
+
args: (step.args && typeof step.args === 'object' && !Array.isArray(step.args)) ? step.args : {},
|
|
60
|
+
when: typeof step.when === 'string' ? step.when : null,
|
|
61
|
+
label: typeof step.label === 'string' ? step.label : null,
|
|
62
|
+
})),
|
|
63
|
+
source: sourceFile,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const collectFromDir = (dir, source) => {
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const file of listFilesIn(dir)) {
|
|
70
|
+
try {
|
|
71
|
+
const ext = path.extname(file).toLowerCase();
|
|
72
|
+
const raw = parseContent(fs.readFileSync(file, 'utf-8'), ext);
|
|
73
|
+
const playbook = validatePlaybook(raw, source);
|
|
74
|
+
if (playbook.name !== nameFromFile(file)) {
|
|
75
|
+
playbook.fileName = nameFromFile(file);
|
|
76
|
+
}
|
|
77
|
+
out.set(playbook.name, playbook);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
out.set(`__error__${path.basename(file)}`, { error: err.message, source, file });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const loadPlaybooks = ({ root = projectRoot } = {}) => {
|
|
86
|
+
const builtin = collectFromDir(BUILTIN_DIR, 'builtin');
|
|
87
|
+
const projectDir = path.join(root, PROJECT_DIR_NAME);
|
|
88
|
+
const project = collectFromDir(projectDir, 'project');
|
|
89
|
+
|
|
90
|
+
const merged = new Map(builtin);
|
|
91
|
+
for (const [name, pb] of project) merged.set(name, pb);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
playbooks: merged,
|
|
95
|
+
sources: {
|
|
96
|
+
builtinDir: BUILTIN_DIR,
|
|
97
|
+
projectDir,
|
|
98
|
+
projectExists: fs.existsSync(projectDir),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const listPlaybookSummaries = ({ root = projectRoot } = {}) => {
|
|
104
|
+
const { playbooks, sources } = loadPlaybooks({ root });
|
|
105
|
+
const summaries = [];
|
|
106
|
+
const errors = [];
|
|
107
|
+
for (const [, value] of playbooks) {
|
|
108
|
+
if (value.error) {
|
|
109
|
+
errors.push({ file: value.file, error: value.error, source: value.source });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
summaries.push({
|
|
113
|
+
name: value.name,
|
|
114
|
+
description: value.description,
|
|
115
|
+
source: value.source,
|
|
116
|
+
steps: value.steps.length,
|
|
117
|
+
defaults: value.defaults,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return { summaries, errors, sources };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const _internal = { validatePlaybook, parseContent, nameFromFile };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { smartContext } from '../tools/smart-context.js';
|
|
2
|
+
import { smartSearch } from '../tools/smart-search.js';
|
|
3
|
+
import { smartRead } from '../tools/smart-read.js';
|
|
4
|
+
import { smartReadBatch } from '../tools/smart-read-batch.js';
|
|
5
|
+
import { smartTest } from '../tools/smart-test.js';
|
|
6
|
+
import { smartReview } from '../tools/smart-review.js';
|
|
7
|
+
import { smartShell } from '../tools/smart-shell.js';
|
|
8
|
+
import { smartSummary } from '../tools/smart-summary.js';
|
|
9
|
+
import { smartTurn } from '../tools/smart-turn.js';
|
|
10
|
+
import { smartStatus } from '../tools/smart-status.js';
|
|
11
|
+
import { smartDoctor } from '../tools/smart-doctor.js';
|
|
12
|
+
import { smartMetrics } from '../tools/smart-metrics.js';
|
|
13
|
+
|
|
14
|
+
const TOOL_REGISTRY = {
|
|
15
|
+
smart_context: smartContext,
|
|
16
|
+
smart_search: smartSearch,
|
|
17
|
+
smart_read: smartRead,
|
|
18
|
+
smart_read_batch: smartReadBatch,
|
|
19
|
+
smart_test: smartTest,
|
|
20
|
+
smart_review: smartReview,
|
|
21
|
+
smart_shell: smartShell,
|
|
22
|
+
smart_summary: smartSummary,
|
|
23
|
+
smart_turn: smartTurn,
|
|
24
|
+
smart_status: smartStatus,
|
|
25
|
+
smart_doctor: smartDoctor,
|
|
26
|
+
smart_metrics: smartMetrics,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TEMPLATE_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
|
|
30
|
+
|
|
31
|
+
const resolvePath = (obj, dottedKey) => {
|
|
32
|
+
const segments = dottedKey.split('.');
|
|
33
|
+
let current = obj;
|
|
34
|
+
for (const seg of segments) {
|
|
35
|
+
if (current === null || current === undefined) return undefined;
|
|
36
|
+
current = current[seg];
|
|
37
|
+
}
|
|
38
|
+
return current;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const interpolate = (value, scope) => {
|
|
42
|
+
if (typeof value === 'string') {
|
|
43
|
+
const full = value.match(/^\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}$/);
|
|
44
|
+
if (full) {
|
|
45
|
+
const resolved = resolvePath(scope, full[1]);
|
|
46
|
+
return resolved === undefined ? value : resolved;
|
|
47
|
+
}
|
|
48
|
+
return value.replace(TEMPLATE_RE, (match, key) => {
|
|
49
|
+
const resolved = resolvePath(scope, key);
|
|
50
|
+
if (resolved === undefined || resolved === null) return '';
|
|
51
|
+
if (typeof resolved === 'object') return JSON.stringify(resolved);
|
|
52
|
+
return String(resolved);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value)) return value.map((item) => interpolate(item, scope));
|
|
56
|
+
if (value && typeof value === 'object') {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [k, v] of Object.entries(value)) out[k] = interpolate(v, scope);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const evaluateWhen = (whenExpr, scope) => {
|
|
65
|
+
if (!whenExpr) return true;
|
|
66
|
+
const expanded = interpolate(whenExpr, scope);
|
|
67
|
+
if (typeof expanded === 'boolean') return expanded;
|
|
68
|
+
if (expanded === null || expanded === undefined) return false;
|
|
69
|
+
if (typeof expanded === 'string') {
|
|
70
|
+
const lower = expanded.trim().toLowerCase();
|
|
71
|
+
if (lower === 'false' || lower === '0' || lower === '' || lower === 'no') return false;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return Boolean(expanded);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const runPlaybook = async (playbook, args = {}, { dryRun = false, toolRegistry = TOOL_REGISTRY } = {}) => {
|
|
78
|
+
const scope = { args: { ...playbook.defaults, ...args } };
|
|
79
|
+
const steps = [];
|
|
80
|
+
const startedAt = Date.now();
|
|
81
|
+
let failed = false;
|
|
82
|
+
|
|
83
|
+
for (const [i, rawStep] of playbook.steps.entries()) {
|
|
84
|
+
const stepStart = Date.now();
|
|
85
|
+
const skipped = !evaluateWhen(rawStep.when, scope);
|
|
86
|
+
|
|
87
|
+
if (skipped) {
|
|
88
|
+
steps.push({
|
|
89
|
+
index: i,
|
|
90
|
+
tool: rawStep.tool,
|
|
91
|
+
label: rawStep.label,
|
|
92
|
+
args: rawStep.args,
|
|
93
|
+
ok: true,
|
|
94
|
+
skipped: true,
|
|
95
|
+
result: null,
|
|
96
|
+
elapsedMs: 0,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tool = toolRegistry[rawStep.tool];
|
|
102
|
+
if (!tool) {
|
|
103
|
+
steps.push({
|
|
104
|
+
index: i,
|
|
105
|
+
tool: rawStep.tool,
|
|
106
|
+
label: rawStep.label,
|
|
107
|
+
args: rawStep.args,
|
|
108
|
+
ok: false,
|
|
109
|
+
skipped: false,
|
|
110
|
+
error: `Tool not allowed in playbooks: ${rawStep.tool}`,
|
|
111
|
+
elapsedMs: 0,
|
|
112
|
+
});
|
|
113
|
+
failed = true;
|
|
114
|
+
if (playbook.stopOnFail) break;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const resolvedArgs = interpolate(rawStep.args, scope);
|
|
119
|
+
|
|
120
|
+
if (dryRun) {
|
|
121
|
+
steps.push({
|
|
122
|
+
index: i,
|
|
123
|
+
tool: rawStep.tool,
|
|
124
|
+
label: rawStep.label,
|
|
125
|
+
args: resolvedArgs,
|
|
126
|
+
ok: true,
|
|
127
|
+
skipped: false,
|
|
128
|
+
dryRun: true,
|
|
129
|
+
elapsedMs: 0,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await tool(resolvedArgs);
|
|
136
|
+
const ok = result?.success !== false;
|
|
137
|
+
steps.push({
|
|
138
|
+
index: i,
|
|
139
|
+
tool: rawStep.tool,
|
|
140
|
+
label: rawStep.label,
|
|
141
|
+
args: resolvedArgs,
|
|
142
|
+
ok,
|
|
143
|
+
skipped: false,
|
|
144
|
+
result,
|
|
145
|
+
elapsedMs: Date.now() - stepStart,
|
|
146
|
+
});
|
|
147
|
+
if (!ok) {
|
|
148
|
+
failed = true;
|
|
149
|
+
if (playbook.stopOnFail) break;
|
|
150
|
+
}
|
|
151
|
+
scope[`step${i}`] = result;
|
|
152
|
+
if (rawStep.label) scope[rawStep.label] = result;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
steps.push({
|
|
155
|
+
index: i,
|
|
156
|
+
tool: rawStep.tool,
|
|
157
|
+
label: rawStep.label,
|
|
158
|
+
args: resolvedArgs,
|
|
159
|
+
ok: false,
|
|
160
|
+
skipped: false,
|
|
161
|
+
error: err?.message ?? String(err),
|
|
162
|
+
elapsedMs: Date.now() - stepStart,
|
|
163
|
+
});
|
|
164
|
+
failed = true;
|
|
165
|
+
if (playbook.stopOnFail) break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
name: playbook.name,
|
|
171
|
+
description: playbook.description,
|
|
172
|
+
success: !failed,
|
|
173
|
+
steps,
|
|
174
|
+
stepCount: playbook.steps.length,
|
|
175
|
+
executed: steps.filter((s) => !s.skipped && !s.dryRun).length,
|
|
176
|
+
skipped: steps.filter((s) => s.skipped).length,
|
|
177
|
+
totalElapsedMs: Date.now() - startedAt,
|
|
178
|
+
dryRun: !!dryRun,
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const _internal = { TOOL_REGISTRY, resolvePath, evaluateWhen };
|