agent-state-machine 2.1.9 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 long-lived state between runs.
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
- return { ok: true };
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/bin/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
5
  import readline from 'readline';
6
+ import { spawn } from 'child_process';
6
7
  import { pathToFileURL, fileURLToPath } from 'url';
7
8
  import { WorkflowRuntime } from '../lib/index.js';
8
9
  import { setup } from '../lib/setup.js';
@@ -11,6 +12,41 @@ import { readRemotePathFromConfig, writeRemotePathToConfig } from '../lib/config
11
12
 
12
13
  import { startLocalServer } from '../vercel-server/local-server.js';
13
14
 
15
+ /**
16
+ * Prevent system sleep on macOS using caffeinate
17
+ * Returns a function to stop caffeinate, or null if not available
18
+ */
19
+ function preventSleep() {
20
+ // Only works on macOS
21
+ if (process.platform !== 'darwin') {
22
+ return null;
23
+ }
24
+
25
+ try {
26
+ // -i: prevent idle sleep (system stays awake)
27
+ // -s: prevent sleep when on AC power
28
+ // Display can still sleep (screen goes black, requires password)
29
+ const caffeinate = spawn('caffeinate', ['-is'], {
30
+ stdio: 'ignore',
31
+ detached: false,
32
+ });
33
+
34
+ caffeinate.on('error', () => {
35
+ // caffeinate not available, ignore
36
+ });
37
+
38
+ return () => {
39
+ try {
40
+ caffeinate.kill();
41
+ } catch {
42
+ // Already dead, ignore
43
+ }
44
+ };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
14
50
  const __filename = fileURLToPath(import.meta.url);
15
51
  const __dirname = path.dirname(__filename);
16
52
 
@@ -258,9 +294,21 @@ async function runOrResume(
258
294
  await runtime.enableRemote(remoteUrl, { sessionToken, uiBaseUrl: useLocalServer });
259
295
  }
260
296
 
297
+ // Prevent system sleep while workflow runs (macOS only)
298
+ // Display can still sleep, but system stays awake for remote follow
299
+ const stopCaffeinate = preventSleep();
300
+ if (stopCaffeinate) {
301
+ console.log('☕ Preventing system sleep while workflow runs (display may still sleep)');
302
+ }
303
+
261
304
  try {
262
305
  await runtime.runWorkflow(workflowUrl);
263
306
  } finally {
307
+ // Allow sleep again
308
+ if (stopCaffeinate) {
309
+ stopCaffeinate();
310
+ }
311
+
264
312
  // Keep local server alive after run so the session remains accessible.
265
313
  if (!useLocalServer && remoteUrl) {
266
314
  await runtime.disableRemote();
@@ -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