agent-state-machine 2.1.8 → 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.
Files changed (28) hide show
  1. package/README.md +55 -5
  2. package/bin/cli.js +1 -140
  3. package/lib/config-utils.js +259 -0
  4. package/lib/file-tree.js +366 -0
  5. package/lib/index.js +109 -2
  6. package/lib/llm.js +35 -5
  7. package/lib/runtime/agent.js +146 -118
  8. package/lib/runtime/model-resolution.js +128 -0
  9. package/lib/runtime/runtime.js +13 -2
  10. package/lib/runtime/track-changes.js +252 -0
  11. package/package.json +1 -1
  12. package/templates/project-builder/agents/assumptions-clarifier.md +0 -8
  13. package/templates/project-builder/agents/code-reviewer.md +0 -8
  14. package/templates/project-builder/agents/code-writer.md +0 -10
  15. package/templates/project-builder/agents/requirements-clarifier.md +0 -7
  16. package/templates/project-builder/agents/roadmap-generator.md +0 -10
  17. package/templates/project-builder/agents/scope-clarifier.md +0 -6
  18. package/templates/project-builder/agents/security-clarifier.md +0 -9
  19. package/templates/project-builder/agents/security-reviewer.md +0 -12
  20. package/templates/project-builder/agents/task-planner.md +0 -10
  21. package/templates/project-builder/agents/test-planner.md +0 -9
  22. package/templates/project-builder/config.js +13 -1
  23. package/templates/starter/config.js +12 -1
  24. package/vercel-server/public/remote/assets/{index-Cunx4VJE.js → index-BOKpYANC.js} +32 -27
  25. package/vercel-server/public/remote/assets/index-DHL_iHQW.css +1 -0
  26. package/vercel-server/public/remote/index.html +2 -2
  27. package/vercel-server/ui/src/components/ContentCard.jsx +6 -6
  28. package/vercel-server/public/remote/assets/index-D02x57pS.css +0 -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 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
@@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'url';
7
7
  import { WorkflowRuntime } from '../lib/index.js';
8
8
  import { setup } from '../lib/setup.js';
9
9
  import { generateSessionToken } from '../lib/remote/client.js';
10
+ import { readRemotePathFromConfig, writeRemotePathToConfig } from '../lib/config-utils.js';
10
11
 
11
12
  import { startLocalServer } from '../vercel-server/local-server.js';
12
13
 
@@ -119,146 +120,6 @@ function resolveWorkflowEntry(workflowDir) {
119
120
  return null;
120
121
  }
121
122
 
122
- function findConfigObjectRange(source) {
123
- const match = source.match(/export\s+const\s+config\s*=/);
124
- if (!match) return null;
125
- const startSearch = match.index + match[0].length;
126
- const braceStart = source.indexOf('{', startSearch);
127
- if (braceStart === -1) return null;
128
-
129
- let depth = 0;
130
- let inString = null;
131
- let inLineComment = false;
132
- let inBlockComment = false;
133
- let escape = false;
134
-
135
- for (let i = braceStart; i < source.length; i += 1) {
136
- const ch = source[i];
137
- const next = source[i + 1];
138
-
139
- if (inLineComment) {
140
- if (ch === '\n') inLineComment = false;
141
- continue;
142
- }
143
-
144
- if (inBlockComment) {
145
- if (ch === '*' && next === '/') {
146
- inBlockComment = false;
147
- i += 1;
148
- }
149
- continue;
150
- }
151
-
152
- if (inString) {
153
- if (escape) {
154
- escape = false;
155
- continue;
156
- }
157
- if (ch === '\\') {
158
- escape = true;
159
- continue;
160
- }
161
- if (ch === inString) {
162
- inString = null;
163
- }
164
- continue;
165
- }
166
-
167
- if (ch === '"' || ch === '\'' || ch === '`') {
168
- inString = ch;
169
- continue;
170
- }
171
-
172
- if (ch === '/' && next === '/') {
173
- inLineComment = true;
174
- i += 1;
175
- continue;
176
- }
177
-
178
- if (ch === '/' && next === '*') {
179
- inBlockComment = true;
180
- i += 1;
181
- continue;
182
- }
183
-
184
- if (ch === '{') {
185
- depth += 1;
186
- } else if (ch === '}') {
187
- depth -= 1;
188
- if (depth === 0) {
189
- return { start: braceStart, end: i };
190
- }
191
- }
192
- }
193
-
194
- return null;
195
- }
196
-
197
- function readRemotePathFromConfig(configFile) {
198
- const source = fs.readFileSync(configFile, 'utf-8');
199
- const range = findConfigObjectRange(source);
200
- if (!range) return null;
201
- const configSource = source.slice(range.start, range.end + 1);
202
- const match = configSource.match(/\bremotePath\s*:\s*(['"`])([^'"`]+)\1/);
203
- return match ? match[2] : null;
204
- }
205
-
206
- function writeRemotePathToConfig(configFile, remotePath) {
207
- const source = fs.readFileSync(configFile, 'utf-8');
208
- const range = findConfigObjectRange(source);
209
- const remoteLine = `remotePath: "${remotePath}"`;
210
-
211
- if (!range) {
212
- const hasConfigExport = /export\s+const\s+config\s*=/.test(source);
213
- if (hasConfigExport) {
214
- throw new Error('Config export is not an object literal; add remotePath manually.');
215
- }
216
- const trimmed = source.replace(/\s*$/, '');
217
- const appended = `${trimmed}\n\nexport const config = {\n ${remoteLine}\n};\n`;
218
- fs.writeFileSync(configFile, appended);
219
- return;
220
- }
221
-
222
- const configSource = source.slice(range.start, range.end + 1);
223
- const remoteRegex = /\bremotePath\s*:\s*(['"`])([^'"`]*?)\1/;
224
- let updatedConfigSource;
225
-
226
- if (remoteRegex.test(configSource)) {
227
- updatedConfigSource = configSource.replace(remoteRegex, remoteLine);
228
- } else {
229
- const inner = configSource.slice(1, -1);
230
- const indentMatch = inner.match(/\n([ \t]+)\S/);
231
- const indent = indentMatch ? indentMatch[1] : ' ';
232
- const trimmedInner = inner.replace(/\s*$/, '');
233
- const hasContent = trimmedInner.trim().length > 0;
234
- let updatedInner = trimmedInner;
235
-
236
- if (hasContent) {
237
- for (let i = updatedInner.length - 1; i >= 0; i -= 1) {
238
- const ch = updatedInner[i];
239
- if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
240
- if (ch !== ',') {
241
- updatedInner += ',';
242
- }
243
- break;
244
- }
245
- }
246
-
247
- const needsNewline = updatedInner && !updatedInner.endsWith('\n');
248
- const insert = `${indent}${remoteLine},\n`;
249
- const newInner = hasContent
250
- ? `${updatedInner}${needsNewline ? '\n' : ''}${insert}`
251
- : `\n${insert}`;
252
- updatedConfigSource = `{${newInner}}`;
253
- }
254
-
255
- const updatedSource =
256
- source.slice(0, range.start) +
257
- updatedConfigSource +
258
- source.slice(range.end + 1);
259
- fs.writeFileSync(configFile, updatedSource);
260
- }
261
-
262
123
  function ensureRemotePath(configFile, { forceNew = false } = {}) {
263
124
  const existing = readRemotePathFromConfig(configFile);
264
125
  if (existing && !forceNew) return existing;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * File: /lib/config-utils.js
3
+ */
4
+
5
+ import fs from 'fs';
6
+
7
+ export function findConfigObjectRange(source) {
8
+ const match = source.match(/export\s+const\s+config\s*=/);
9
+ if (!match) return null;
10
+ const startSearch = match.index + match[0].length;
11
+ const braceStart = source.indexOf('{', startSearch);
12
+ if (braceStart === -1) return null;
13
+
14
+ return findObjectLiteralRange(source, braceStart);
15
+ }
16
+
17
+ function findObjectLiteralRange(source, braceStart) {
18
+ let depth = 0;
19
+ let inString = null;
20
+ let inLineComment = false;
21
+ let inBlockComment = false;
22
+ let escape = false;
23
+
24
+ for (let i = braceStart; i < source.length; i += 1) {
25
+ const ch = source[i];
26
+ const next = source[i + 1];
27
+
28
+ if (inLineComment) {
29
+ if (ch === '\n') inLineComment = false;
30
+ continue;
31
+ }
32
+
33
+ if (inBlockComment) {
34
+ if (ch === '*' && next === '/') {
35
+ inBlockComment = false;
36
+ i += 1;
37
+ }
38
+ continue;
39
+ }
40
+
41
+ if (inString) {
42
+ if (escape) {
43
+ escape = false;
44
+ continue;
45
+ }
46
+ if (ch === '\\') {
47
+ escape = true;
48
+ continue;
49
+ }
50
+ if (ch === inString) {
51
+ inString = null;
52
+ }
53
+ continue;
54
+ }
55
+
56
+ if (ch === '"' || ch === '\'' || ch === '`') {
57
+ inString = ch;
58
+ continue;
59
+ }
60
+
61
+ if (ch === '/' && next === '/') {
62
+ inLineComment = true;
63
+ i += 1;
64
+ continue;
65
+ }
66
+
67
+ if (ch === '/' && next === '*') {
68
+ inBlockComment = true;
69
+ i += 1;
70
+ continue;
71
+ }
72
+
73
+ if (ch === '{') {
74
+ depth += 1;
75
+ } else if (ch === '}') {
76
+ depth -= 1;
77
+ if (depth === 0) {
78
+ return { start: braceStart, end: i };
79
+ }
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function detectIndent(inner) {
87
+ const indentMatch = inner.match(/\n([ \t]+)\S/);
88
+ return indentMatch ? indentMatch[1] : ' ';
89
+ }
90
+
91
+ function escapeRegExp(value) {
92
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ }
94
+
95
+ function isValidIdentifier(value) {
96
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
97
+ }
98
+
99
+ function formatObjectKey(value) {
100
+ if (isValidIdentifier(value)) return value;
101
+ return JSON.stringify(value);
102
+ }
103
+
104
+ function insertPropertyIntoObjectSource(objectSource, propertySource, indent) {
105
+ const inner = objectSource.slice(1, -1);
106
+ const trimmedInner = inner.replace(/\s*$/, '');
107
+ const hasContent = trimmedInner.trim().length > 0;
108
+ let updatedInner = trimmedInner;
109
+
110
+ if (hasContent) {
111
+ for (let i = updatedInner.length - 1; i >= 0; i -= 1) {
112
+ const ch = updatedInner[i];
113
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
114
+ if (ch !== ',') {
115
+ updatedInner += ',';
116
+ }
117
+ break;
118
+ }
119
+ }
120
+
121
+ const needsNewline = updatedInner && !updatedInner.endsWith('\n');
122
+ const insert = `${indent}${propertySource},\n`;
123
+ const newInner = hasContent
124
+ ? `${updatedInner}${needsNewline ? '\n' : ''}${insert}`
125
+ : `\n${insert}`;
126
+
127
+ return `{${newInner}}`;
128
+ }
129
+
130
+ export function readRemotePathFromConfig(configFile) {
131
+ const source = fs.readFileSync(configFile, 'utf-8');
132
+ const range = findConfigObjectRange(source);
133
+ if (!range) return null;
134
+ const configSource = source.slice(range.start, range.end + 1);
135
+ const match = configSource.match(/\bremotePath\s*:\s*(['"`])([^'"`]+)\1/);
136
+ return match ? match[2] : null;
137
+ }
138
+
139
+ export function writeRemotePathToConfig(configFile, remotePath) {
140
+ const source = fs.readFileSync(configFile, 'utf-8');
141
+ const range = findConfigObjectRange(source);
142
+ const remoteLine = `remotePath: ${JSON.stringify(remotePath)}`;
143
+
144
+ if (!range) {
145
+ const hasConfigExport = /export\s+const\s+config\s*=/.test(source);
146
+ if (hasConfigExport) {
147
+ throw new Error('Config export is not an object literal; add remotePath manually.');
148
+ }
149
+ const trimmed = source.replace(/\s*$/, '');
150
+ const appended = `${trimmed}\n\nexport const config = {\n ${remoteLine}\n};\n`;
151
+ fs.writeFileSync(configFile, appended);
152
+ return;
153
+ }
154
+
155
+ const configSource = source.slice(range.start, range.end + 1);
156
+ const remoteRegex = /\bremotePath\s*:\s*(['"`])([^'"`]*?)\1/;
157
+ let updatedConfigSource;
158
+
159
+ if (remoteRegex.test(configSource)) {
160
+ updatedConfigSource = configSource.replace(remoteRegex, remoteLine);
161
+ } else {
162
+ const inner = configSource.slice(1, -1);
163
+ const indent = detectIndent(inner);
164
+ updatedConfigSource = insertPropertyIntoObjectSource(configSource, remoteLine, indent);
165
+ }
166
+
167
+ const updatedSource =
168
+ source.slice(0, range.start) +
169
+ updatedConfigSource +
170
+ source.slice(range.end + 1);
171
+ fs.writeFileSync(configFile, updatedSource);
172
+ }
173
+
174
+ function findModelsObjectRange(configSource) {
175
+ const regex = /\bmodels\s*:\s*{/g;
176
+ const match = regex.exec(configSource);
177
+ if (!match) return null;
178
+ const braceStart = configSource.indexOf('{', match.index + match[0].length - 1);
179
+ if (braceStart === -1) return null;
180
+ return findObjectLiteralRange(configSource, braceStart);
181
+ }
182
+
183
+ function buildModelKeyRegex(modelKey) {
184
+ const escaped = escapeRegExp(modelKey);
185
+ const quotedKey = `['"\\\`]${escaped}['"\\\`]`;
186
+ const unquotedKey = isValidIdentifier(modelKey) ? escaped : null;
187
+ const keyPattern = unquotedKey ? `(?:${unquotedKey}|${quotedKey})` : quotedKey;
188
+ return new RegExp(`(^|[,{\\s])(${keyPattern})\\s*:\\s*(['"\\\`])([^'"\\\\\`]*?)\\3`, 'm');
189
+ }
190
+
191
+ export function readModelFromConfig(configFile, modelKey) {
192
+ const source = fs.readFileSync(configFile, 'utf-8');
193
+ const range = findConfigObjectRange(source);
194
+ if (!range) return null;
195
+ const configSource = source.slice(range.start, range.end + 1);
196
+ const modelsRange = findModelsObjectRange(configSource);
197
+ if (!modelsRange) return null;
198
+ const modelsSource = configSource.slice(modelsRange.start, modelsRange.end + 1);
199
+ const keyRegex = buildModelKeyRegex(modelKey);
200
+ const match = modelsSource.match(keyRegex);
201
+ return match ? match[4] : null;
202
+ }
203
+
204
+ export function writeModelToConfig(configFile, modelKey, modelValue) {
205
+ const source = fs.readFileSync(configFile, 'utf-8');
206
+ const range = findConfigObjectRange(source);
207
+ const formattedKey = formatObjectKey(modelKey);
208
+ const serializedValue = JSON.stringify(modelValue);
209
+
210
+ if (!range) {
211
+ const hasConfigExport = /export\s+const\s+config\s*=/.test(source);
212
+ if (hasConfigExport) {
213
+ throw new Error('Config export is not an object literal; add models mapping manually.');
214
+ }
215
+ const trimmed = source.replace(/\s*$/, '');
216
+ const appended = `${trimmed}\n\nexport const config = {\n models: {\n ${formattedKey}: ${serializedValue}\n }\n};\n`;
217
+ fs.writeFileSync(configFile, appended);
218
+ return;
219
+ }
220
+
221
+ const configSource = source.slice(range.start, range.end + 1);
222
+ const modelsRange = findModelsObjectRange(configSource);
223
+ let updatedConfigSource = configSource;
224
+
225
+ if (!modelsRange) {
226
+ const inner = configSource.slice(1, -1);
227
+ const indent = detectIndent(inner);
228
+ const nestedIndent = `${indent} `;
229
+ const modelsBlock = `models: {\n${nestedIndent}${formattedKey}: ${serializedValue},\n${indent}}`;
230
+ updatedConfigSource = insertPropertyIntoObjectSource(configSource, modelsBlock, indent);
231
+ } else {
232
+ const modelsSource = configSource.slice(modelsRange.start, modelsRange.end + 1);
233
+ const keyRegex = buildModelKeyRegex(modelKey);
234
+ let updatedModelsSource;
235
+
236
+ if (keyRegex.test(modelsSource)) {
237
+ updatedModelsSource = modelsSource.replace(
238
+ keyRegex,
239
+ (_match, prefix, keyToken) => `${prefix}${keyToken}: ${serializedValue}`
240
+ );
241
+ } else {
242
+ const inner = modelsSource.slice(1, -1);
243
+ const indent = detectIndent(inner);
244
+ const modelLine = `${formattedKey}: ${serializedValue}`;
245
+ updatedModelsSource = insertPropertyIntoObjectSource(modelsSource, modelLine, indent);
246
+ }
247
+
248
+ updatedConfigSource =
249
+ configSource.slice(0, modelsRange.start) +
250
+ updatedModelsSource +
251
+ configSource.slice(modelsRange.end + 1);
252
+ }
253
+
254
+ const updatedSource =
255
+ source.slice(0, range.start) +
256
+ updatedConfigSource +
257
+ source.slice(range.end + 1);
258
+ fs.writeFileSync(configFile, updatedSource);
259
+ }