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.
@@ -29,9 +29,13 @@ export function generateSessionToken() {
29
29
  }
30
30
 
31
31
  /**
32
- * Make an HTTP/HTTPS request
32
+ * Make an HTTP/HTTPS request with timeout
33
+ * @param {string} url - Request URL
34
+ * @param {object} options - Request options
35
+ * @param {object|null} body - Request body
36
+ * @param {number} timeoutMs - Request timeout in milliseconds
33
37
  */
34
- function makeRequest(url, options, body = null) {
38
+ function makeRequest(url, options, body = null, timeoutMs = 60000) {
35
39
  return new Promise((resolve, reject) => {
36
40
  const parsedUrl = new URL(url);
37
41
  const client = parsedUrl.protocol === 'https:' ? https : http;
@@ -60,6 +64,12 @@ function makeRequest(url, options, body = null) {
60
64
  });
61
65
  });
62
66
 
67
+ // Timeout prevents hanging on sleep/wake cycles
68
+ req.setTimeout(timeoutMs, () => {
69
+ req.destroy();
70
+ reject(new Error('Request timeout'));
71
+ });
72
+
63
73
  req.on('error', reject);
64
74
 
65
75
  if (body) {
@@ -222,28 +232,47 @@ export class RemoteClient {
222
232
 
223
233
  /**
224
234
  * Poll for interaction responses
235
+ * Uses 35s timeout to stay under Vercel's 50s limit with buffer
225
236
  */
226
237
  async poll() {
238
+ let consecutiveErrors = 0;
239
+
227
240
  while (this.polling && this.connected) {
228
241
  try {
242
+ // Request 30s poll from server, with 35s client timeout
229
243
  const url = `${this.serverUrl}/api/ws/cli?token=${this.sessionToken}&timeout=30000`;
230
- const response = await makeRequest(url, { method: 'GET' });
244
+ const response = await makeRequest(url, { method: 'GET' }, null, 35000);
245
+
246
+ consecutiveErrors = 0; // Reset on success
231
247
 
232
248
  if (response.status === 200 && response.data) {
233
249
  const { type, slug, targetKey, response: interactionResponse } = response.data;
234
250
 
235
251
  if (type === 'interaction_response' && this.onInteractionResponse) {
252
+ // Confirm receipt BEFORE processing - removes from Redis pending queue
253
+ // This ensures we don't lose the interaction if processing fails
254
+ try {
255
+ const confirmUrl = `${this.serverUrl}/api/ws/cli?token=${this.sessionToken}`;
256
+ await makeRequest(confirmUrl, { method: 'DELETE' }, null, 10000);
257
+ } catch (err) {
258
+ // Non-fatal - interaction will be re-delivered on next poll
259
+ console.error(`${C.dim}Remote: Failed to confirm receipt: ${err.message}${C.reset}`);
260
+ }
261
+
236
262
  this.onInteractionResponse(slug, targetKey, interactionResponse);
237
263
  }
238
264
  }
239
265
 
240
- // If 204 (no content), just continue polling
241
- // Small delay between polls
242
- await new Promise(resolve => setTimeout(resolve, 500));
266
+ // If 204 (no content), just continue polling immediately
267
+ // Small delay only on success to prevent tight loop
268
+ await new Promise(resolve => setTimeout(resolve, 100));
243
269
 
244
270
  } catch (err) {
245
- // Connection error - wait and retry
246
- await new Promise(resolve => setTimeout(resolve, 5000));
271
+ consecutiveErrors++;
272
+
273
+ // Exponential backoff: 1s, 2s, 4s, max 10s
274
+ const backoff = Math.min(1000 * Math.pow(2, consecutiveErrors - 1), 10000);
275
+ await new Promise(resolve => setTimeout(resolve, backoff));
247
276
  }
248
277
  }
249
278
  }
@@ -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
- let result = await handler(context);
172
- let interactionDepth = 0;
173
-
174
- // Handle interaction response from JS agent (support multiple rounds)
175
- while (result && result._interaction) {
176
- const interactionResponse = await handleInteraction(runtime, result._interaction, name);
177
- const resumedContext = { ...context, userResponse: interactionResponse };
178
- await logAgentStart(runtime, name);
179
- result = await handler(resumedContext);
180
- interactionDepth += 1;
181
- if (interactionDepth > 5) {
182
- throw new Error(`Agent ${name} exceeded maximum interaction depth`);
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
- let response = null;
239
- let output = null;
240
- let interactionDepth = 0;
241
- let currentParams = params;
242
-
243
- while (true) {
244
- // Build context - only spread params, NOT memory (explicit context passing)
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
- const model = config.model || 'fast';
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
- const fullPrompt = buildPrompt(context, {
261
- model,
262
- prompt: interpolatedPrompt,
263
- includeContext: config.includeContext !== 'false',
264
- responseType: config.response
265
- });
287
+ await logAgentStart(runtime, name, fullPrompt);
266
288
 
267
- await logAgentStart(runtime, name, fullPrompt);
289
+ console.log(` Using model: ${model}`);
268
290
 
269
- console.log(` Using model: ${model}`);
291
+ response = await llm(context, {
292
+ model: model,
293
+ prompt: interpolatedPrompt,
294
+ includeContext: config.includeContext !== 'false',
295
+ responseType: config.response
296
+ });
270
297
 
271
- response = await llm(context, {
272
- model: model,
273
- prompt: interpolatedPrompt,
274
- includeContext: config.includeContext !== 'false',
275
- responseType: config.response
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
- // Parse output based on format
279
- output = response.text;
280
- if (config.format === 'json') {
281
- try {
282
- output = parseJSON(response.text);
283
- } catch {
284
- console.warn(` Warning: Failed to parse JSON output`);
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
- if (output && typeof output === 'object' && output._interaction) {
289
- const interactionResponse = await handleInteraction(runtime, output._interaction, name);
290
- currentParams = { ...params, userResponse: interactionResponse };
291
- interactionDepth += 1;
292
- if (interactionDepth > 5) {
293
- throw new Error(`Agent ${name} exceeded maximum interaction depth`);
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
- break;
299
- }
370
+ return userResponse;
371
+ }
300
372
 
301
- // Check for interaction request
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]: userResponse, _debug_prompt: response.fullPrompt };
375
+ return { [outputKey]: output, _debug_prompt: response.fullPrompt };
348
376
  }
349
377
 
350
- return userResponse;
351
- }
378
+ return output;
379
+ };
352
380
 
353
- // Return result object
354
- if (outputKey) {
355
- return { [outputKey]: output, _debug_prompt: response.fullPrompt };
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
  /**
@@ -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
+ }