keystone-cli 2.0.1 → 2.1.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 (56) hide show
  1. package/README.md +30 -4
  2. package/package.json +4 -1
  3. package/src/cli.ts +1 -0
  4. package/src/commands/event.ts +9 -0
  5. package/src/commands/run.ts +17 -0
  6. package/src/db/dynamic-state-manager.ts +12 -9
  7. package/src/db/memory-db.test.ts +19 -1
  8. package/src/db/memory-db.ts +101 -22
  9. package/src/db/workflow-db.ts +181 -9
  10. package/src/expression/evaluator.ts +4 -1
  11. package/src/parser/schema.ts +1 -0
  12. package/src/runner/__test__/llm-test-setup.ts +43 -11
  13. package/src/runner/durable-timers.test.ts +1 -1
  14. package/src/runner/executors/dynamic-executor.ts +125 -88
  15. package/src/runner/executors/engine-executor.ts +10 -39
  16. package/src/runner/executors/file-executor.ts +67 -0
  17. package/src/runner/executors/foreach-executor.ts +170 -17
  18. package/src/runner/executors/human-executor.ts +18 -0
  19. package/src/runner/executors/llm/stream-handler.ts +103 -0
  20. package/src/runner/executors/llm/tool-manager.ts +360 -0
  21. package/src/runner/executors/llm-executor.ts +288 -555
  22. package/src/runner/executors/memory-executor.ts +41 -34
  23. package/src/runner/executors/shell-executor.ts +96 -52
  24. package/src/runner/executors/subworkflow-executor.ts +16 -0
  25. package/src/runner/executors/types.ts +3 -1
  26. package/src/runner/executors/verification_fixes.test.ts +46 -0
  27. package/src/runner/join-scheduling.test.ts +2 -1
  28. package/src/runner/llm-adapter.integration.test.ts +10 -5
  29. package/src/runner/llm-adapter.ts +46 -17
  30. package/src/runner/llm-clarification.test.ts +4 -1
  31. package/src/runner/llm-executor.test.ts +21 -7
  32. package/src/runner/mcp-client.ts +36 -2
  33. package/src/runner/mcp-server.ts +65 -36
  34. package/src/runner/recovery-security.test.ts +5 -2
  35. package/src/runner/reflexion.test.ts +6 -3
  36. package/src/runner/services/context-builder.ts +13 -4
  37. package/src/runner/services/workflow-validator.ts +2 -1
  38. package/src/runner/standard-tools-ast.test.ts +4 -2
  39. package/src/runner/standard-tools-execution.test.ts +14 -1
  40. package/src/runner/standard-tools-integration.test.ts +6 -0
  41. package/src/runner/standard-tools.ts +13 -10
  42. package/src/runner/step-executor.ts +2 -2
  43. package/src/runner/tool-integration.test.ts +4 -1
  44. package/src/runner/workflow-runner.test.ts +23 -12
  45. package/src/runner/workflow-runner.ts +174 -85
  46. package/src/runner/workflow-state.ts +181 -111
  47. package/src/ui/dashboard.tsx +17 -3
  48. package/src/utils/config-loader.ts +4 -0
  49. package/src/utils/constants.ts +4 -0
  50. package/src/utils/context-injector.test.ts +27 -27
  51. package/src/utils/context-injector.ts +68 -26
  52. package/src/utils/process-sandbox.ts +138 -148
  53. package/src/utils/redactor.ts +39 -9
  54. package/src/utils/resource-loader.ts +24 -19
  55. package/src/utils/sandbox.ts +6 -0
  56. package/src/utils/stream-utils.ts +58 -0
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=flat&logo=bun&logoColor=white)](https://bun.sh)
8
8
  [![npm version](https://img.shields.io/npm/v/keystone-cli.svg?style=flat)](https://www.npmjs.com/package/keystone-cli)
9
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mhingston/keystone-cli)
10
11
 
11
12
  A local-first, declarative, agentic workflow orchestrator built on **Bun**.
12
13
 
@@ -427,6 +428,9 @@ expression:
427
428
  strict: true
428
429
  ```
429
430
 
431
+ > [!NOTE]
432
+ > When `strict: false` (default), evaluation errors in outputs will be reported as warnings and the value will be set to `null` to allow the workflow to potentially continue.
433
+
430
434
  ---
431
435
 
432
436
  ## <a id="step-types">🏗️ Step Types</a>
@@ -604,6 +608,9 @@ All steps support common features:
604
608
  - `outputRetries`: Max retries for output validation failures.
605
609
  - `repairStrategy`: Strategy for output repair (`reask`, `repair`, `hybrid`).
606
610
 
611
+ > [!TIP]
612
+ > **Performance Optimization**: For `foreach` steps with very large datasets, Keystone may automatically skip output aggregation to prevent memory issues. Use file-based storage or external databases if you need to process tens of thousands of items.
613
+
607
614
  Workflows also support a top-level `concurrency` field to limit how many steps can run in parallel across the entire workflow. This must resolve to a positive integer (number or expression).
608
615
 
609
616
  ### Engine Steps
@@ -678,6 +685,8 @@ Allow the LLM to switch to a specialist agent mid-step by defining `allowedHando
678
685
  allowedHandoffs: [handoff-specialist]
679
686
  ```
680
687
 
688
+ To prevent infinite loops, handoffs are limited to **20** occurrences per step by default.
689
+
681
690
  Agent prompts can use `${{ }}` expressions (evaluated against the workflow context) for dynamic system prompts.
682
691
 
683
692
  ```markdown
@@ -1223,18 +1232,26 @@ Input keys passed via `-i key=val` must be alphanumeric/underscore and cannot be
1223
1232
  ## <a id="security">🛡️ Security</a>
1224
1233
 
1225
1234
  ### Shell Execution
1226
- Keystone blocks shell commands that match common injection/destructive patterns (like `rm -rf /` or pipes to shells). To run them, set `allowInsecure: true` on the step. Prefer `${{ escape(...) }}` when interpolating user input.
1235
+ Keystone strictly enforces an allowlist of characters (`alphanumeric`, `whitespace`, and `_./:@,+=~-`) to prevent shell injection.
1236
+
1237
+ - **Directory Traversal**: Commands containing `..` in a path are blocked by default for security.
1238
+ - **Denylist**: Commands like `rm`, `mkfs`, or `alias` are blocked via a configurable denylist in `config.yaml`, even if `allowInsecure: true` is set.
1239
+ - **Windows Support**: Keystone uses `cmd.exe /d /s /c` on Windows and `sh -c` on other platforms for consistent behavior.
1227
1240
 
1241
+ To run complex commands or bypass allowlist checks, set `allowInsecure: true` on the step. Prefer `${{ escape(...) }}` when interpolating user input.
1242
+
1243
+ ```yaml
1228
1244
  - id: deploy
1229
1245
  type: shell
1230
1246
  run: ./deploy.sh ${{ inputs.env }}
1247
+ # Required if inputs.env might contain special characters or for complex scripts
1231
1248
  allowInsecure: true
1232
1249
  ```
1233
1250
 
1234
1251
  #### Troubleshooting Security Errors
1235
- If you see a `Security Error: Evaluated command contains shell metacharacters`, it means your command contains characters like `\n`, `|`, or `&` that were not explicitly escaped or are not in the safe whitelist.
1252
+ If you see a `Security Error: Evaluated command contains shell metacharacters`, it means your command contains characters like `\n`, `|`, `&`, or quotes that are not in the strict allowlist.
1236
1253
  - **Fix 1**: Use `${{ escape(steps.id.output) }}` for any dynamic values.
1237
- - **Fix 2**: Set `allowInsecure: true` if the command naturally uses special characters (like `echo "line1\nline2"`).
1254
+ - **Fix 2**: Set `allowInsecure: true` if the command naturally uses special characters.
1238
1255
 
1239
1256
  ### Expression Safety
1240
1257
  Expressions `${{ }}` are evaluated using a safe AST parser (`jsep`) which:
@@ -1288,8 +1305,14 @@ graph TD
1288
1305
  EX --> Wait[Wait Step]
1289
1306
  EX --> Join[Join Step]
1290
1307
  EX --> Blueprint[Blueprint Step]
1308
+ EX --> Dynamic[Dynamic Executor]
1309
+ EX --> Plan[Plan Executor]
1291
1310
 
1292
- LLM --> Adapter[LLM Adapter (AI SDK)]
1311
+ subgraph "LLM Subsystem"
1312
+ LLM --> ToolManager[Tool Manager]
1313
+ LLM --> StreamHandler[Stream Handler]
1314
+ ToolManager --> Adapter[LLM Adapter (AI SDK)]
1315
+ end
1293
1316
  Adapter --> Providers[OpenAI, Anthropic, Gemini, Copilot, etc.]
1294
1317
  LLM --> MCPClient[MCP Client]
1295
1318
  ```
@@ -1297,10 +1320,13 @@ graph TD
1297
1320
  ## <a id="project-structure">📂 Project Structure</a>
1298
1321
 
1299
1322
  - `src/cli.ts`: CLI entry point.
1323
+ - `src/commands/`: Command implementations (run, ui, config, etc.).
1300
1324
  - `src/db/`: SQLite persistence layer.
1301
1325
  - `src/runner/`: The core execution engine, handles parallelization and retries.
1302
1326
  - `src/parser/`: Zod-powered validation for workflows and agents.
1303
1327
  - `src/expression/`: `${{ }}` expression evaluator.
1328
+ - `src/providers/`: Custom AI provider implementations.
1329
+ - `src/scripts/`: Build and utility scripts.
1304
1330
  - `src/templates/`: Bundled workflow and agent templates.
1305
1331
  - `src/ui/`: Ink-powered TUI dashboard.
1306
1332
  - `src/utils/`: Shared utilities (auth, redaction, config loading).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-cli",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A local-first, declarative, agentic workflow orchestrator built on Bun",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,8 +42,10 @@
42
42
  "ink-spinner": "^5.0.0",
43
43
  "js-yaml": "^4.1.0",
44
44
  "jsep": "^1.4.0",
45
+ "minimatch": "^10.1.1",
45
46
  "react": "^19.0.0",
46
47
  "sqlite-vec": "0.1.6",
48
+ "yaml": "^2.8.2",
47
49
  "zod": "^3.25.76",
48
50
  "zod-to-json-schema": "^3.25.1"
49
51
  },
@@ -55,6 +57,7 @@
55
57
  "@types/bun": "^1.3.5",
56
58
  "@types/dagre": "^0.7.53",
57
59
  "@types/js-yaml": "^4.0.9",
60
+ "@types/minimatch": "^6.0.0",
58
61
  "@types/node": "^25.0.3",
59
62
  "react-devtools-core": "^7.0.1"
60
63
  },
package/src/cli.ts CHANGED
@@ -57,6 +57,7 @@ registerGraphCommand(program);
57
57
  registerDocCommand(program);
58
58
  registerSchemaCommand(program);
59
59
  registerEventCommand(program);
60
+
60
61
  registerRunCommand(program);
61
62
 
62
63
  // Helper function used by remaining commands (rerun)
@@ -25,5 +25,14 @@ export function registerEventCommand(program: Command): void {
25
25
  }
26
26
  await db.storeEvent(name, data);
27
27
  console.log(`✓ Event '${name}' triggered.`);
28
+
29
+ // Check for workflows waiting for this event
30
+ const suspendedRunIds = await db.getSuspendedStepsForEvent(name);
31
+ if (suspendedRunIds.length > 0) {
32
+ console.log(`\nFound ${suspendedRunIds.length} workflow(s) waiting for this event:`);
33
+ for (const runId of suspendedRunIds) {
34
+ console.log(` - Run ${runId}: Resume with \`keystone resume ${runId}\``);
35
+ }
36
+ }
28
37
  });
29
38
  }
@@ -24,6 +24,23 @@ export function registerRunCommand(program: Command): void {
24
24
  .option('--resume', 'Resume the last run of this workflow if it failed or was paused')
25
25
  .option('--explain', 'Show detailed error context with suggestions on failure')
26
26
  .action(async (workflowPathArg, options) => {
27
+ // Security Warning
28
+ if (!options.events) {
29
+ console.warn(
30
+ '\x1b[33m%s\x1b[0m',
31
+ '⚠️ SECURITY WARNING: This tool executes code from the current directory.'
32
+ );
33
+ console.warn(
34
+ '\x1b[33m%s\x1b[0m',
35
+ ' - Local provider scripts in ./providers/ are loaded and executed.'
36
+ );
37
+ console.warn(
38
+ '\x1b[33m%s\x1b[0m',
39
+ ' - Ensure you trust the code in this directory before running.'
40
+ );
41
+ console.warn('');
42
+ }
43
+
27
44
  const inputs = parseInputs(options.input);
28
45
  let resolvedPath: string | undefined;
29
46
 
@@ -175,17 +175,20 @@ export class DynamicStateManager {
175
175
  const db = this.getDatabase();
176
176
  const now = new Date().toISOString();
177
177
 
178
- // Load current state to get replanCount
179
- const current = db
180
- .prepare('SELECT replan_count FROM dynamic_workflow_state WHERE id = ?')
181
- .get(stateId) as { replan_count: number };
182
- const replanCount = current?.replan_count || 0;
183
-
184
- db.prepare(`
178
+ // Use atomic SQL update to increment replan_count and set new plan
179
+ const result = db
180
+ .prepare(`
185
181
  UPDATE dynamic_workflow_state
186
- SET generated_plan = ?, status = ?, updated_at = ?, replan_count = ?
182
+ SET generated_plan = ?, status = ?, updated_at = ?, replan_count = replan_count + 1
187
183
  WHERE id = ?
188
- `).run(JSON.stringify(plan), status, now, replanCount, stateId);
184
+ RETURNING replan_count
185
+ `)
186
+ .get(JSON.stringify(plan), status, now, stateId) as { replan_count: number } | undefined;
187
+
188
+ if (!result) {
189
+ throw new Error(`Failed to update dynamic state: ${stateId}`);
190
+ }
191
+ const replanCount = result.replan_count;
189
192
 
190
193
  // Delete previous execution records IF this is a re-plan (optional, but cleaner)
191
194
  if (replanCount > 0) {
@@ -1,8 +1,14 @@
1
1
  import { afterAll, describe, expect, test } from 'bun:test';
2
2
  import * as fs from 'node:fs';
3
3
  import { MemoryDb } from './memory-db';
4
+ import { setupSqlite } from './sqlite-setup';
4
5
 
5
- const TEST_DB = '.keystone/test-memory.db';
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ // Initialize SQLite with custom library for extensions
9
+ setupSqlite();
10
+
11
+ const TEST_DB = `.keystone/test-memory-${randomUUID()}.db`;
6
12
 
7
13
  describe('MemoryDb', () => {
8
14
  // Clean up previous runs
@@ -11,6 +17,7 @@ describe('MemoryDb', () => {
11
17
  }
12
18
 
13
19
  const db = new MemoryDb(TEST_DB);
20
+ console.log(`[MemoryDb Test] DB: ${TEST_DB}, Vector Ready: ${db.isVectorReady}`);
14
21
 
15
22
  afterAll(() => {
16
23
  db.close();
@@ -20,6 +27,7 @@ describe('MemoryDb', () => {
20
27
  });
21
28
 
22
29
  test('should initialize and store embedding', async () => {
30
+ if (!db.isVectorReady) return;
23
31
  const id = await db.store('hello world', Array(384).fill(0.1), { tag: 'test' });
24
32
  expect(id).toBeDefined();
25
33
  expect(typeof id).toBe('string');
@@ -32,6 +40,8 @@ describe('MemoryDb', () => {
32
40
 
33
41
  const db1536 = new MemoryDb(testDb1536, DIM_1536);
34
42
  try {
43
+ if (!db1536.isVectorReady) return;
44
+
35
45
  const id = await db1536.store('hi', Array(DIM_1536).fill(0.5));
36
46
  expect(id).toBeDefined();
37
47
 
@@ -56,6 +66,12 @@ describe('MemoryDb', () => {
56
66
 
57
67
  // Let's just test that we can use different dimensions on the same DB file.
58
68
  const db1 = new MemoryDb(testDbMismatch, 128);
69
+ if (!db1.isVectorReady) {
70
+ db1.close();
71
+ if (fs.existsSync(testDbMismatch)) fs.unlinkSync(testDbMismatch);
72
+ return;
73
+ }
74
+
59
75
  await db1.store('test128', Array(128).fill(0));
60
76
  db1.close();
61
77
 
@@ -71,6 +87,7 @@ describe('MemoryDb', () => {
71
87
  });
72
88
 
73
89
  test('should search and retrieve result', async () => {
90
+ if (!db.isVectorReady) return;
74
91
  // Store another item to search for
75
92
  await db.store('search target', Array(384).fill(0.9), { tag: 'target' });
76
93
 
@@ -81,6 +98,7 @@ describe('MemoryDb', () => {
81
98
  });
82
99
 
83
100
  test('should fail gracefully with invalid dimensions', async () => {
101
+ if (!db.isVectorReady) return;
84
102
  // sqlite-vec requires fixed dimensions (384 defined in schema)
85
103
  // bun:sqlite usually throws an error for constraint violations
86
104
  let error: unknown;
@@ -66,33 +66,83 @@ export class MemoryDb {
66
66
  // Cache connections by path to avoid reloading extensions
67
67
  private static connectionCache = new Map<string, { db: Database; refCount: number }>();
68
68
  private tableName: string;
69
+ private vectorReady = false;
70
+
71
+ get isVectorReady(): boolean {
72
+ return this.vectorReady;
73
+ }
74
+
75
+ /**
76
+ * Acquire a MemoryDb instance. This handles reference counting automatically.
77
+ */
78
+ static acquire(dbPath = '.keystone/memory.db', embeddingDimension = 384): MemoryDb {
79
+ const cached = MemoryDb.connectionCache.get(dbPath);
80
+ if (cached) {
81
+ cached.refCount++;
82
+ // We return a new instance but it shares the underlying DB connection
83
+ return new MemoryDb(dbPath, embeddingDimension, cached.db);
84
+ }
85
+
86
+ // Create new connection
87
+ const instance = new MemoryDb(dbPath, embeddingDimension);
88
+ MemoryDb.connectionCache.set(dbPath, { db: instance.db, refCount: 1 });
89
+ return instance;
90
+ }
69
91
 
70
92
  constructor(
71
93
  public readonly dbPath = '.keystone/memory.db',
72
- private readonly embeddingDimension = 384
94
+ private readonly embeddingDimension = 384,
95
+ existingDb?: Database
73
96
  ) {
74
97
  // Ensure SQLite is set up with custom library on macOS (idempotent)
75
98
  setupSqlite();
76
99
 
77
100
  this.tableName = `vec_memory_${embeddingDimension}`;
78
- const cached = MemoryDb.connectionCache.get(dbPath);
79
- if (cached) {
80
- cached.refCount++;
81
- this.db = cached.db;
101
+
102
+ if (existingDb) {
103
+ this.db = existingDb;
82
104
  } else {
83
- const dir = dirname(dbPath);
84
- if (!existsSync(dir)) {
85
- mkdirSync(dir, { recursive: true });
86
- }
87
- this.db = new Database(dbPath, { create: true });
105
+ // Check cache again in case direct constructor usage overlaps with cache
106
+ const cached = MemoryDb.connectionCache.get(dbPath);
107
+ if (cached) {
108
+ // This path shouldn't typically be hit if users use acquire(), but for safety:
109
+ cached.refCount++;
110
+ this.db = cached.db;
111
+ } else {
112
+ const dir = dirname(dbPath);
113
+ if (!existsSync(dir)) {
114
+ mkdirSync(dir, { recursive: true });
115
+ }
116
+ this.db = new Database(dbPath, { create: true });
117
+
118
+ // Load sqlite-vec extension
119
+ try {
120
+ const extensionPath = resolveSqliteVecPath();
121
+ this.db.loadExtension(extensionPath);
122
+ } catch (error) {
123
+ // In some environments (e.g. standard Bun builds), dynamic extension loading might be disabled.
124
+ // We log a warning and proceed without vector support.
125
+ new ConsoleLogger().warn(
126
+ `⚠️ Vector DB: Failed to load sqlite-vec extension. Vector search will be unavailable. Error: ${error instanceof Error ? error.message : String(error)}`
127
+ );
128
+ }
88
129
 
89
- // Load sqlite-vec extension
90
- const extensionPath = resolveSqliteVecPath();
91
- this.db.loadExtension(extensionPath);
130
+ this.initSchema();
92
131
 
93
- this.initSchema();
132
+ // Seed cache
133
+ MemoryDb.connectionCache.set(dbPath, { db: this.db, refCount: 1 });
134
+ }
135
+ }
136
+ }
94
137
 
95
- MemoryDb.connectionCache.set(dbPath, { db: this.db, refCount: 1 });
138
+ /**
139
+ * Manually increment reference count.
140
+ * Useful when passing an instance to another component that should also own it.
141
+ */
142
+ retain(): void {
143
+ const cached = MemoryDb.connectionCache.get(this.dbPath);
144
+ if (cached) {
145
+ cached.refCount++;
96
146
  }
97
147
  }
98
148
 
@@ -123,12 +173,29 @@ export class MemoryDb {
123
173
  }
124
174
  }
125
175
 
126
- this.db.run(`
127
- CREATE VIRTUAL TABLE IF NOT EXISTS ${this.tableName} USING vec0(
128
- id TEXT PRIMARY KEY,
129
- embedding FLOAT[${this.embeddingDimension}]
176
+ try {
177
+ this.db.run(`
178
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${this.tableName} USING vec0(
179
+ id TEXT PRIMARY KEY,
180
+ embedding FLOAT[${this.embeddingDimension}]
181
+ );
182
+ `);
183
+
184
+ // Verify table actually exists (in case run() didn't throw but failed)
185
+ const tableExists = this.db
186
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='${this.tableName}'`)
187
+ .get();
188
+ this.vectorReady = !!tableExists;
189
+
190
+ if (!this.vectorReady) {
191
+ new ConsoleLogger().warn(`⚠️ Vector DB: Vector table '${this.tableName}' was not created.`);
192
+ }
193
+ } catch (error) {
194
+ this.vectorReady = false;
195
+ new ConsoleLogger().warn(
196
+ `⚠️ Vector DB: Failed to create vector table. Vector search will be unavailable. Error: ${error}`
130
197
  );
131
- `);
198
+ }
132
199
 
133
200
  this.db.run(`
134
201
  CREATE TABLE IF NOT EXISTS memory_metadata (
@@ -219,6 +286,14 @@ export class MemoryDb {
219
286
  }));
220
287
  }
221
288
 
289
+ /**
290
+ * Release the connection. Decrements ref count and closes DB if 0.
291
+ * Alias for close() for backward compatibility.
292
+ */
293
+ release(): void {
294
+ this.close();
295
+ }
296
+
222
297
  close(): void {
223
298
  const cached = MemoryDb.connectionCache.get(this.dbPath);
224
299
  if (cached) {
@@ -228,8 +303,12 @@ export class MemoryDb {
228
303
  MemoryDb.connectionCache.delete(this.dbPath);
229
304
  }
230
305
  } else {
231
- // Fallback if not in cache for some reason
232
- this.db.close();
306
+ // Fallback if not in cache for some reason or already closed
307
+ try {
308
+ this.db.close();
309
+ } catch {
310
+ // ignore
311
+ }
233
312
  }
234
313
  }
235
314
  }