@strayl/agent 0.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 (107) hide show
  1. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_10_1772719084959.json +1 -0
  2. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_1_1772715451195.json +1 -0
  3. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_2_1772715452775.json +1 -0
  4. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_3_1772715454851.json +1 -0
  5. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_4_1772715457128.json +1 -0
  6. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_5_1772715464496.json +1 -0
  7. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_6_1772715466914.json +1 -0
  8. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_7_1772717269500.json +1 -0
  9. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_8_1772717274176.json +1 -0
  10. package/.strayl/checkpoints/03bdaa7e-3d71-44ee-9ff1-e23947c145b4/cp_9_1772717277769.json +1 -0
  11. package/.strayl/checkpoints/3f487378-fdba-413d-8c85-71e219cf4029/cp_1_1772710881634.json +1 -0
  12. package/.strayl/checkpoints/3f487378-fdba-413d-8c85-71e219cf4029/cp_2_1772710883272.json +1 -0
  13. package/.strayl/checkpoints/4c4d6f62-9e95-4790-9642-dced72212054/cp_1_1772722301047.json +1 -0
  14. package/.strayl/checkpoints/60ad3c6e-7e08-4841-9244-db9ef1351f94/cp_1_1772723527023.json +1 -0
  15. package/.strayl/checkpoints/6144a383-958f-478e-9f08-b3e435671beb/cp_1_1772723741593.json +1 -0
  16. package/.strayl/checkpoints/64d547ea-9114-4eac-8066-8c1d1cfafce3/cp_1_1772722436077.json +1 -0
  17. package/.strayl/checkpoints/88adc272-3c4f-410d-971a-dccd3a5a7c55/cp_1_1772723725211.json +1 -0
  18. package/.strayl/checkpoints/995ba1b6-6a4c-41c5-8a24-59d8475e31d4/cp_1_1772715363842.json +1 -0
  19. package/.strayl/checkpoints/aa9e0d03-bebe-49fb-b20d-7b5e5a3f299c/cp_1_1772715381414.json +1 -0
  20. package/.strayl/checkpoints/b0c6c2a4-a34d-451a-8937-c4235b306b2e/cp_1_1772711029179.json +1 -0
  21. package/.strayl/checkpoints/b4123afa-4e61-4a6e-b629-ef82273fb9af/cp_1_1772721833257.json +1 -0
  22. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_10_1772715577535.tmp +0 -0
  23. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_1_1772715557995.json +1 -0
  24. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_2_1772715560232.json +1 -0
  25. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_3_1772715562207.json +1 -0
  26. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_4_1772715564077.json +1 -0
  27. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_5_1772715566552.json +1 -0
  28. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_6_1772715569518.json +1 -0
  29. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_7_1772715571538.json +1 -0
  30. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_8_1772715573416.json +1 -0
  31. package/.strayl/checkpoints/b7fcfea4-6d41-4b31-98bd-e442f5b48f98/cp_9_1772715575737.json +1 -0
  32. package/.strayl/checkpoints/d42651a8-3bb2-4456-8775-66088ed31aca/cp_2_1772711043652.json +1 -0
  33. package/.strayl/checkpoints/ea81c9e2-47f7-4446-865d-40cb369d7944/cp_1_1772722131331.json +1 -0
  34. package/.strayl/checkpoints/eaaa83aa-18e4-4e74-be3a-1b1113f58dbe/cp_1_1772715398883.json +1 -0
  35. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_10_1772721968386.json +1 -0
  36. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_1_1772721929655.json +1 -0
  37. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_2_1772721935883.json +1 -0
  38. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_3_1772721940170.json +1 -0
  39. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_4_1772721945158.json +1 -0
  40. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_5_1772721949901.json +1 -0
  41. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_6_1772721954449.json +1 -0
  42. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_7_1772721957297.json +1 -0
  43. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_8_1772721961731.json +1 -0
  44. package/.strayl/checkpoints/ebb33f2b-ec62-4dba-9d84-7ccc4a54a255/cp_9_1772721964921.json +1 -0
  45. package/.strayl/checkpoints/f94498fa-cf5b-48de-a1f9-4c4754695dce/cp_1_1772715412172.json +1 -0
  46. package/.strayl/checkpoints/f94498fa-cf5b-48de-a1f9-4c4754695dce/cp_2_1772715414557.json +1 -0
  47. package/.strayl/checkpoints/f94498fa-cf5b-48de-a1f9-4c4754695dce/cp_3_1772715416369.json +1 -0
  48. package/.strayl/checkpoints/f94498fa-cf5b-48de-a1f9-4c4754695dce/cp_4_1772715421585.json +1 -0
  49. package/.strayl/logs/bg_1772680728576.log +2 -0
  50. package/INTEGRATION-PLAN.md +385 -0
  51. package/build.ts +14 -0
  52. package/dist/agent.js +13876 -0
  53. package/hello.txt +4 -0
  54. package/package.json +23 -0
  55. package/run.sh +84 -0
  56. package/src/agent.ts +440 -0
  57. package/src/checkpoints/manager.ts +112 -0
  58. package/src/context/manager.ts +185 -0
  59. package/src/context/summarizer.ts +104 -0
  60. package/src/context/trim.ts +55 -0
  61. package/src/emitter.ts +14 -0
  62. package/src/hitl/manager.ts +77 -0
  63. package/src/hitl/transport.ts +13 -0
  64. package/src/index.ts +116 -0
  65. package/src/llm/client.ts +276 -0
  66. package/src/llm/gemini-native.ts +307 -0
  67. package/src/llm/models.ts +64 -0
  68. package/src/middleware/compose.ts +24 -0
  69. package/src/middleware/credential-scrubbing.ts +31 -0
  70. package/src/middleware/forbidden-packages.ts +107 -0
  71. package/src/middleware/plan-mode.ts +143 -0
  72. package/src/middleware/prompt-caching.ts +21 -0
  73. package/src/middleware/tool-compression.ts +25 -0
  74. package/src/middleware/tool-filter.ts +13 -0
  75. package/src/prompts/implementation-mode.md +16 -0
  76. package/src/prompts/plan-mode.md +51 -0
  77. package/src/prompts/system.ts +173 -0
  78. package/src/skills/loader.ts +53 -0
  79. package/src/stdin-listener.ts +62 -0
  80. package/src/subagents/definitions.ts +72 -0
  81. package/src/subagents/manager.ts +140 -0
  82. package/src/todos/manager.ts +61 -0
  83. package/src/tools/builtin/delete.ts +29 -0
  84. package/src/tools/builtin/edit.ts +74 -0
  85. package/src/tools/builtin/exec.ts +216 -0
  86. package/src/tools/builtin/glob.ts +104 -0
  87. package/src/tools/builtin/grep.ts +115 -0
  88. package/src/tools/builtin/ls.ts +54 -0
  89. package/src/tools/builtin/move.ts +31 -0
  90. package/src/tools/builtin/read.ts +69 -0
  91. package/src/tools/builtin/write.ts +42 -0
  92. package/src/tools/executor.ts +51 -0
  93. package/src/tools/external/database.ts +285 -0
  94. package/src/tools/external/enter-plan-mode.ts +34 -0
  95. package/src/tools/external/generate-image.ts +110 -0
  96. package/src/tools/external/hitl-tools.ts +118 -0
  97. package/src/tools/external/preview.ts +28 -0
  98. package/src/tools/external/proxy-fetch.ts +51 -0
  99. package/src/tools/external/task.ts +38 -0
  100. package/src/tools/external/wait.ts +20 -0
  101. package/src/tools/external/web-fetch.ts +57 -0
  102. package/src/tools/external/web-search.ts +61 -0
  103. package/src/tools/registry.ts +36 -0
  104. package/src/tools/zod-to-json-schema.ts +86 -0
  105. package/src/types.ts +151 -0
  106. package/test-hitl.sh +90 -0
  107. package/tsconfig.json +15 -0
package/hello.txt ADDED
@@ -0,0 +1,4 @@
1
+ Hello World!
2
+ This is a test file with multiple lines.
3
+ Line 2.
4
+ Line 3.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@strayl/agent",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "dist/index.js",
9
+ "scripts": {
10
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/agent.js --external:fsevents",
11
+ "dev": "tsx watch src/index.ts"
12
+ },
13
+ "dependencies": {
14
+ "openai": "^5.8.0",
15
+ "zod": "^3.25.67"
16
+ },
17
+ "devDependencies": {
18
+ "esbuild": "^0.25.5",
19
+ "tsx": "^4.19.4",
20
+ "typescript": "^5.8.3",
21
+ "@types/node": "^22.15.31"
22
+ }
23
+ }
package/run.sh ADDED
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+
3
+ # Strayl Agent Runner
4
+ # Usage: ./run.sh "your prompt here"
5
+ # Options:
6
+ # ./run.sh "prompt" - raw JSON output
7
+ # ./run.sh "prompt" --pretty - human-readable output
8
+ # ./run.sh "prompt" --model deep - use specific model (auto|light|pro|deep)
9
+ # ./run.sh "prompt" --work-dir /path - set working directory
10
+
11
+ # Direct mode: SDK calls providers directly with API keys (for local dev only)
12
+ # In production, STRAYL_LLM_DIRECT is NOT set — all calls go through api.strayl.dev
13
+ export STRAYL_LLM_DIRECT="1"
14
+ export GOOGLE_GENERATIVE_AI_API_KEY="AIzaSyBLeK4sA6RjoAk1f1011nvtaS0OxFO6nas"
15
+
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+ PROMPT=""
18
+ MODEL="auto"
19
+ PRETTY=false
20
+ EXTRA_ARGS=()
21
+
22
+ while [[ $# -gt 0 ]]; do
23
+ case $1 in
24
+ --pretty) PRETTY=true; shift ;;
25
+ --model) MODEL="$2"; shift 2 ;;
26
+ --work-dir|--blocked-tools|--max-iterations|--extra-prompt-file|--skills-dir)
27
+ EXTRA_ARGS+=("$1" "$2"); shift 2 ;;
28
+ *)
29
+ if [[ -z "$PROMPT" ]]; then
30
+ PROMPT="$1"
31
+ fi
32
+ shift ;;
33
+ esac
34
+ done
35
+
36
+ if [[ -z "$PROMPT" ]]; then
37
+ echo "Usage: ./run.sh \"your prompt\" [--pretty] [--model auto|light|pro|deep]"
38
+ exit 1
39
+ fi
40
+
41
+ if $PRETTY; then
42
+ node "$SCRIPT_DIR/dist/agent.js" --model "$MODEL" --prompt "$PROMPT" "${EXTRA_ARGS[@]}" 2>&1 | while IFS= read -r line; do
43
+ type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null)
44
+ case "$type" in
45
+ session-start)
46
+ model=$(echo "$line" | jq -r '.model')
47
+ echo -e "\033[36m▶ Session started (model: $model)\033[0m"
48
+ ;;
49
+ text-delta)
50
+ echo -n "$(echo "$line" | jq -r '.text')"
51
+ ;;
52
+ reasoning-delta)
53
+ echo -ne "\033[2m$(echo "$line" | jq -r '.text')\033[0m"
54
+ ;;
55
+ tool-call-start)
56
+ name=$(echo "$line" | jq -r '.name')
57
+ args=$(echo "$line" | jq -c '.args')
58
+ echo -e "\n\033[33m🔧 $name\033[0m $args"
59
+ ;;
60
+ tool-result)
61
+ echo -e "\033[32m✅ done\033[0m"
62
+ ;;
63
+ exec-log)
64
+ echo -ne "\033[2m$(echo "$line" | jq -r '.data')\033[0m"
65
+ ;;
66
+ usage-update)
67
+ left=$(echo "$line" | jq -r '.context_left_percent')
68
+ cost=$(echo "$line" | jq -r '.cost // 0')
69
+ ;;
70
+ session-end)
71
+ reason=$(echo "$line" | jq -r '.exit_reason')
72
+ input=$(echo "$line" | jq -r '.usage.input_tokens')
73
+ output=$(echo "$line" | jq -r '.usage.output_tokens')
74
+ echo -e "\n\033[36m■ Done ($reason) | tokens: ${input}in/${output}out\033[0m"
75
+ ;;
76
+ error)
77
+ msg=$(echo "$line" | jq -r '.message')
78
+ echo -e "\033[31m✖ $msg\033[0m"
79
+ ;;
80
+ esac
81
+ done
82
+ else
83
+ node "$SCRIPT_DIR/dist/agent.js" --model "$MODEL" --prompt "$PROMPT" "${EXTRA_ARGS[@]}" 2>&1
84
+ fi
package/src/agent.ts ADDED
@@ -0,0 +1,440 @@
1
+ import type { AgentConfig, ToolCall, ToolContext, Middleware, Message, AgentMode, ActivityType } from "./types.js";
2
+ import { Emitter } from "./emitter.js";
3
+ import { LLMClient } from "./llm/client.js";
4
+ import { getModelLimits, resolveModel } from "./llm/models.js";
5
+ import { ContextManager } from "./context/manager.js";
6
+ import { ToolRegistry } from "./tools/registry.js";
7
+ import { executeTool } from "./tools/executor.js";
8
+ import { HITLManager } from "./hitl/manager.js";
9
+ import { SubAgentManager } from "./subagents/manager.js";
10
+ import { TodoManager } from "./todos/manager.js";
11
+ import { buildSystemPrompt } from "./prompts/system.js";
12
+ import { loadSkills } from "./skills/loader.js";
13
+ import { StdinListener } from "./stdin-listener.js";
14
+ import { CheckpointManager } from "./checkpoints/manager.js";
15
+
16
+ // Builtin tools
17
+ import { execTool, getLogsTool } from "./tools/builtin/exec.js";
18
+ import { readFileTool } from "./tools/builtin/read.js";
19
+ import { writeFileTool } from "./tools/builtin/write.js";
20
+ import { editFileTool } from "./tools/builtin/edit.js";
21
+ import { lsTool } from "./tools/builtin/ls.js";
22
+ import { grepTool } from "./tools/builtin/grep.js";
23
+ import { globTool } from "./tools/builtin/glob.js";
24
+ import { deleteFileTool } from "./tools/builtin/delete.js";
25
+ import { moveFileTool } from "./tools/builtin/move.js";
26
+
27
+ // External tools
28
+ import { webSearchTool } from "./tools/external/web-search.js";
29
+ import { webFetchTool } from "./tools/external/web-fetch.js";
30
+ import { previewTool } from "./tools/external/preview.js";
31
+ import { waitTool } from "./tools/external/wait.js";
32
+ import { generateImageTool } from "./tools/external/generate-image.js";
33
+ import { createTaskTool } from "./tools/external/task.js";
34
+ import { enterPlanModeTool } from "./tools/external/enter-plan-mode.js";
35
+ import { askUserTool, writePlanTool, requestEnvVarTool } from "./tools/external/hitl-tools.js";
36
+ import { createDatabaseTool, listDatabasesTool, runDatabaseQueryTool, prepareDatabaseMigrationTool, completeDatabaseMigrationTool } from "./tools/external/database.js";
37
+
38
+ // Middleware
39
+ import { forbiddenPackagesMiddleware } from "./middleware/forbidden-packages.js";
40
+ import { toolCompressionMiddleware } from "./middleware/tool-compression.js";
41
+ import { createToolFilterMiddleware } from "./middleware/tool-filter.js";
42
+ import { createPromptCachingMiddleware } from "./middleware/prompt-caching.js";
43
+ import { credentialScrubbingMiddleware } from "./middleware/credential-scrubbing.js";
44
+ import { createPlanModeMiddleware, resetContextForImplementation } from "./middleware/plan-mode.js";
45
+
46
+ export async function runAgent(config: AgentConfig): Promise<void> {
47
+ const emitter = new Emitter();
48
+ const stdin = new StdinListener();
49
+ stdin.start();
50
+ const modelName = resolveModel(config.model);
51
+ const limits = getModelLimits(modelName);
52
+
53
+ // Mode state — mutable, changes on enterPlanMode tool or confirm-plan stdin command
54
+ let currentMode: AgentMode = config.mode ?? "normal";
55
+
56
+ const client = new LLMClient({
57
+ modelTier: config.model,
58
+ env: config.env,
59
+ sessionId: config.sessionId,
60
+ });
61
+
62
+ const context = new ContextManager({
63
+ maxInputTokens: limits.maxInputTokens,
64
+ summarizationThreshold: config.summarizationThreshold,
65
+ preSummarizationRatio: config.preSummarizationRatio,
66
+ previousSummary: config.previousSummary,
67
+ });
68
+
69
+ const registry = new ToolRegistry();
70
+ const hitl = new HITLManager(config.hitlDir);
71
+ await hitl.init();
72
+ const subAgentManager = new SubAgentManager(emitter);
73
+ const todoManager = new TodoManager(emitter);
74
+ const checkpoints = new CheckpointManager(config.workDir, config.sessionId);
75
+ await checkpoints.init();
76
+
77
+ // Register all tools
78
+ const builtinTools = [execTool, getLogsTool, readFileTool, writeFileTool, editFileTool, lsTool, grepTool, globTool, deleteFileTool, moveFileTool];
79
+ const databaseTools = [createDatabaseTool, listDatabasesTool, runDatabaseQueryTool, prepareDatabaseMigrationTool, completeDatabaseMigrationTool];
80
+ const externalTools = [webSearchTool, webFetchTool, previewTool, waitTool, generateImageTool, enterPlanModeTool, ...databaseTools];
81
+ const hitlTools = [askUserTool, writePlanTool, requestEnvVarTool];
82
+
83
+ for (const tool of [...builtinTools, ...externalTools, ...hitlTools]) {
84
+ registry.register(tool);
85
+ }
86
+ registry.register(createTaskTool(subAgentManager));
87
+ for (const tool of todoManager.createTools()) {
88
+ registry.register(tool);
89
+ }
90
+
91
+ // Build middleware
92
+ const middleware: Middleware[] = [
93
+ toolCompressionMiddleware,
94
+ forbiddenPackagesMiddleware,
95
+ credentialScrubbingMiddleware,
96
+ createToolFilterMiddleware(config.blockedTools ?? []),
97
+ createPlanModeMiddleware(() => currentMode),
98
+ createPromptCachingMiddleware(modelName),
99
+ ];
100
+
101
+ emitter.emit({ type: "session-start", model: modelName, session_id: config.sessionId });
102
+
103
+ // Load skills and build system prompt
104
+ const skills = await loadSkills(config.skillsDir ?? "./skills");
105
+ const systemPrompt = buildSystemPrompt({
106
+ workDir: config.workDir,
107
+ skills,
108
+ systemPromptExtra: config.systemPromptExtra,
109
+ mode: currentMode,
110
+ });
111
+
112
+ let iteration = 0;
113
+ const maxIterations = config.maxIterations ?? 200;
114
+
115
+ // Restore from checkpoint or start fresh
116
+ if (config.restoreCheckpoint) {
117
+ const cp = config.restoreCheckpoint;
118
+ context.restoreMessages(cp.messages);
119
+ todoManager.restore(cp.todos);
120
+ iteration = cp.iteration;
121
+ emitter.emit({ type: "checkpoint-restored", id: cp.id, iteration: cp.iteration });
122
+
123
+ // Inject new user message as continuation
124
+ context.addUser(config.userMessage);
125
+ } else {
126
+ context.addSystem(systemPrompt);
127
+ context.addUser(config.userMessage, config.images);
128
+ }
129
+
130
+ while (iteration < maxIterations) {
131
+ iteration++;
132
+
133
+ // Process stdin commands (inject, cancel, hitl-response, rollback)
134
+ for (const cmd of stdin.drain()) {
135
+ switch (cmd.type) {
136
+ case "inject":
137
+ context.addUser(cmd.text, cmd.images);
138
+ emitter.emit({ type: "inject-received", text: cmd.text });
139
+ break;
140
+ case "hitl-response":
141
+ // Forward to file-based HITL (stdin is an alternative transport)
142
+ await hitl.writeResponse(cmd.id, { decision: cmd.decision, data: cmd.data });
143
+ break;
144
+ case "confirm-plan": {
145
+ if (currentMode !== "plan") break;
146
+ const prevMode = currentMode;
147
+ currentMode = "implement";
148
+
149
+ // Context reset: extract plan, clear planning messages, inject plan as system context
150
+ const { newMessages, planContent } = resetContextForImplementation(context.messages());
151
+ context.restoreMessages(newMessages);
152
+
153
+ // Save confirmed plan to disk
154
+ if (planContent) {
155
+ const planDir = `${config.workDir}/.strayl/plans`;
156
+ const fs = await import("node:fs/promises");
157
+ const path = await import("node:path");
158
+ await fs.mkdir(planDir, { recursive: true });
159
+ const planFile = path.join(planDir, `${config.sessionId}-${Date.now()}.md`);
160
+ await fs.writeFile(planFile, planContent);
161
+ emitter.emit({ type: "plan-confirmed", plan: planContent });
162
+ }
163
+
164
+ emitter.emit({ type: "mode-changed", from: prevMode, to: currentMode });
165
+
166
+ // Inject implementation mode prompt into context
167
+ const { IMPLEMENTATION_MODE_PROMPT } = await import("./prompts/system.js");
168
+ context.addUser(`[System] The plan has been confirmed. Switch to implementation mode.\n\n${IMPLEMENTATION_MODE_PROMPT}`);
169
+ break;
170
+ }
171
+ case "rollback": {
172
+ const cp = cmd.checkpoint_id
173
+ ? checkpoints.get(cmd.checkpoint_id)
174
+ : cmd.iteration != null
175
+ ? checkpoints.getByIteration(cmd.iteration)
176
+ : checkpoints.latest();
177
+ if (cp) {
178
+ context.restoreMessages(cp.messages);
179
+ todoManager.restore(cp.todos);
180
+ iteration = cp.iteration;
181
+ emitter.emit({ type: "checkpoint-restored", id: cp.id, iteration: cp.iteration });
182
+ } else {
183
+ emitter.emit({ type: "error", message: "Checkpoint not found", recoverable: true });
184
+ }
185
+ break;
186
+ }
187
+ }
188
+ }
189
+
190
+ // Check cancellation (stdin or file-based)
191
+ if (stdin.isCancelled() || await hitl.isCancelled()) {
192
+ stdin.stop();
193
+ emitter.emit({ type: "session-end", usage: context.totalUsage(), exit_reason: "cancelled" });
194
+ return;
195
+ }
196
+
197
+ // Pre-summarization (non-blocking)
198
+ context.maybeTriggerPreSummarization(client, emitter);
199
+
200
+ // Apply pending summary if ready
201
+ await context.applyPendingSummary(emitter);
202
+
203
+ // Hard summarization (blocking)
204
+ if (context.shouldSummarize()) {
205
+ emitter.emit({
206
+ type: "summarizing",
207
+ token_count: context.estimateTokens(),
208
+ threshold: config.summarizationThreshold ?? 140_000,
209
+ });
210
+ await context.summarize(client, emitter);
211
+ }
212
+
213
+ // Hard trim if still over limit
214
+ if (context.estimateTokens() > context.maxInputTokens) {
215
+ context.applyTrim();
216
+ }
217
+
218
+ // Prepare messages + tools
219
+ let messages = context.messages();
220
+ let tools = registry.toOpenAITools(new Set(config.blockedTools));
221
+
222
+ // Apply middleware
223
+ for (const mw of middleware) {
224
+ if (mw.beforeModel) messages = mw.beforeModel(messages);
225
+ if (mw.filterTools) tools = mw.filterTools(tools);
226
+ }
227
+
228
+ // Stream LLM response
229
+ let assistantText = "";
230
+ const completedToolCalls: ToolCall[] = [];
231
+ const partialArgs = new Map<number, { id: string; name: string; args: string }>();
232
+
233
+ try {
234
+ for await (const chunk of client.stream(messages, tools)) {
235
+ switch (chunk.type) {
236
+ case "text":
237
+ assistantText += chunk.text;
238
+ emitter.emit({ type: "text-delta", text: chunk.text });
239
+ break;
240
+
241
+ case "reasoning":
242
+ emitter.emit({ type: "reasoning-delta", text: chunk.text });
243
+ break;
244
+
245
+ case "tool_call_delta": {
246
+ const partial = partialArgs.get(chunk.index) ?? { id: "", name: "", args: "" };
247
+ if (chunk.id) partial.id = chunk.id;
248
+ if (chunk.name) partial.name = chunk.name;
249
+ partial.args += chunk.arguments;
250
+ partialArgs.set(chunk.index, partial);
251
+ break;
252
+ }
253
+
254
+ case "tool_call_complete":
255
+ completedToolCalls.push({
256
+ id: chunk.id,
257
+ type: "function",
258
+ function: { name: chunk.name, arguments: chunk.arguments },
259
+ });
260
+ break;
261
+
262
+ case "usage": {
263
+ context.recordUsage(chunk);
264
+ const used = context.estimateTokens();
265
+ const max = context.maxInputTokens;
266
+ const leftPercent = Math.max(0, Math.round((1 - used / max) * 100));
267
+ emitter.emit({
268
+ type: "usage-update",
269
+ input_tokens: chunk.input_tokens,
270
+ output_tokens: chunk.output_tokens,
271
+ cost: chunk.cost,
272
+ peak_input_tokens: context.peakInputTokens(),
273
+ context_left_percent: leftPercent,
274
+ });
275
+ break;
276
+ }
277
+ }
278
+ }
279
+ } catch (e) {
280
+ const msg = e instanceof Error ? e.message : String(e);
281
+ emitter.emit({ type: "error", message: `LLM error: ${msg}`, recoverable: true });
282
+
283
+ // Add error as assistant message so loop can continue
284
+ context.addAssistant(`[Error communicating with model: ${msg}]`);
285
+ continue;
286
+ }
287
+
288
+ // Add assistant message to context
289
+ context.addAssistant(assistantText, completedToolCalls.length > 0 ? completedToolCalls : undefined);
290
+
291
+ // Emit context status after each LLM call
292
+ {
293
+ const used = context.estimateTokens();
294
+ const max = context.maxInputTokens;
295
+ const leftPercent = Math.max(0, Math.round((1 - used / max) * 100));
296
+ const usage = context.totalUsage();
297
+ emitter.emit({
298
+ type: "usage-update",
299
+ input_tokens: usage.input_tokens,
300
+ output_tokens: usage.output_tokens,
301
+ cost: usage.cost,
302
+ peak_input_tokens: context.peakInputTokens(),
303
+ context_left_percent: leftPercent,
304
+ });
305
+ }
306
+
307
+ // No tool calls = agent is done
308
+ if (completedToolCalls.length === 0) break;
309
+
310
+ // Execute tool calls
311
+ for (const tc of completedToolCalls) {
312
+ let parsedArgs: unknown;
313
+ try {
314
+ parsedArgs = JSON.parse(tc.function.arguments);
315
+ } catch {
316
+ parsedArgs = {};
317
+ }
318
+
319
+ // Emit semantic activity for UI indicators
320
+ const activity = toolToActivity(tc.function.name, parsedArgs);
321
+ if (activity) {
322
+ emitter.emit({ type: "activity", ...activity });
323
+ }
324
+
325
+ emitter.emit({ type: "tool-call-start", id: tc.id, name: tc.function.name, args: parsedArgs });
326
+
327
+ // HITL interrupt check
328
+ const toolDef = registry.get(tc.function.name);
329
+ if (toolDef?.hitl) {
330
+ emitter.emit({ type: "hitl-request", id: tc.id, safe_id: hitl.safeId(tc.id), tool: tc.function.name, args: parsedArgs });
331
+ const response = await hitl.waitForResponse(tc.id);
332
+
333
+ if (response.decision === "reject") {
334
+ const rejectResult = JSON.stringify({ error: "User rejected this action." });
335
+ emitter.emit({ type: "tool-result", id: tc.id, name: tc.function.name, output: rejectResult });
336
+ context.addToolResult(tc.id, tc.function.name, rejectResult);
337
+ continue;
338
+ }
339
+
340
+ if (response.decision === "edit" && response.data && typeof response.data === "object") {
341
+ parsedArgs = { ...(parsedArgs as Record<string, unknown>), ...(response.data as Record<string, unknown>) };
342
+ }
343
+
344
+ emitter.emit({ type: "hitl-response", id: tc.id, decision: response.decision, data: response.data });
345
+ }
346
+
347
+ // Execute with middleware chain
348
+ const toolCtx: ToolContext = {
349
+ emitter,
350
+ workDir: config.workDir,
351
+ env: config.env,
352
+ sessionId: config.sessionId,
353
+ toolCallId: tc.id,
354
+ };
355
+
356
+ const modifiedTc: ToolCall = {
357
+ ...tc,
358
+ function: { ...tc.function, arguments: JSON.stringify(parsedArgs) },
359
+ };
360
+
361
+ const result = await executeTool(registry, modifiedTc, toolCtx, middleware);
362
+
363
+ emitter.emit({ type: "tool-result", id: tc.id, name: tc.function.name, output: result });
364
+ context.addToolResult(tc.id, tc.function.name, result);
365
+
366
+ // Mode transition: enterPlanMode tool → switch to plan mode
367
+ if (tc.function.name === "enterPlanMode" && currentMode === "normal") {
368
+ const prevMode = currentMode;
369
+ currentMode = "plan";
370
+ emitter.emit({ type: "mode-changed", from: prevMode, to: currentMode });
371
+ }
372
+ }
373
+
374
+ // Save checkpoint after each complete iteration (LLM response + all tool results)
375
+ await checkpoints.save(iteration, context.messages(), todoManager.read(), context.totalUsage(), emitter);
376
+ }
377
+
378
+ if (iteration >= maxIterations) {
379
+ emitter.emit({
380
+ type: "error",
381
+ message: `Agent exceeded maximum iterations (${maxIterations})`,
382
+ recoverable: false,
383
+ });
384
+ }
385
+
386
+ stdin.stop();
387
+ emitter.emit({
388
+ type: "session-end",
389
+ usage: context.totalUsage(),
390
+ exit_reason: iteration >= maxIterations ? "max_iterations" : "complete",
391
+ });
392
+ }
393
+
394
+ /** Map tool name → semantic activity for UI indicators */
395
+ function toolToActivity(
396
+ name: string,
397
+ args: unknown,
398
+ ): { activity: ActivityType; message?: string } | null {
399
+ const a = args as Record<string, unknown> | undefined;
400
+ switch (name) {
401
+ case "write_todos": {
402
+ const todos = a?.todos as unknown[] | undefined;
403
+ if (!todos || todos.length === 0) return { activity: "clearing-todos" };
404
+ return { activity: "creating-todos" };
405
+ }
406
+ case "update_todo":
407
+ return { activity: "updating-todos" };
408
+ case "askUser":
409
+ return { activity: "asking-user", message: a?.question as string };
410
+ case "writePlan":
411
+ return { activity: "planning", message: a?.title as string };
412
+ case "enterPlanMode":
413
+ return { activity: "planning", message: a?.reason as string };
414
+ case "create_database":
415
+ return { activity: "creating-database", message: a?.name as string };
416
+ case "run_database_query":
417
+ return { activity: "running-query" };
418
+ case "prepare_database_migration":
419
+ case "complete_database_migration":
420
+ return { activity: "running-migration" };
421
+ case "web_search":
422
+ return { activity: "searching-web", message: a?.query as string };
423
+ case "web_fetch":
424
+ return { activity: "fetching-page", message: a?.url as string };
425
+ case "generate_image":
426
+ return { activity: "generating-image" };
427
+ case "read_file":
428
+ return { activity: "reading-file", message: a?.path as string };
429
+ case "write_file":
430
+ return { activity: "writing-file", message: a?.path as string };
431
+ case "edit_file":
432
+ return { activity: "editing-file", message: a?.path as string };
433
+ case "exec":
434
+ return { activity: "running-command", message: (a?.command as string)?.slice(0, 80) };
435
+ case "task":
436
+ return { activity: "delegating-task", message: a?.description as string };
437
+ default:
438
+ return null;
439
+ }
440
+ }
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Message, TodoItem, Usage, CheckpointData } from "../types.js";
4
+ import type { Emitter } from "../emitter.js";
5
+
6
+ export type Checkpoint = CheckpointData;
7
+
8
+ export class CheckpointManager {
9
+ private dir: string;
10
+ private checkpoints: Map<string, Checkpoint> = new Map();
11
+
12
+ constructor(workDir: string, sessionId: string) {
13
+ this.dir = path.join(workDir, ".strayl", "checkpoints", sessionId);
14
+ }
15
+
16
+ async init(): Promise<void> {
17
+ await fs.mkdir(this.dir, { recursive: true });
18
+
19
+ // Load existing checkpoints from disk
20
+ try {
21
+ const files = await fs.readdir(this.dir);
22
+ for (const file of files) {
23
+ if (!file.endsWith(".json")) continue;
24
+ try {
25
+ const content = await fs.readFile(path.join(this.dir, file), "utf-8");
26
+ const cp = JSON.parse(content) as Checkpoint;
27
+ this.checkpoints.set(cp.id, cp);
28
+ } catch {
29
+ // Corrupted checkpoint — skip
30
+ }
31
+ }
32
+ } catch {
33
+ // No checkpoints dir yet — fine
34
+ }
35
+ }
36
+
37
+ async save(
38
+ iteration: number,
39
+ messages: Message[],
40
+ todos: TodoItem[],
41
+ usage: Usage,
42
+ emitter: Emitter,
43
+ ): Promise<string> {
44
+ const id = `cp_${iteration}_${Date.now()}`;
45
+ const checkpoint: Checkpoint = {
46
+ id,
47
+ iteration,
48
+ timestamp: Date.now(),
49
+ messages: structuredClone(messages),
50
+ todos: structuredClone(todos),
51
+ usage: { ...usage },
52
+ };
53
+
54
+ this.checkpoints.set(id, checkpoint);
55
+
56
+ // Write atomically: tmp → rename
57
+ const tmpPath = path.join(this.dir, `${id}.tmp`);
58
+ const finalPath = path.join(this.dir, `${id}.json`);
59
+ await fs.writeFile(tmpPath, JSON.stringify(checkpoint));
60
+ await fs.rename(tmpPath, finalPath);
61
+
62
+ emitter.emit({ type: "checkpoint-saved", id, iteration, checkpoint });
63
+
64
+ // Keep only last 50 checkpoints on disk
65
+ await this.pruneOld(50);
66
+
67
+ return id;
68
+ }
69
+
70
+ get(id: string): Checkpoint | undefined {
71
+ return this.checkpoints.get(id);
72
+ }
73
+
74
+ /** Get the most recent checkpoint at or before the given iteration */
75
+ getByIteration(iteration: number): Checkpoint | undefined {
76
+ let best: Checkpoint | undefined;
77
+ for (const cp of this.checkpoints.values()) {
78
+ if (cp.iteration <= iteration) {
79
+ if (!best || cp.iteration > best.iteration) {
80
+ best = cp;
81
+ }
82
+ }
83
+ }
84
+ return best;
85
+ }
86
+
87
+ /** Get the latest checkpoint */
88
+ latest(): Checkpoint | undefined {
89
+ let best: Checkpoint | undefined;
90
+ for (const cp of this.checkpoints.values()) {
91
+ if (!best || cp.timestamp > best.timestamp) {
92
+ best = cp;
93
+ }
94
+ }
95
+ return best;
96
+ }
97
+
98
+ list(): Checkpoint[] {
99
+ return [...this.checkpoints.values()].sort((a, b) => a.iteration - b.iteration);
100
+ }
101
+
102
+ private async pruneOld(keep: number): Promise<void> {
103
+ const sorted = this.list();
104
+ if (sorted.length <= keep) return;
105
+
106
+ const toRemove = sorted.slice(0, sorted.length - keep);
107
+ for (const cp of toRemove) {
108
+ this.checkpoints.delete(cp.id);
109
+ await fs.unlink(path.join(this.dir, `${cp.id}.json`)).catch(() => {});
110
+ }
111
+ }
112
+ }