agent-state-machine 2.1.9 → 2.2.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 +55 -5
- package/lib/file-tree.js +366 -0
- package/lib/index.js +109 -2
- package/lib/llm.js +12 -0
- package/lib/runtime/agent.js +146 -118
- package/lib/runtime/runtime.js +13 -2
- package/lib/runtime/track-changes.js +252 -0
- package/package.json +1 -1
- package/templates/project-builder/config.js +12 -1
- package/templates/starter/config.js +12 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ A workflow runner for building **linear, stateful agent workflows** in plain Jav
|
|
|
4
4
|
|
|
5
5
|
You write normal `async/await` code. The runtime handles:
|
|
6
6
|
- **Auto-persisted** `memory` (saved to disk on mutation)
|
|
7
|
+
- **Auto-tracked** `fileTree` (detects file changes made by agents via Git)
|
|
7
8
|
- **Human-in-the-loop** blocking via `askHuman()` or agent-driven interactions
|
|
8
9
|
- Local **JS agents** + **Markdown agents** (LLM-powered)
|
|
9
10
|
- **Agent retries** with history logging for failures
|
|
@@ -26,7 +27,7 @@ pnpm add -g agent-state-machine
|
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
### Local Library
|
|
29
|
-
Required so your `workflow.js` can `import { agent, memory } from 'agent-state-machine'`.
|
|
30
|
+
Required so your `workflow.js` can `import { agent, memory, fileTree } from 'agent-state-machine'`.
|
|
30
31
|
|
|
31
32
|
```bash
|
|
32
33
|
# npm
|
|
@@ -163,12 +164,56 @@ Context is explicit: only `params` are provided to agents unless you pass additi
|
|
|
163
164
|
A persisted object for your workflow.
|
|
164
165
|
|
|
165
166
|
- Mutations auto-save to `workflows/<name>/state/current.json`.
|
|
166
|
-
- Use it as your
|
|
167
|
+
- Use it as your "long-lived state" between runs.
|
|
167
168
|
|
|
168
169
|
```js
|
|
169
170
|
memory.count = (memory.count || 0) + 1;
|
|
170
171
|
```
|
|
171
172
|
|
|
173
|
+
### `fileTree`
|
|
174
|
+
|
|
175
|
+
Auto-tracked file changes made by agents.
|
|
176
|
+
|
|
177
|
+
- Before each `await agent(...)`, the runtime captures a Git baseline
|
|
178
|
+
- After the agent completes, it detects created/modified/deleted files
|
|
179
|
+
- Changes are stored in `memory.fileTree` and persisted to `current.json`
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
// Files are auto-tracked when agents create them
|
|
183
|
+
await agent('code-writer', { task: 'Create auth module' });
|
|
184
|
+
|
|
185
|
+
// Access tracked files
|
|
186
|
+
console.log(memory.fileTree);
|
|
187
|
+
// { "src/auth.js": { status: "created", createdBy: "code-writer", ... } }
|
|
188
|
+
|
|
189
|
+
// Pass file context to other agents
|
|
190
|
+
await agent('code-reviewer', { fileTree: memory.fileTree });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Configuration in `config.js`:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
export const config = {
|
|
197
|
+
// ... models and apiKeys ...
|
|
198
|
+
projectRoot: process.env.PROJECT_ROOT, // defaults to ../.. from workflow
|
|
199
|
+
fileTracking: true, // enable/disable (default: true)
|
|
200
|
+
fileTrackingIgnore: ['node_modules/**', '.git/**', 'dist/**'],
|
|
201
|
+
fileTrackingKeepDeleted: false // keep deleted files in tree
|
|
202
|
+
};
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### `trackFile(path, options?)` / `untrackFile(path)`
|
|
206
|
+
|
|
207
|
+
Manual file tracking utilities:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import { trackFile, getFileTree, untrackFile } from 'agent-state-machine';
|
|
211
|
+
|
|
212
|
+
trackFile('README.md', { caption: 'Project docs' });
|
|
213
|
+
const tree = getFileTree();
|
|
214
|
+
untrackFile('old-file.js');
|
|
215
|
+
```
|
|
216
|
+
|
|
172
217
|
### `askHuman(question, options?)`
|
|
173
218
|
|
|
174
219
|
Gets user input.
|
|
@@ -217,8 +262,13 @@ export default async function handler(context) {
|
|
|
217
262
|
// context includes:
|
|
218
263
|
// - params passed to agent(name, params)
|
|
219
264
|
// - context._steering (global + optional additional steering content)
|
|
220
|
-
// - context._config (models/apiKeys/workflowDir)
|
|
221
|
-
|
|
265
|
+
// - context._config (models/apiKeys/workflowDir/projectRoot)
|
|
266
|
+
|
|
267
|
+
// Optionally return _files to annotate tracked files
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
_files: [{ path: 'src/example.js', caption: 'Example module' }]
|
|
271
|
+
};
|
|
222
272
|
}
|
|
223
273
|
```
|
|
224
274
|
|
|
@@ -308,7 +358,7 @@ The runtime captures the fully-built prompt in `state/history.jsonl`, viewable i
|
|
|
308
358
|
|
|
309
359
|
Native JS workflows persist to:
|
|
310
360
|
|
|
311
|
-
- `workflows/<name>/state/current.json` — status, memory, pending interaction
|
|
361
|
+
- `workflows/<name>/state/current.json` — status, memory (includes fileTree), pending interaction
|
|
312
362
|
- `workflows/<name>/state/history.jsonl` — event log (newest entries first, includes agent retry/failure entries)
|
|
313
363
|
- `workflows/<name>/interactions/*.md` — human input files (when paused)
|
|
314
364
|
|
package/lib/file-tree.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /lib/file-tree.js
|
|
3
|
+
*
|
|
4
|
+
* File tree tracking utilities for agent-state-machine.
|
|
5
|
+
* Provides Git-based and filesystem-based change detection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { exec } from 'child_process';
|
|
11
|
+
import { promisify } from 'util';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
|
|
15
|
+
// Default ignore patterns (mainly for FS fallback, git respects .gitignore)
|
|
16
|
+
export const DEFAULT_IGNORE = [
|
|
17
|
+
'node_modules/**',
|
|
18
|
+
'.git/**',
|
|
19
|
+
'dist/**',
|
|
20
|
+
'build/**',
|
|
21
|
+
'coverage/**',
|
|
22
|
+
'.next/**',
|
|
23
|
+
'.cache/**',
|
|
24
|
+
'workflows/**',
|
|
25
|
+
'*.log',
|
|
26
|
+
'.DS_Store'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize a path for consistent storage.
|
|
31
|
+
* - Converts backslashes to forward slashes
|
|
32
|
+
* - Rejects absolute paths
|
|
33
|
+
* - Rejects traversal outside projectRoot
|
|
34
|
+
*/
|
|
35
|
+
export function normalizePath(relativePath, projectRoot) {
|
|
36
|
+
// Normalize slashes
|
|
37
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
38
|
+
|
|
39
|
+
// Reject absolute paths
|
|
40
|
+
if (path.isAbsolute(normalized)) {
|
|
41
|
+
throw new Error(`Absolute path not allowed: ${normalized}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Reject traversal outside projectRoot
|
|
45
|
+
const resolved = path.resolve(projectRoot, normalized);
|
|
46
|
+
const normalizedRoot = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep;
|
|
47
|
+
if (!resolved.startsWith(normalizedRoot) && resolved !== projectRoot.replace(/\/$/, '')) {
|
|
48
|
+
// Also allow exact match with projectRoot
|
|
49
|
+
if (resolved !== projectRoot) {
|
|
50
|
+
throw new Error(`Path traversal outside projectRoot: ${normalized}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a path matches any of the ignore patterns.
|
|
59
|
+
*/
|
|
60
|
+
export function shouldIgnore(relativePath, ignorePatterns = DEFAULT_IGNORE) {
|
|
61
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
62
|
+
|
|
63
|
+
for (const pattern of ignorePatterns) {
|
|
64
|
+
// Simple glob matching
|
|
65
|
+
if (pattern.endsWith('/**')) {
|
|
66
|
+
const prefix = pattern.slice(0, -3);
|
|
67
|
+
if (normalized.startsWith(prefix + '/') || normalized === prefix) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
} else if (pattern.startsWith('*.')) {
|
|
71
|
+
const ext = pattern.slice(1);
|
|
72
|
+
if (normalized.endsWith(ext)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
} else if (normalized === pattern || normalized.startsWith(pattern + '/')) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Git-based change detection
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse git status --porcelain -z output into a Map.
|
|
89
|
+
* Format: XY PATH\0 (or XY ORIG\0PATH\0 for renames)
|
|
90
|
+
*/
|
|
91
|
+
function parseGitStatus(stdout) {
|
|
92
|
+
const files = new Map();
|
|
93
|
+
if (!stdout) return files;
|
|
94
|
+
|
|
95
|
+
const entries = stdout.split('\0').filter(Boolean);
|
|
96
|
+
let i = 0;
|
|
97
|
+
|
|
98
|
+
while (i < entries.length) {
|
|
99
|
+
const entry = entries[i];
|
|
100
|
+
if (entry.length < 3) {
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const statusCode = entry.slice(0, 2);
|
|
106
|
+
const filePath = entry.slice(3);
|
|
107
|
+
|
|
108
|
+
// Handle renames (R) - next entry is the new path
|
|
109
|
+
if (statusCode.startsWith('R') || statusCode.startsWith('C')) {
|
|
110
|
+
i++;
|
|
111
|
+
const newPath = entries[i];
|
|
112
|
+
if (newPath) {
|
|
113
|
+
files.set(newPath, statusCode);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
files.set(filePath, statusCode);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Capture current git status as a baseline.
|
|
127
|
+
* Returns Map of file paths to their status codes.
|
|
128
|
+
*/
|
|
129
|
+
export async function captureGitBaseline(projectRoot) {
|
|
130
|
+
try {
|
|
131
|
+
// Check if git repo
|
|
132
|
+
await execAsync('git rev-parse --git-dir', { cwd: projectRoot });
|
|
133
|
+
|
|
134
|
+
// Get status: untracked + staged + unstaged
|
|
135
|
+
const { stdout } = await execAsync(
|
|
136
|
+
'git status --porcelain -z',
|
|
137
|
+
{ cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return { files: parseGitStatus(stdout), isGitRepo: true };
|
|
141
|
+
} catch {
|
|
142
|
+
return { files: new Map(), isGitRepo: false };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Detect changes between baseline and current state.
|
|
148
|
+
* Only attributes changes made since baseline capture.
|
|
149
|
+
*/
|
|
150
|
+
export async function detectGitChanges(projectRoot, baseline, ignorePatterns = DEFAULT_IGNORE) {
|
|
151
|
+
const after = await captureGitBaseline(projectRoot);
|
|
152
|
+
|
|
153
|
+
const created = [];
|
|
154
|
+
const modified = [];
|
|
155
|
+
const deleted = [];
|
|
156
|
+
|
|
157
|
+
// Find new and modified files
|
|
158
|
+
for (const [filePath, status] of after.files) {
|
|
159
|
+
if (shouldIgnore(filePath, ignorePatterns)) continue;
|
|
160
|
+
|
|
161
|
+
if (!baseline.files.has(filePath)) {
|
|
162
|
+
created.push(filePath);
|
|
163
|
+
} else if (baseline.files.get(filePath) !== status) {
|
|
164
|
+
modified.push(filePath);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Find deleted files
|
|
169
|
+
for (const [filePath] of baseline.files) {
|
|
170
|
+
if (shouldIgnore(filePath, ignorePatterns)) continue;
|
|
171
|
+
|
|
172
|
+
if (!after.files.has(filePath)) {
|
|
173
|
+
deleted.push(filePath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// MVP: Treat renames as delete+create
|
|
178
|
+
// Future: Use `git diff --name-status -z -M` for rename detection
|
|
179
|
+
return { created, modified, deleted, renamed: [] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// Filesystem-based change detection (fallback when no git)
|
|
184
|
+
// ============================================================================
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Recursively walk a directory and collect file info.
|
|
188
|
+
*/
|
|
189
|
+
async function walkDirectory(dir, baseDir, ignorePatterns, result = new Map()) {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
193
|
+
} catch {
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const fullPath = path.join(dir, entry.name);
|
|
199
|
+
const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
200
|
+
|
|
201
|
+
if (shouldIgnore(relativePath, ignorePatterns)) continue;
|
|
202
|
+
|
|
203
|
+
if (entry.isDirectory()) {
|
|
204
|
+
await walkDirectory(fullPath, baseDir, ignorePatterns, result);
|
|
205
|
+
} else if (entry.isFile()) {
|
|
206
|
+
try {
|
|
207
|
+
const stats = fs.statSync(fullPath);
|
|
208
|
+
result.set(relativePath, {
|
|
209
|
+
mtime: stats.mtimeMs,
|
|
210
|
+
size: stats.size
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
// File may have been deleted between readdir and stat
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Capture filesystem snapshot for change detection.
|
|
223
|
+
*/
|
|
224
|
+
export async function captureFilesystemSnapshot(projectRoot, ignorePatterns = DEFAULT_IGNORE) {
|
|
225
|
+
const files = await walkDirectory(projectRoot, projectRoot, ignorePatterns);
|
|
226
|
+
return { files, isGitRepo: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Detect filesystem changes between baseline and current state.
|
|
231
|
+
*/
|
|
232
|
+
export async function detectFilesystemChanges(projectRoot, baseline, ignorePatterns = DEFAULT_IGNORE) {
|
|
233
|
+
const after = await captureFilesystemSnapshot(projectRoot, ignorePatterns);
|
|
234
|
+
|
|
235
|
+
const created = [];
|
|
236
|
+
const modified = [];
|
|
237
|
+
const deleted = [];
|
|
238
|
+
|
|
239
|
+
// Find new and modified files
|
|
240
|
+
for (const [filePath, info] of after.files) {
|
|
241
|
+
const baselineInfo = baseline.files.get(filePath);
|
|
242
|
+
|
|
243
|
+
if (!baselineInfo) {
|
|
244
|
+
created.push(filePath);
|
|
245
|
+
} else if (baselineInfo.mtime !== info.mtime || baselineInfo.size !== info.size) {
|
|
246
|
+
modified.push(filePath);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Find deleted files
|
|
251
|
+
for (const [filePath] of baseline.files) {
|
|
252
|
+
if (!after.files.has(filePath)) {
|
|
253
|
+
deleted.push(filePath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { created, modified, deleted, renamed: [] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Unified baseline capture - uses Git if available, falls back to filesystem.
|
|
262
|
+
*/
|
|
263
|
+
export async function captureBaseline(projectRoot, ignorePatterns = DEFAULT_IGNORE) {
|
|
264
|
+
const gitBaseline = await captureGitBaseline(projectRoot);
|
|
265
|
+
|
|
266
|
+
if (gitBaseline.isGitRepo) {
|
|
267
|
+
return gitBaseline;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return captureFilesystemSnapshot(projectRoot, ignorePatterns);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Unified change detection - uses Git if available, falls back to filesystem.
|
|
275
|
+
*/
|
|
276
|
+
export async function detectChanges(projectRoot, baseline, ignorePatterns = DEFAULT_IGNORE) {
|
|
277
|
+
if (baseline.isGitRepo) {
|
|
278
|
+
return detectGitChanges(projectRoot, baseline, ignorePatterns);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return detectFilesystemChanges(projectRoot, baseline, ignorePatterns);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// Export extraction (best-effort)
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Extract exports from JavaScript/TypeScript file content using regex.
|
|
290
|
+
* This is a best-effort extraction, not a full parser.
|
|
291
|
+
*/
|
|
292
|
+
export function extractExportsFromContent(content) {
|
|
293
|
+
if (!content || typeof content !== 'string') return null;
|
|
294
|
+
|
|
295
|
+
const exports = new Set();
|
|
296
|
+
|
|
297
|
+
// export const/let/var/function/class Name
|
|
298
|
+
const namedExportPattern = /export\s+(?:const|let|var|function|class|async\s+function)\s+(\w+)/g;
|
|
299
|
+
let match;
|
|
300
|
+
while ((match = namedExportPattern.exec(content)) !== null) {
|
|
301
|
+
exports.add(match[1]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// export { Name, Name2 as Alias }
|
|
305
|
+
const bracedExportPattern = /export\s*\{([^}]+)\}/g;
|
|
306
|
+
while ((match = bracedExportPattern.exec(content)) !== null) {
|
|
307
|
+
const items = match[1].split(',');
|
|
308
|
+
for (const item of items) {
|
|
309
|
+
const name = item.trim().split(/\s+as\s+/)[0].trim();
|
|
310
|
+
if (name && /^\w+$/.test(name)) {
|
|
311
|
+
exports.add(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// export default (tracked as 'default')
|
|
317
|
+
if (/export\s+default\s/.test(content)) {
|
|
318
|
+
exports.add('default');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// module.exports = { name: ... } (CommonJS)
|
|
322
|
+
const cjsPattern = /module\.exports\s*=\s*\{([^}]+)\}/;
|
|
323
|
+
const cjsMatch = content.match(cjsPattern);
|
|
324
|
+
if (cjsMatch) {
|
|
325
|
+
const items = cjsMatch[1].split(',');
|
|
326
|
+
for (const item of items) {
|
|
327
|
+
const name = item.split(':')[0].trim();
|
|
328
|
+
if (name && /^\w+$/.test(name)) {
|
|
329
|
+
exports.add(name);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// module.exports = Name (CommonJS default)
|
|
335
|
+
const cjsDefaultPattern = /module\.exports\s*=\s*(\w+)\s*[;\n]/;
|
|
336
|
+
const cjsDefaultMatch = content.match(cjsDefaultPattern);
|
|
337
|
+
if (cjsDefaultMatch && cjsDefaultMatch[1] !== '{') {
|
|
338
|
+
exports.add('default');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return exports.size > 0 ? Array.from(exports).sort() : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Extract exports from a file on disk.
|
|
346
|
+
*/
|
|
347
|
+
export function extractExportsFromFile(absolutePath) {
|
|
348
|
+
if (!fs.existsSync(absolutePath)) return null;
|
|
349
|
+
|
|
350
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
351
|
+
if (!['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) return null;
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const content = fs.readFileSync(absolutePath, 'utf-8');
|
|
355
|
+
return extractExportsFromContent(content);
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ============================================================================
|
|
362
|
+
// File tree management (for manual tracking)
|
|
363
|
+
// ============================================================================
|
|
364
|
+
|
|
365
|
+
// These functions require access to the runtime, so they're implemented
|
|
366
|
+
// in track-changes.js and re-exported from index.js
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import { setup } from './setup.js';
|
|
8
8
|
import { llm, llmText, llmJSON, parseJSON, detectAvailableCLIs } from './llm.js';
|
|
9
|
+
import { extractExportsFromContent, extractExportsFromFile } from './file-tree.js';
|
|
10
|
+
import {
|
|
11
|
+
trackFile as trackFileInternal,
|
|
12
|
+
getFileTree as getFileTreeInternal,
|
|
13
|
+
untrackFile as untrackFileInternal
|
|
14
|
+
} from './runtime/track-changes.js';
|
|
9
15
|
|
|
10
16
|
import {
|
|
11
17
|
WorkflowRuntime,
|
|
@@ -83,6 +89,97 @@ export const memory = new Proxy(
|
|
|
83
89
|
}
|
|
84
90
|
);
|
|
85
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Live fileTree proxy:
|
|
94
|
+
* - Reads/writes target the current workflow runtime's fileTree in memory
|
|
95
|
+
* - Indexed by relative file path from projectRoot
|
|
96
|
+
*/
|
|
97
|
+
export const fileTree = new Proxy(
|
|
98
|
+
{},
|
|
99
|
+
{
|
|
100
|
+
get(_target, prop) {
|
|
101
|
+
const runtime = getCurrentRuntime();
|
|
102
|
+
if (!runtime) return undefined;
|
|
103
|
+
return runtime._rawMemory.fileTree?.[prop];
|
|
104
|
+
},
|
|
105
|
+
set(_target, prop, value) {
|
|
106
|
+
const runtime = getCurrentRuntime();
|
|
107
|
+
if (!runtime) {
|
|
108
|
+
throw new Error('fileTree can only be mutated within a running workflow');
|
|
109
|
+
}
|
|
110
|
+
if (!runtime._rawMemory.fileTree) {
|
|
111
|
+
runtime._rawMemory.fileTree = {};
|
|
112
|
+
}
|
|
113
|
+
runtime._rawMemory.fileTree[prop] = value;
|
|
114
|
+
runtime.memory.fileTree = runtime._rawMemory.fileTree;
|
|
115
|
+
return true;
|
|
116
|
+
},
|
|
117
|
+
deleteProperty(_target, prop) {
|
|
118
|
+
const runtime = getCurrentRuntime();
|
|
119
|
+
if (!runtime) {
|
|
120
|
+
throw new Error('fileTree can only be mutated within a running workflow');
|
|
121
|
+
}
|
|
122
|
+
if (runtime._rawMemory.fileTree) {
|
|
123
|
+
delete runtime._rawMemory.fileTree[prop];
|
|
124
|
+
runtime.memory.fileTree = runtime._rawMemory.fileTree;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
},
|
|
128
|
+
has(_target, prop) {
|
|
129
|
+
const runtime = getCurrentRuntime();
|
|
130
|
+
return runtime?._rawMemory.fileTree?.hasOwnProperty(prop) ?? false;
|
|
131
|
+
},
|
|
132
|
+
ownKeys() {
|
|
133
|
+
const runtime = getCurrentRuntime();
|
|
134
|
+
return Object.keys(runtime?._rawMemory.fileTree || {});
|
|
135
|
+
},
|
|
136
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
137
|
+
const runtime = getCurrentRuntime();
|
|
138
|
+
if (runtime?._rawMemory.fileTree?.hasOwnProperty(prop)) {
|
|
139
|
+
return {
|
|
140
|
+
configurable: true,
|
|
141
|
+
enumerable: true,
|
|
142
|
+
value: runtime._rawMemory.fileTree[prop]
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Track a file in the fileTree.
|
|
152
|
+
* @param {string} relativePath - Path relative to projectRoot
|
|
153
|
+
* @param {object} options - Tracking options (caption, exports, extractExports, metadata, agentName)
|
|
154
|
+
*/
|
|
155
|
+
export function trackFile(relativePath, options = {}) {
|
|
156
|
+
const runtime = getCurrentRuntime();
|
|
157
|
+
if (!runtime) {
|
|
158
|
+
throw new Error('trackFile() must be called within a workflow context');
|
|
159
|
+
}
|
|
160
|
+
return trackFileInternal(runtime, relativePath, options);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get all tracked files.
|
|
165
|
+
*/
|
|
166
|
+
export function getFileTree() {
|
|
167
|
+
const runtime = getCurrentRuntime();
|
|
168
|
+
if (!runtime) return {};
|
|
169
|
+
return getFileTreeInternal(runtime);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Remove a file from tracking.
|
|
174
|
+
*/
|
|
175
|
+
export function untrackFile(relativePath) {
|
|
176
|
+
const runtime = getCurrentRuntime();
|
|
177
|
+
if (!runtime) {
|
|
178
|
+
throw new Error('untrackFile() must be called within a workflow context');
|
|
179
|
+
}
|
|
180
|
+
return untrackFileInternal(runtime, relativePath);
|
|
181
|
+
}
|
|
182
|
+
|
|
86
183
|
export {
|
|
87
184
|
setup,
|
|
88
185
|
llm,
|
|
@@ -108,7 +205,10 @@ export {
|
|
|
108
205
|
parallel,
|
|
109
206
|
parallelLimit,
|
|
110
207
|
getCurrentRuntime,
|
|
111
|
-
getMemory
|
|
208
|
+
getMemory,
|
|
209
|
+
// File tree utilities
|
|
210
|
+
extractExportsFromContent,
|
|
211
|
+
extractExportsFromFile
|
|
112
212
|
};
|
|
113
213
|
|
|
114
214
|
const api = {
|
|
@@ -137,7 +237,14 @@ const api = {
|
|
|
137
237
|
parallelLimit,
|
|
138
238
|
getCurrentRuntime,
|
|
139
239
|
getMemory,
|
|
140
|
-
memory
|
|
240
|
+
memory,
|
|
241
|
+
// File tree
|
|
242
|
+
fileTree,
|
|
243
|
+
trackFile,
|
|
244
|
+
getFileTree,
|
|
245
|
+
untrackFile,
|
|
246
|
+
extractExportsFromContent,
|
|
247
|
+
extractExportsFromFile
|
|
141
248
|
};
|
|
142
249
|
|
|
143
250
|
export default api;
|
package/lib/llm.js
CHANGED
|
@@ -162,6 +162,18 @@ export function buildPrompt(context, options) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Add file tracking context if enabled
|
|
166
|
+
if (context._config?.fileTracking !== false && context._config?.projectRoot) {
|
|
167
|
+
parts.push('# File Context\n\n');
|
|
168
|
+
parts.push(`You are running from: ${context._config.workflowDir}\n`);
|
|
169
|
+
parts.push(`Project root (where to create files): ${context._config.projectRoot}\n\n`);
|
|
170
|
+
parts.push('When creating or modifying files, use paths relative to the project root.\n');
|
|
171
|
+
parts.push('To annotate files, include `_files` in your JSON response:\n');
|
|
172
|
+
parts.push('```json\n');
|
|
173
|
+
parts.push('{ "_files": [{ "path": "src/file.js", "caption": "Brief description" }] }\n');
|
|
174
|
+
parts.push('```\n---\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
165
177
|
return parts.join('\n');
|
|
166
178
|
}
|
|
167
179
|
|
package/lib/runtime/agent.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createRequire } from 'module';
|
|
|
12
12
|
import { pathToFileURL } from 'url';
|
|
13
13
|
import { getCurrentRuntime } from './runtime.js';
|
|
14
14
|
import { formatInteractionPrompt } from './interaction.js';
|
|
15
|
+
import { withChangeTracking } from './track-changes.js';
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
|
|
@@ -164,25 +165,37 @@ async function executeJSAgent(runtime, agentPath, name, params, options = {}) {
|
|
|
164
165
|
_config: {
|
|
165
166
|
models: runtime.workflowConfig.models,
|
|
166
167
|
apiKeys: runtime.workflowConfig.apiKeys,
|
|
167
|
-
workflowDir: runtime.workflowDir
|
|
168
|
+
workflowDir: runtime.workflowDir,
|
|
169
|
+
projectRoot: runtime.workflowConfig.projectRoot
|
|
168
170
|
}
|
|
169
171
|
};
|
|
170
172
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
173
|
+
// Execute handler with optional file change tracking
|
|
174
|
+
const executeHandler = async () => {
|
|
175
|
+
let result = await handler(context);
|
|
176
|
+
let interactionDepth = 0;
|
|
177
|
+
|
|
178
|
+
// Handle interaction response from JS agent (support multiple rounds)
|
|
179
|
+
while (result && result._interaction) {
|
|
180
|
+
const interactionResponse = await handleInteraction(runtime, result._interaction, name);
|
|
181
|
+
const resumedContext = { ...context, userResponse: interactionResponse };
|
|
182
|
+
await logAgentStart(runtime, name);
|
|
183
|
+
result = await handler(resumedContext);
|
|
184
|
+
interactionDepth += 1;
|
|
185
|
+
if (interactionDepth > 5) {
|
|
186
|
+
throw new Error(`Agent ${name} exceeded maximum interaction depth`);
|
|
187
|
+
}
|
|
183
188
|
}
|
|
184
|
-
}
|
|
185
189
|
|
|
190
|
+
return result;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let result;
|
|
194
|
+
if (runtime.workflowConfig.fileTracking !== false) {
|
|
195
|
+
result = await withChangeTracking(runtime, name, executeHandler);
|
|
196
|
+
} else {
|
|
197
|
+
result = await executeHandler();
|
|
198
|
+
}
|
|
186
199
|
|
|
187
200
|
// Clean internal properties from result
|
|
188
201
|
if (result && typeof result === 'object') {
|
|
@@ -191,6 +204,7 @@ async function executeJSAgent(runtime, agentPath, name, params, options = {}) {
|
|
|
191
204
|
delete cleanResult._config;
|
|
192
205
|
delete cleanResult._loop;
|
|
193
206
|
delete cleanResult._interaction;
|
|
207
|
+
delete cleanResult._files;
|
|
194
208
|
|
|
195
209
|
// If agent returned a context-like object, only return non-internal keys
|
|
196
210
|
const meaningfulKeys = Object.keys(cleanResult).filter((k) => !k.startsWith('_'));
|
|
@@ -235,127 +249,141 @@ async function executeMDAgent(runtime, agentPath, name, params, options = {}) {
|
|
|
235
249
|
? runtime.loadSteeringFiles(steeringNames)
|
|
236
250
|
: runtime.steering;
|
|
237
251
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const context = {
|
|
246
|
-
...currentParams,
|
|
247
|
-
_steering: steeringContext,
|
|
248
|
-
_config: {
|
|
249
|
-
models: runtime.workflowConfig.models,
|
|
250
|
-
apiKeys: runtime.workflowConfig.apiKeys,
|
|
251
|
-
workflowDir: runtime.workflowDir
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
// Interpolate variables in prompt
|
|
256
|
-
const interpolatedPrompt = interpolatePrompt(prompt, context);
|
|
252
|
+
// Build base config (used for all iterations)
|
|
253
|
+
const baseConfig = {
|
|
254
|
+
models: runtime.workflowConfig.models,
|
|
255
|
+
apiKeys: runtime.workflowConfig.apiKeys,
|
|
256
|
+
workflowDir: runtime.workflowDir,
|
|
257
|
+
projectRoot: runtime.workflowConfig.projectRoot
|
|
258
|
+
};
|
|
257
259
|
|
|
258
|
-
|
|
260
|
+
// Execute the MD agent core logic
|
|
261
|
+
const executeMDAgentCore = async () => {
|
|
262
|
+
let response = null;
|
|
263
|
+
let output = null;
|
|
264
|
+
let interactionDepth = 0;
|
|
265
|
+
let currentParams = params;
|
|
266
|
+
|
|
267
|
+
while (true) {
|
|
268
|
+
// Build context - only spread params, NOT memory (explicit context passing)
|
|
269
|
+
const context = {
|
|
270
|
+
...currentParams,
|
|
271
|
+
_steering: steeringContext,
|
|
272
|
+
_config: baseConfig
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Interpolate variables in prompt
|
|
276
|
+
const interpolatedPrompt = interpolatePrompt(prompt, context);
|
|
277
|
+
|
|
278
|
+
const model = config.model || 'fast';
|
|
279
|
+
|
|
280
|
+
const fullPrompt = buildPrompt(context, {
|
|
281
|
+
model,
|
|
282
|
+
prompt: interpolatedPrompt,
|
|
283
|
+
includeContext: config.includeContext !== 'false',
|
|
284
|
+
responseType: config.response
|
|
285
|
+
});
|
|
259
286
|
|
|
260
|
-
|
|
261
|
-
model,
|
|
262
|
-
prompt: interpolatedPrompt,
|
|
263
|
-
includeContext: config.includeContext !== 'false',
|
|
264
|
-
responseType: config.response
|
|
265
|
-
});
|
|
287
|
+
await logAgentStart(runtime, name, fullPrompt);
|
|
266
288
|
|
|
267
|
-
|
|
289
|
+
console.log(` Using model: ${model}`);
|
|
268
290
|
|
|
269
|
-
|
|
291
|
+
response = await llm(context, {
|
|
292
|
+
model: model,
|
|
293
|
+
prompt: interpolatedPrompt,
|
|
294
|
+
includeContext: config.includeContext !== 'false',
|
|
295
|
+
responseType: config.response
|
|
296
|
+
});
|
|
270
297
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
298
|
+
// Parse output based on format
|
|
299
|
+
output = response.text;
|
|
300
|
+
if (config.format === 'json') {
|
|
301
|
+
try {
|
|
302
|
+
output = parseJSON(response.text);
|
|
303
|
+
} catch {
|
|
304
|
+
console.warn(` Warning: Failed to parse JSON output`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
277
307
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
308
|
+
if (output && typeof output === 'object' && output._interaction) {
|
|
309
|
+
const interactionResponse = await handleInteraction(runtime, output._interaction, name);
|
|
310
|
+
currentParams = { ...params, userResponse: interactionResponse };
|
|
311
|
+
interactionDepth += 1;
|
|
312
|
+
if (interactionDepth > 5) {
|
|
313
|
+
throw new Error(`Agent ${name} exceeded maximum interaction depth`);
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
285
316
|
}
|
|
317
|
+
|
|
318
|
+
break;
|
|
286
319
|
}
|
|
287
320
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
321
|
+
// Check for interaction request
|
|
322
|
+
const parsedInteraction = parseInteractionRequest(response.text);
|
|
323
|
+
const structuredInteraction =
|
|
324
|
+
config.autoInteract !== 'false' && parsedInteraction.isInteraction;
|
|
325
|
+
|
|
326
|
+
// Check if agent returned an 'interact' object in its JSON response
|
|
327
|
+
const hasInteractKey = output && typeof output === 'object' && output.interact;
|
|
328
|
+
|
|
329
|
+
// Explicit interaction mode (format: interaction OR interaction: true)
|
|
330
|
+
// But only trigger if agent actually wants to interact (has interact key or parsed interaction)
|
|
331
|
+
const explicitInteraction =
|
|
332
|
+
config.format === 'interaction' ||
|
|
333
|
+
((config.interaction === 'true' || (typeof config.interaction === 'string' && config.interaction.length > 0)) &&
|
|
334
|
+
(hasInteractKey || structuredInteraction));
|
|
335
|
+
|
|
336
|
+
if (explicitInteraction || structuredInteraction) {
|
|
337
|
+
// Use interact object if present, otherwise fall back to parsed/raw
|
|
338
|
+
const interactionData = hasInteractKey ? output.interact : (structuredInteraction ? parsedInteraction : null);
|
|
339
|
+
|
|
340
|
+
const slugRaw =
|
|
341
|
+
interactionData?.slug ||
|
|
342
|
+
(typeof config.interaction === 'string' && config.interaction !== 'true'
|
|
343
|
+
? config.interaction
|
|
344
|
+
: null) ||
|
|
345
|
+
config.interactionSlug ||
|
|
346
|
+
config.interactionKey ||
|
|
347
|
+
name;
|
|
348
|
+
|
|
349
|
+
const slug = sanitizeSlug(slugRaw);
|
|
350
|
+
const targetKey = config.interactionKey || outputKey || slug;
|
|
351
|
+
|
|
352
|
+
// Build interaction object with full metadata
|
|
353
|
+
const interactionObj = hasInteractKey ? {
|
|
354
|
+
...output.interact,
|
|
355
|
+
slug,
|
|
356
|
+
targetKey
|
|
357
|
+
} : {
|
|
358
|
+
slug,
|
|
359
|
+
targetKey,
|
|
360
|
+
content: structuredInteraction ? parsedInteraction.question : response.text
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const userResponse = await handleInteraction(runtime, interactionObj, name);
|
|
364
|
+
|
|
365
|
+
// Return the user's response as the agent result
|
|
366
|
+
if (outputKey) {
|
|
367
|
+
return { [outputKey]: userResponse, _debug_prompt: response.fullPrompt };
|
|
294
368
|
}
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
369
|
|
|
298
|
-
|
|
299
|
-
|
|
370
|
+
return userResponse;
|
|
371
|
+
}
|
|
300
372
|
|
|
301
|
-
|
|
302
|
-
const parsedInteraction = parseInteractionRequest(response.text);
|
|
303
|
-
const structuredInteraction =
|
|
304
|
-
config.autoInteract !== 'false' && parsedInteraction.isInteraction;
|
|
305
|
-
|
|
306
|
-
// Check if agent returned an 'interact' object in its JSON response
|
|
307
|
-
const hasInteractKey = output && typeof output === 'object' && output.interact;
|
|
308
|
-
|
|
309
|
-
// Explicit interaction mode (format: interaction OR interaction: true)
|
|
310
|
-
// But only trigger if agent actually wants to interact (has interact key or parsed interaction)
|
|
311
|
-
const explicitInteraction =
|
|
312
|
-
config.format === 'interaction' ||
|
|
313
|
-
((config.interaction === 'true' || (typeof config.interaction === 'string' && config.interaction.length > 0)) &&
|
|
314
|
-
(hasInteractKey || structuredInteraction));
|
|
315
|
-
|
|
316
|
-
if (explicitInteraction || structuredInteraction) {
|
|
317
|
-
// Use interact object if present, otherwise fall back to parsed/raw
|
|
318
|
-
const interactionData = hasInteractKey ? output.interact : (structuredInteraction ? parsedInteraction : null);
|
|
319
|
-
|
|
320
|
-
const slugRaw =
|
|
321
|
-
interactionData?.slug ||
|
|
322
|
-
(typeof config.interaction === 'string' && config.interaction !== 'true'
|
|
323
|
-
? config.interaction
|
|
324
|
-
: null) ||
|
|
325
|
-
config.interactionSlug ||
|
|
326
|
-
config.interactionKey ||
|
|
327
|
-
name;
|
|
328
|
-
|
|
329
|
-
const slug = sanitizeSlug(slugRaw);
|
|
330
|
-
const targetKey = config.interactionKey || outputKey || slug;
|
|
331
|
-
|
|
332
|
-
// Build interaction object with full metadata
|
|
333
|
-
const interactionObj = hasInteractKey ? {
|
|
334
|
-
...output.interact,
|
|
335
|
-
slug,
|
|
336
|
-
targetKey
|
|
337
|
-
} : {
|
|
338
|
-
slug,
|
|
339
|
-
targetKey,
|
|
340
|
-
content: structuredInteraction ? parsedInteraction.question : response.text
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const userResponse = await handleInteraction(runtime, interactionObj, name);
|
|
344
|
-
|
|
345
|
-
// Return the user's response as the agent result
|
|
373
|
+
// Return result object
|
|
346
374
|
if (outputKey) {
|
|
347
|
-
return { [outputKey]:
|
|
375
|
+
return { [outputKey]: output, _debug_prompt: response.fullPrompt };
|
|
348
376
|
}
|
|
349
377
|
|
|
350
|
-
return
|
|
351
|
-
}
|
|
378
|
+
return output;
|
|
379
|
+
};
|
|
352
380
|
|
|
353
|
-
//
|
|
354
|
-
if (
|
|
355
|
-
return
|
|
381
|
+
// Execute with optional file change tracking
|
|
382
|
+
if (runtime.workflowConfig.fileTracking !== false) {
|
|
383
|
+
return withChangeTracking(runtime, name, executeMDAgentCore);
|
|
384
|
+
} else {
|
|
385
|
+
return executeMDAgentCore();
|
|
356
386
|
}
|
|
357
|
-
|
|
358
|
-
return output;
|
|
359
387
|
}
|
|
360
388
|
|
|
361
389
|
/**
|
package/lib/runtime/runtime.js
CHANGED
|
@@ -12,6 +12,7 @@ import readline from 'readline';
|
|
|
12
12
|
import { pathToFileURL } from 'url';
|
|
13
13
|
import { createMemoryProxy } from './memory.js';
|
|
14
14
|
import { RemoteClient } from '../remote/client.js';
|
|
15
|
+
import { DEFAULT_IGNORE } from '../file-tree.js';
|
|
15
16
|
|
|
16
17
|
// Global runtime reference for agent() and memory access
|
|
17
18
|
// stored on globalThis to ensure singleton access across different module instances (CLI vs local)
|
|
@@ -74,7 +75,12 @@ export class WorkflowRuntime {
|
|
|
74
75
|
this.workflowConfig = {
|
|
75
76
|
models: {},
|
|
76
77
|
apiKeys: {},
|
|
77
|
-
description: ''
|
|
78
|
+
description: '',
|
|
79
|
+
// File tracking defaults
|
|
80
|
+
projectRoot: path.resolve(workflowDir, '../..'),
|
|
81
|
+
fileTracking: true,
|
|
82
|
+
fileTrackingIgnore: DEFAULT_IGNORE,
|
|
83
|
+
fileTrackingKeepDeleted: false
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
// Load steering
|
|
@@ -317,7 +323,12 @@ export class WorkflowRuntime {
|
|
|
317
323
|
this.workflowConfig = {
|
|
318
324
|
models: cfg.models || {},
|
|
319
325
|
apiKeys: cfg.apiKeys || {},
|
|
320
|
-
description: cfg.description || ''
|
|
326
|
+
description: cfg.description || '',
|
|
327
|
+
// File tracking configuration
|
|
328
|
+
projectRoot: cfg.projectRoot || path.resolve(this.workflowDir, '../..'),
|
|
329
|
+
fileTracking: cfg.fileTracking ?? true,
|
|
330
|
+
fileTrackingIgnore: cfg.fileTrackingIgnore || DEFAULT_IGNORE,
|
|
331
|
+
fileTrackingKeepDeleted: cfg.fileTrackingKeepDeleted ?? false
|
|
321
332
|
};
|
|
322
333
|
|
|
323
334
|
// Import workflow module
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: /lib/runtime/track-changes.js
|
|
3
|
+
*
|
|
4
|
+
* Wraps agent execution with file change tracking.
|
|
5
|
+
* Captures baseline before agent runs, detects changes after,
|
|
6
|
+
* and updates memory.fileTree with the results.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import {
|
|
11
|
+
captureBaseline,
|
|
12
|
+
detectChanges,
|
|
13
|
+
normalizePath,
|
|
14
|
+
extractExportsFromFile,
|
|
15
|
+
DEFAULT_IGNORE
|
|
16
|
+
} from '../file-tree.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wrap an async function with file change tracking.
|
|
20
|
+
* Captures baseline before execution, detects changes after,
|
|
21
|
+
* and updates the runtime's fileTree.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} runtime - The workflow runtime instance
|
|
24
|
+
* @param {string} agentName - Name of the agent (for attribution)
|
|
25
|
+
* @param {Function} fn - Async function to execute
|
|
26
|
+
* @returns {Promise<any>} - Result of the function
|
|
27
|
+
*/
|
|
28
|
+
export async function withChangeTracking(runtime, agentName, fn) {
|
|
29
|
+
const projectRoot = runtime.workflowConfig.projectRoot;
|
|
30
|
+
const ignorePatterns = runtime.workflowConfig.fileTrackingIgnore || DEFAULT_IGNORE;
|
|
31
|
+
|
|
32
|
+
// Capture baseline before agent runs
|
|
33
|
+
const baseline = await captureBaseline(projectRoot, ignorePatterns);
|
|
34
|
+
|
|
35
|
+
// Run the agent
|
|
36
|
+
const result = await fn();
|
|
37
|
+
|
|
38
|
+
// Detect changes made during agent execution
|
|
39
|
+
const changes = await detectChanges(projectRoot, baseline, ignorePatterns);
|
|
40
|
+
|
|
41
|
+
// Update fileTree with detected changes
|
|
42
|
+
applyChangesToFileTree(runtime, changes, agentName);
|
|
43
|
+
|
|
44
|
+
// Merge _files annotations if present (preserves existing data unless explicitly overwritten)
|
|
45
|
+
if (result && typeof result === 'object' && Array.isArray(result._files)) {
|
|
46
|
+
mergeAnnotations(runtime, result._files);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Apply detected file changes to the runtime's fileTree.
|
|
54
|
+
*/
|
|
55
|
+
function applyChangesToFileTree(runtime, changes, agentName) {
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const projectRoot = runtime.workflowConfig.projectRoot;
|
|
58
|
+
|
|
59
|
+
// Initialize fileTree if needed
|
|
60
|
+
if (!runtime._rawMemory.fileTree) {
|
|
61
|
+
runtime._rawMemory.fileTree = {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle created files
|
|
65
|
+
for (const filePath of changes.created) {
|
|
66
|
+
try {
|
|
67
|
+
const normalized = normalizePath(filePath, projectRoot);
|
|
68
|
+
runtime._rawMemory.fileTree[normalized] = {
|
|
69
|
+
path: normalized,
|
|
70
|
+
status: 'created',
|
|
71
|
+
createdBy: agentName,
|
|
72
|
+
lastModifiedBy: agentName,
|
|
73
|
+
createdAt: now,
|
|
74
|
+
updatedAt: now
|
|
75
|
+
};
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Skip files with invalid paths
|
|
78
|
+
console.warn(`[file-tree] Skipping invalid path: ${filePath} - ${e.message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle modified files
|
|
83
|
+
for (const filePath of changes.modified) {
|
|
84
|
+
try {
|
|
85
|
+
const normalized = normalizePath(filePath, projectRoot);
|
|
86
|
+
const existing = runtime._rawMemory.fileTree[normalized] || {};
|
|
87
|
+
runtime._rawMemory.fileTree[normalized] = {
|
|
88
|
+
...existing,
|
|
89
|
+
path: normalized,
|
|
90
|
+
status: 'modified',
|
|
91
|
+
lastModifiedBy: agentName,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
createdAt: existing.createdAt || now,
|
|
94
|
+
createdBy: existing.createdBy || agentName
|
|
95
|
+
};
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.warn(`[file-tree] Skipping invalid path: ${filePath} - ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Handle renamed files (MVP: from/to pairs)
|
|
102
|
+
for (const { from, to } of changes.renamed) {
|
|
103
|
+
try {
|
|
104
|
+
const normalizedFrom = normalizePath(from, projectRoot);
|
|
105
|
+
const normalizedTo = normalizePath(to, projectRoot);
|
|
106
|
+
const existing = runtime._rawMemory.fileTree[normalizedFrom] || {};
|
|
107
|
+
delete runtime._rawMemory.fileTree[normalizedFrom];
|
|
108
|
+
runtime._rawMemory.fileTree[normalizedTo] = {
|
|
109
|
+
...existing,
|
|
110
|
+
path: normalizedTo,
|
|
111
|
+
status: 'renamed',
|
|
112
|
+
renamedFrom: normalizedFrom,
|
|
113
|
+
lastModifiedBy: agentName,
|
|
114
|
+
updatedAt: now
|
|
115
|
+
};
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.warn(`[file-tree] Skipping invalid rename: ${from} -> ${to} - ${e.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Handle deleted files
|
|
122
|
+
for (const filePath of changes.deleted) {
|
|
123
|
+
try {
|
|
124
|
+
const normalized = normalizePath(filePath, projectRoot);
|
|
125
|
+
if (runtime.workflowConfig.fileTrackingKeepDeleted) {
|
|
126
|
+
runtime._rawMemory.fileTree[normalized] = {
|
|
127
|
+
...runtime._rawMemory.fileTree[normalized],
|
|
128
|
+
status: 'deleted',
|
|
129
|
+
deletedBy: agentName,
|
|
130
|
+
deletedAt: now
|
|
131
|
+
};
|
|
132
|
+
} else {
|
|
133
|
+
delete runtime._rawMemory.fileTree[normalized];
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.warn(`[file-tree] Skipping invalid path: ${filePath} - ${e.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Trigger persistence
|
|
141
|
+
runtime.memory.fileTree = runtime._rawMemory.fileTree;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Merge _files annotations from agent result into fileTree.
|
|
146
|
+
* Only annotates files that were actually detected by change tracking.
|
|
147
|
+
* Preserves existing values unless explicitly overwritten.
|
|
148
|
+
*/
|
|
149
|
+
function mergeAnnotations(runtime, files) {
|
|
150
|
+
const projectRoot = runtime.workflowConfig.projectRoot;
|
|
151
|
+
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
if (!file.path) continue;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const normalizedPath = normalizePath(file.path, projectRoot);
|
|
157
|
+
const existing = runtime._rawMemory.fileTree?.[normalizedPath];
|
|
158
|
+
|
|
159
|
+
if (!existing) continue; // Only annotate files that were actually detected
|
|
160
|
+
|
|
161
|
+
// Preserve existing values unless _files explicitly provides new ones
|
|
162
|
+
if (file.caption !== undefined) existing.caption = file.caption;
|
|
163
|
+
if (file.exports !== undefined) existing.exports = file.exports;
|
|
164
|
+
if (file.metadata !== undefined) {
|
|
165
|
+
existing.metadata = { ...existing.metadata, ...file.metadata };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Trigger best-effort export extraction if requested
|
|
169
|
+
if (file.extractExports) {
|
|
170
|
+
const absolutePath = path.resolve(projectRoot, normalizedPath);
|
|
171
|
+
const exports = extractExportsFromFile(absolutePath);
|
|
172
|
+
if (exports) existing.exports = exports;
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.warn(`[file-tree] Skipping invalid annotation path: ${file.path} - ${e.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Trigger persistence
|
|
180
|
+
runtime.memory.fileTree = runtime._rawMemory.fileTree;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Manually track a file in the fileTree.
|
|
185
|
+
* Used for files created outside of agent execution.
|
|
186
|
+
*/
|
|
187
|
+
export function trackFile(runtime, relativePath, options = {}) {
|
|
188
|
+
const projectRoot = runtime.workflowConfig.projectRoot;
|
|
189
|
+
const normalized = normalizePath(relativePath, projectRoot);
|
|
190
|
+
const now = new Date().toISOString();
|
|
191
|
+
const agentName = options.agentName || 'manual';
|
|
192
|
+
|
|
193
|
+
// Initialize fileTree if needed
|
|
194
|
+
if (!runtime._rawMemory.fileTree) {
|
|
195
|
+
runtime._rawMemory.fileTree = {};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const existing = runtime._rawMemory.fileTree[normalized];
|
|
199
|
+
|
|
200
|
+
const entry = {
|
|
201
|
+
path: normalized,
|
|
202
|
+
status: existing?.status || 'created',
|
|
203
|
+
createdBy: existing?.createdBy || agentName,
|
|
204
|
+
lastModifiedBy: agentName,
|
|
205
|
+
createdAt: existing?.createdAt || now,
|
|
206
|
+
updatedAt: now
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Apply options
|
|
210
|
+
if (options.caption !== undefined) entry.caption = options.caption;
|
|
211
|
+
if (options.exports !== undefined) entry.exports = options.exports;
|
|
212
|
+
if (options.metadata !== undefined) {
|
|
213
|
+
entry.metadata = { ...existing?.metadata, ...options.metadata };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Extract exports if requested
|
|
217
|
+
if (options.extractExports) {
|
|
218
|
+
const absolutePath = path.resolve(projectRoot, normalized);
|
|
219
|
+
const exports = extractExportsFromFile(absolutePath);
|
|
220
|
+
if (exports) entry.exports = exports;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
runtime._rawMemory.fileTree[normalized] = entry;
|
|
224
|
+
|
|
225
|
+
// Trigger persistence
|
|
226
|
+
runtime.memory.fileTree = runtime._rawMemory.fileTree;
|
|
227
|
+
|
|
228
|
+
return entry;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get the current fileTree.
|
|
233
|
+
*/
|
|
234
|
+
export function getFileTree(runtime) {
|
|
235
|
+
return runtime._rawMemory.fileTree || {};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Remove a file from tracking.
|
|
240
|
+
*/
|
|
241
|
+
export function untrackFile(runtime, relativePath) {
|
|
242
|
+
const projectRoot = runtime.workflowConfig.projectRoot;
|
|
243
|
+
const normalized = normalizePath(relativePath, projectRoot);
|
|
244
|
+
|
|
245
|
+
if (runtime._rawMemory.fileTree?.[normalized]) {
|
|
246
|
+
delete runtime._rawMemory.fileTree[normalized];
|
|
247
|
+
runtime.memory.fileTree = runtime._rawMemory.fileTree;
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return false;
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -9,5 +9,16 @@ export const config = {
|
|
|
9
9
|
gemini: process.env.GEMINI_API_KEY,
|
|
10
10
|
anthropic: process.env.ANTHROPIC_API_KEY,
|
|
11
11
|
openai: process.env.OPENAI_API_KEY,
|
|
12
|
-
}
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
// File tracking (all optional - shown with defaults)
|
|
15
|
+
// projectRoot: process.env.PROJECT_ROOT, // Defaults to ../.. from workflow
|
|
16
|
+
// fileTracking: true, // Enable/disable file tracking
|
|
17
|
+
// fileTrackingIgnore: [ // Glob patterns to ignore
|
|
18
|
+
// 'node_modules/**',
|
|
19
|
+
// '.git/**',
|
|
20
|
+
// 'dist/**',
|
|
21
|
+
// 'workflows/**'
|
|
22
|
+
// ],
|
|
23
|
+
// fileTrackingKeepDeleted: false // Keep deleted files in tree
|
|
13
24
|
};
|
|
@@ -8,5 +8,16 @@ export const config = {
|
|
|
8
8
|
gemini: process.env.GEMINI_API_KEY,
|
|
9
9
|
anthropic: process.env.ANTHROPIC_API_KEY,
|
|
10
10
|
openai: process.env.OPENAI_API_KEY,
|
|
11
|
-
}
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
// File tracking (all optional - shown with defaults)
|
|
14
|
+
// projectRoot: process.env.PROJECT_ROOT, // Defaults to ../.. from workflow
|
|
15
|
+
// fileTracking: true, // Enable/disable file tracking
|
|
16
|
+
// fileTrackingIgnore: [ // Glob patterns to ignore
|
|
17
|
+
// 'node_modules/**',
|
|
18
|
+
// '.git/**',
|
|
19
|
+
// 'dist/**',
|
|
20
|
+
// 'workflows/**'
|
|
21
|
+
// ],
|
|
22
|
+
// fileTrackingKeepDeleted: false // Keep deleted files in tree
|
|
12
23
|
};
|