@tjamescouch/gro 1.3.6 → 1.3.8

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 (51) hide show
  1. package/dist/drivers/anthropic.js +256 -0
  2. package/dist/drivers/index.js +2 -0
  3. package/dist/drivers/streaming-openai.js +262 -0
  4. package/dist/drivers/types.js +1 -0
  5. package/dist/errors.js +79 -0
  6. package/dist/logger.js +30 -0
  7. package/dist/main.js +867 -0
  8. package/dist/mcp/client.js +130 -0
  9. package/dist/mcp/index.js +1 -0
  10. package/dist/memory/advanced-memory.js +210 -0
  11. package/dist/memory/agent-memory.js +52 -0
  12. package/dist/memory/agenthnsw.js +86 -0
  13. package/{src/memory/index.ts → dist/memory/index.js} +0 -1
  14. package/dist/memory/simple-memory.js +34 -0
  15. package/dist/memory/vector-index.js +7 -0
  16. package/dist/package.json +22 -0
  17. package/dist/session.js +154 -0
  18. package/dist/tools/agentpatch.js +91 -0
  19. package/dist/tools/bash.js +61 -0
  20. package/dist/tools/version.js +76 -0
  21. package/dist/utils/rate-limiter.js +46 -0
  22. package/{src/utils/retry.ts → dist/utils/retry.js} +8 -12
  23. package/dist/utils/timed-fetch.js +25 -0
  24. package/package.json +11 -3
  25. package/.github/workflows/ci.yml +0 -20
  26. package/src/drivers/anthropic.ts +0 -281
  27. package/src/drivers/index.ts +0 -5
  28. package/src/drivers/streaming-openai.ts +0 -258
  29. package/src/drivers/types.ts +0 -39
  30. package/src/errors.ts +0 -97
  31. package/src/logger.ts +0 -28
  32. package/src/main.ts +0 -905
  33. package/src/mcp/client.ts +0 -163
  34. package/src/mcp/index.ts +0 -2
  35. package/src/memory/advanced-memory.ts +0 -263
  36. package/src/memory/agent-memory.ts +0 -61
  37. package/src/memory/agenthnsw.ts +0 -122
  38. package/src/memory/simple-memory.ts +0 -41
  39. package/src/memory/vector-index.ts +0 -30
  40. package/src/session.ts +0 -150
  41. package/src/tools/agentpatch.ts +0 -89
  42. package/src/tools/bash.ts +0 -61
  43. package/src/tools/version.ts +0 -98
  44. package/src/utils/rate-limiter.ts +0 -60
  45. package/src/utils/timed-fetch.ts +0 -29
  46. package/tests/errors.test.ts +0 -246
  47. package/tests/memory.test.ts +0 -186
  48. package/tests/rate-limiter.test.ts +0 -76
  49. package/tests/retry.test.ts +0 -138
  50. package/tests/timed-fetch.test.ts +0 -104
  51. package/tsconfig.json +0 -13
package/dist/main.js ADDED
@@ -0,0 +1,867 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gro — provider-agnostic LLM runtime with context management.
4
+ *
5
+ * Extracted from org. Single-agent, headless, no terminal UI.
6
+ * Reads prompt from argv or stdin, manages conversation state,
7
+ * outputs completion to stdout. Connects to MCP servers for tools.
8
+ *
9
+ * Supersets the claude CLI flags for drop-in compatibility.
10
+ */
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { Logger, C } from "./logger.js";
15
+ import { makeStreamingOpenAiDriver } from "./drivers/streaming-openai.js";
16
+ import { makeAnthropicDriver } from "./drivers/anthropic.js";
17
+ import { SimpleMemory } from "./memory/simple-memory.js";
18
+ import { AdvancedMemory } from "./memory/advanced-memory.js";
19
+ import { McpManager } from "./mcp/index.js";
20
+ import { newSessionId, findLatestSession, loadSession, ensureGroDir } from "./session.js";
21
+ import { groError, asError, isGroError, errorLogFields } from "./errors.js";
22
+ import { bashToolDefinition, executeBash } from "./tools/bash.js";
23
+ import { agentpatchToolDefinition, executeAgentpatch } from "./tools/agentpatch.js";
24
+ import { groVersionToolDefinition, executeGroVersion, getGroVersion } from "./tools/version.js";
25
+ const VERSION = getGroVersion();
26
+ // ---------------------------------------------------------------------------
27
+ // Graceful shutdown state — module-level so signal handlers can save sessions.
28
+ // ---------------------------------------------------------------------------
29
+ let _shutdownMemory = null;
30
+ let _shutdownSessionId = null;
31
+ let _shutdownSessionPersistence = false;
32
+ /** Auto-save interval: save session every N tool rounds in persistent mode */
33
+ const AUTO_SAVE_INTERVAL = 10;
34
+ // Wake notes: a runner-global file that is prepended to the system prompt on process start
35
+ // so agents reliably see dev workflow + memory pointers on wake.
36
+ const WAKE_NOTES_DEFAULT_PATH = join(process.env.HOME || "", ".claude", "WAKE.md");
37
+ function loadMcpServers(mcpConfigPaths) {
38
+ // If explicit --mcp-config paths given, use those
39
+ if (mcpConfigPaths.length > 0) {
40
+ const merged = {};
41
+ for (const p of mcpConfigPaths) {
42
+ try {
43
+ let raw;
44
+ if (p.startsWith("{")) {
45
+ raw = p; // inline JSON
46
+ }
47
+ else if (existsSync(p)) {
48
+ raw = readFileSync(p, "utf-8");
49
+ }
50
+ else {
51
+ Logger.warn(`MCP config not found: ${p}`);
52
+ continue;
53
+ }
54
+ const parsed = JSON.parse(raw);
55
+ const servers = parsed.mcpServers || parsed;
56
+ if (typeof servers === "object") {
57
+ Object.assign(merged, servers);
58
+ }
59
+ }
60
+ catch (e) {
61
+ const ge = groError("config_error", `Failed to parse MCP config ${p}: ${asError(e).message}`, { cause: e });
62
+ Logger.warn(ge.message, errorLogFields(ge));
63
+ }
64
+ }
65
+ return merged;
66
+ }
67
+ // Try Claude Code config locations
68
+ const candidates = [
69
+ join(process.cwd(), ".claude", "settings.json"),
70
+ join(process.env.HOME || "", ".claude", "settings.json"),
71
+ ];
72
+ for (const path of candidates) {
73
+ if (existsSync(path)) {
74
+ try {
75
+ const raw = readFileSync(path, "utf-8");
76
+ const parsed = JSON.parse(raw);
77
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
78
+ Logger.debug(`Loaded MCP config from ${path}`);
79
+ return parsed.mcpServers;
80
+ }
81
+ }
82
+ catch (e) {
83
+ const ge = groError("config_error", `Failed to parse ${path}: ${asError(e).message}`, { cause: e });
84
+ Logger.debug(ge.message, errorLogFields(ge));
85
+ }
86
+ }
87
+ }
88
+ return {};
89
+ }
90
+ // Flags that claude supports but we don't yet — accept gracefully
91
+ const UNSUPPORTED_VALUE_FLAGS = new Set([
92
+ "--effort", "--agent", "--agents", "--betas", "--fallback-model",
93
+ "--permission-prompt-tool", "--permission-mode", "--tools",
94
+ "--allowedTools", "--allowed-tools", "--disallowedTools", "--disallowed-tools",
95
+ "--add-dir", "--plugin-dir", "--settings", "--setting-sources",
96
+ "--json-schema", "--input-format", "--file",
97
+ "--resume-session-at", "--rewind-files", "--session-id",
98
+ "--debug-file", "--sdk-url",
99
+ ]);
100
+ const UNSUPPORTED_BOOL_FLAGS = new Set([
101
+ "--include-partial-messages", "--replay-user-messages",
102
+ "--dangerously-skip-permissions", "--allow-dangerously-skip-permissions",
103
+ "--fork-session", "--from-pr", "--strict-mcp-config", "--mcp-debug",
104
+ "--ide", "--chrome", "--no-chrome", "--disable-slash-commands",
105
+ "--init", "--init-only", "--maintenance", "--enable-auth-status",
106
+ ]);
107
+ function loadConfig() {
108
+ const args = process.argv.slice(2);
109
+ const flags = {};
110
+ const positional = [];
111
+ const mcpConfigPaths = [];
112
+ // Wake file: global startup instructions injected into the system prompt.
113
+ // This is intentionally runner-level (not per-repo) so agents reliably see
114
+ // the same rules on boot.
115
+ const defaultWakeFile = join(homedir(), ".claude", "WAKE.md");
116
+ let wakeFile = defaultWakeFile;
117
+ let disableWake = false;
118
+ for (let i = 0; i < args.length; i++) {
119
+ const arg = args[i];
120
+ // --- gro native flags ---
121
+ if (arg === "--provider" || arg === "-P") {
122
+ flags.provider = args[++i];
123
+ }
124
+ else if (arg === "--model" || arg === "-m") {
125
+ flags.model = args[++i];
126
+ }
127
+ else if (arg === "--base-url") {
128
+ flags.baseUrl = args[++i];
129
+ }
130
+ else if (arg === "--system-prompt") {
131
+ flags.systemPrompt = args[++i];
132
+ }
133
+ else if (arg === "--system-prompt-file") {
134
+ flags.systemPromptFile = args[++i];
135
+ }
136
+ else if (arg === "--append-system-prompt") {
137
+ flags.appendSystemPrompt = args[++i];
138
+ }
139
+ else if (arg === "--append-system-prompt-file") {
140
+ flags.appendSystemPromptFile = args[++i];
141
+ }
142
+ else if (arg === "--wake-notes") {
143
+ flags.wakeNotes = args[++i];
144
+ }
145
+ else if (arg === "--no-wake-notes") {
146
+ flags.noWakeNotes = "true";
147
+ }
148
+ else if (arg === "--context-tokens") {
149
+ flags.contextTokens = args[++i];
150
+ }
151
+ else if (arg === "--max-tokens") {
152
+ flags.maxTokens = args[++i];
153
+ }
154
+ else if (arg === "--max-tool-rounds" || arg === "--max-turns") {
155
+ flags.maxToolRounds = args[++i];
156
+ }
157
+ else if (arg === "--bash") {
158
+ flags.bash = "true";
159
+ }
160
+ else if (arg === "--persistent" || arg === "--keep-alive") {
161
+ flags.persistent = "true";
162
+ }
163
+ else if (arg === "--max-idle-nudges") {
164
+ flags.maxIdleNudges = args[++i];
165
+ }
166
+ else if (arg === "--max-thinking-tokens") {
167
+ flags.maxThinkingTokens = args[++i];
168
+ } // accepted, not used yet
169
+ else if (arg === "--max-budget-usd") {
170
+ flags.maxBudgetUsd = args[++i];
171
+ } // accepted, not used yet
172
+ else if (arg === "--summarizer-model") {
173
+ flags.summarizerModel = args[++i];
174
+ }
175
+ else if (arg === "--output-format") {
176
+ flags.outputFormat = args[++i];
177
+ }
178
+ else if (arg === "--mcp-config") {
179
+ mcpConfigPaths.push(args[++i]);
180
+ }
181
+ else if (arg === "-i" || arg === "--interactive") {
182
+ flags.interactive = "true";
183
+ }
184
+ else if (arg === "-p" || arg === "--print") {
185
+ flags.print = "true";
186
+ }
187
+ else if (arg === "-c" || arg === "--continue") {
188
+ flags.continue = "true";
189
+ }
190
+ else if (arg === "-r" || arg === "--resume") {
191
+ // --resume can have optional value
192
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
193
+ flags.resume = args[++i];
194
+ }
195
+ else {
196
+ flags.resume = "latest";
197
+ }
198
+ }
199
+ else if (arg === "--no-mcp") {
200
+ flags.noMcp = "true";
201
+ }
202
+ else if (arg === "--no-session-persistence") {
203
+ flags.noSessionPersistence = "true";
204
+ }
205
+ else if (arg === "--verbose") {
206
+ flags.verbose = "true";
207
+ }
208
+ else if (arg === "-d" || arg === "--debug" || arg === "-d2e" || arg === "--debug-to-stderr") {
209
+ flags.verbose = "true";
210
+ // --debug may have optional filter value
211
+ if (arg === "-d" || arg === "--debug") {
212
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
213
+ i++;
214
+ } // consume filter
215
+ }
216
+ }
217
+ else if (arg === "-V" || arg === "--version") {
218
+ console.log(`gro ${VERSION}`);
219
+ process.exit(0);
220
+ }
221
+ else if (arg === "-h" || arg === "--help") {
222
+ usage();
223
+ process.exit(0);
224
+ }
225
+ // --- graceful degradation for unsupported claude flags ---
226
+ else if (UNSUPPORTED_VALUE_FLAGS.has(arg)) {
227
+ Logger.warn(`${arg} not yet supported, ignoring`);
228
+ if (i + 1 < args.length && !args[i + 1].startsWith("-"))
229
+ i++; // skip value
230
+ }
231
+ else if (UNSUPPORTED_BOOL_FLAGS.has(arg)) {
232
+ Logger.warn(`${arg} not yet supported, ignoring`);
233
+ }
234
+ else if (!arg.startsWith("-")) {
235
+ positional.push(arg);
236
+ }
237
+ else {
238
+ Logger.warn(`Unknown flag: ${arg}`);
239
+ }
240
+ }
241
+ const provider = inferProvider(flags.provider, flags.model);
242
+ const apiKey = resolveApiKey(provider);
243
+ const noMcp = flags.noMcp === "true";
244
+ const mcpServers = noMcp ? {} : loadMcpServers(mcpConfigPaths);
245
+ // Resolve system prompt
246
+ let systemPrompt = flags.systemPrompt || "";
247
+ // Inject wake notes by default (runner-global), unless explicitly disabled.
248
+ // This ensures the model always sees workflow + memory pointers on wake.
249
+ const wakeNotesPath = flags.wakeNotes || WAKE_NOTES_DEFAULT_PATH;
250
+ const wakeNotesEnabled = flags.noWakeNotes !== "true";
251
+ if (wakeNotesEnabled && wakeNotesPath && existsSync(wakeNotesPath)) {
252
+ try {
253
+ const wake = readFileSync(wakeNotesPath, "utf-8").trim();
254
+ if (wake)
255
+ systemPrompt = systemPrompt ? `${wake}
256
+
257
+ ${systemPrompt}` : wake;
258
+ }
259
+ catch (e) {
260
+ // Non-fatal: if wake notes can't be read, proceed without them.
261
+ Logger.warn(`Failed to read wake notes at ${wakeNotesPath}: ${asError(e).message}`);
262
+ }
263
+ }
264
+ if (flags.systemPromptFile) {
265
+ try {
266
+ systemPrompt = readFileSync(flags.systemPromptFile, "utf-8").trim();
267
+ }
268
+ catch (e) {
269
+ const ge = groError("config_error", `Failed to read system prompt file: ${asError(e).message}`, { cause: e });
270
+ Logger.error(ge.message, errorLogFields(ge));
271
+ process.exit(1);
272
+ }
273
+ }
274
+ if (flags.appendSystemPrompt) {
275
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${flags.appendSystemPrompt}` : flags.appendSystemPrompt;
276
+ }
277
+ if (flags.appendSystemPromptFile) {
278
+ try {
279
+ const extra = readFileSync(flags.appendSystemPromptFile, "utf-8").trim();
280
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${extra}` : extra;
281
+ }
282
+ catch (e) {
283
+ const ge = groError("config_error", `Failed to read append system prompt file: ${asError(e).message}`, { cause: e });
284
+ Logger.error(ge.message, errorLogFields(ge));
285
+ process.exit(1);
286
+ }
287
+ }
288
+ // Default wake injection: prepend runner-global WAKE.md unless explicitly disabled.
289
+ // Soft dependency: if missing, warn and continue.
290
+ if (!disableWake && wakeFile) {
291
+ try {
292
+ const wake = readFileSync(wakeFile, "utf-8").trim();
293
+ if (wake)
294
+ systemPrompt = systemPrompt ? `${wake}\n\n${systemPrompt}` : wake;
295
+ }
296
+ catch (e) {
297
+ Logger.warn(`Wake file not found/readable (${wakeFile}); continuing without it`);
298
+ }
299
+ }
300
+ // Mode resolution: -p forces non-interactive, -i forces interactive
301
+ // Default: interactive if TTY and no prompt given
302
+ const printMode = flags.print === "true";
303
+ const interactiveMode = printMode ? false
304
+ : flags.interactive === "true" ? true
305
+ : (positional.length === 0 && process.stdin.isTTY === true);
306
+ return {
307
+ provider,
308
+ model: flags.model || defaultModel(provider),
309
+ baseUrl: flags.baseUrl || defaultBaseUrl(provider),
310
+ apiKey,
311
+ systemPrompt,
312
+ wakeNotes: flags.wakeNotes || WAKE_NOTES_DEFAULT_PATH,
313
+ wakeNotesEnabled: flags.noWakeNotes !== "true",
314
+ contextTokens: parseInt(flags.contextTokens || "8192"),
315
+ maxTokens: parseInt(flags.maxTokens || "16384"),
316
+ interactive: interactiveMode,
317
+ print: printMode,
318
+ maxToolRounds: parseInt(flags.maxToolRounds || "10"),
319
+ persistent: flags.persistent === "true",
320
+ maxIdleNudges: parseInt(flags.maxIdleNudges || "10"),
321
+ bash: flags.bash === "true",
322
+ summarizerModel: flags.summarizerModel || null,
323
+ outputFormat: flags.outputFormat || "text",
324
+ continueSession: flags.continue === "true",
325
+ resumeSession: flags.resume || null,
326
+ sessionPersistence: flags.noSessionPersistence !== "true",
327
+ verbose: flags.verbose === "true",
328
+ mcpServers,
329
+ };
330
+ }
331
+ function inferProvider(explicit, model) {
332
+ if (explicit) {
333
+ if (explicit === "openai" || explicit === "anthropic" || explicit === "local")
334
+ return explicit;
335
+ Logger.warn(`Unknown provider "${explicit}", defaulting to anthropic`);
336
+ return "anthropic";
337
+ }
338
+ if (model) {
339
+ if (/^(gpt-|o1-|o3-|o4-|chatgpt-)/.test(model))
340
+ return "openai";
341
+ if (/^(claude-|sonnet|haiku|opus)/.test(model))
342
+ return "anthropic";
343
+ if (/^(gemma|llama|mistral|phi|qwen|deepseek)/.test(model))
344
+ return "local";
345
+ }
346
+ return "anthropic";
347
+ }
348
+ function defaultModel(provider) {
349
+ switch (provider) {
350
+ case "openai": return "gpt-4o";
351
+ case "anthropic": return "claude-sonnet-4-20250514";
352
+ case "local": return "llama3";
353
+ default: return "claude-sonnet-4-20250514";
354
+ }
355
+ }
356
+ function defaultBaseUrl(provider) {
357
+ switch (provider) {
358
+ case "openai": return process.env.OPENAI_BASE_URL || "https://api.openai.com";
359
+ case "local": return "http://127.0.0.1:11434";
360
+ default: return process.env.ANTHROPIC_BASE_URL || "https://api.anthropic.com";
361
+ }
362
+ }
363
+ function resolveApiKey(provider) {
364
+ switch (provider) {
365
+ case "openai": return process.env.OPENAI_API_KEY || "";
366
+ case "anthropic": return process.env.ANTHROPIC_API_KEY || "";
367
+ default: return "";
368
+ }
369
+ }
370
+ function usage() {
371
+ console.log(`gro ${VERSION} — provider-agnostic LLM runtime
372
+
373
+ usage:
374
+ gro [options] "prompt"
375
+ echo "prompt" | gro [options]
376
+ gro -i # interactive mode
377
+
378
+ options:
379
+ -P, --provider openai | anthropic | local (default: anthropic)
380
+ -m, --model model name (auto-infers provider)
381
+ --base-url API base URL
382
+ --system-prompt system prompt text
383
+ --system-prompt-file read system prompt from file
384
+ --append-system-prompt append to system prompt
385
+ --append-system-prompt-file append system prompt from file
386
+ --wake-notes path to wake notes file (default: ~/.claude/WAKE.md)
387
+ --no-wake-notes disable auto-prepending wake notes
388
+ --context-tokens context window budget (default: 8192)
389
+ --max-tokens max response tokens per turn (default: 16384)
390
+ --max-turns max agentic rounds per turn (default: 10)
391
+ --max-tool-rounds alias for --max-turns
392
+ --bash enable built-in bash tool for shell command execution
393
+ --persistent nudge model to keep using tools instead of exiting
394
+ --max-idle-nudges max consecutive nudges before giving up (default: 10)
395
+ --summarizer-model model for context summarization (default: same as --model)
396
+ --output-format text | json | stream-json (default: text)
397
+ --mcp-config load MCP servers from JSON file or string
398
+ --no-mcp disable MCP server connections
399
+ --no-session-persistence don't save sessions to .gro/
400
+ -p, --print print response and exit (non-interactive)
401
+ -c, --continue continue most recent session
402
+ -r, --resume [id] resume a session by ID
403
+ -i, --interactive interactive conversation mode
404
+ --verbose verbose output
405
+ -V, --version show version
406
+ -h, --help show this help
407
+
408
+ session state is stored in .gro/context/<session-id>/`);
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // Driver factory
412
+ // ---------------------------------------------------------------------------
413
+ function createDriverForModel(provider, model, apiKey, baseUrl, maxTokens) {
414
+ switch (provider) {
415
+ case "anthropic":
416
+ if (!apiKey && baseUrl === "https://api.anthropic.com") {
417
+ Logger.error("gro: ANTHROPIC_API_KEY not set (set ANTHROPIC_BASE_URL for proxy mode)");
418
+ process.exit(1);
419
+ }
420
+ return makeAnthropicDriver({ apiKey: apiKey || "proxy-managed", model, baseUrl, maxTokens });
421
+ case "openai":
422
+ if (!apiKey && baseUrl === "https://api.openai.com") {
423
+ Logger.error("gro: OPENAI_API_KEY not set (set OPENAI_BASE_URL for proxy mode)");
424
+ process.exit(1);
425
+ }
426
+ return makeStreamingOpenAiDriver({ baseUrl, model, apiKey: apiKey || undefined });
427
+ case "local":
428
+ return makeStreamingOpenAiDriver({ baseUrl, model });
429
+ default:
430
+ Logger.error(`gro: unknown provider "${provider}"`);
431
+ process.exit(1);
432
+ }
433
+ throw new Error("unreachable");
434
+ }
435
+ function createDriver(cfg) {
436
+ return createDriverForModel(cfg.provider, cfg.model, cfg.apiKey, cfg.baseUrl, cfg.maxTokens);
437
+ }
438
+ // ---------------------------------------------------------------------------
439
+ // Memory factory
440
+ // ---------------------------------------------------------------------------
441
+ function createMemory(cfg, driver) {
442
+ if (cfg.interactive) {
443
+ let summarizerDriver;
444
+ let summarizerModel;
445
+ if (cfg.summarizerModel) {
446
+ summarizerModel = cfg.summarizerModel;
447
+ const summarizerProvider = inferProvider(undefined, summarizerModel);
448
+ summarizerDriver = createDriverForModel(summarizerProvider, summarizerModel, resolveApiKey(summarizerProvider), defaultBaseUrl(summarizerProvider));
449
+ Logger.info(`Summarizer: ${summarizerProvider}/${summarizerModel}`);
450
+ }
451
+ return new AdvancedMemory({
452
+ driver,
453
+ model: cfg.model,
454
+ summarizerDriver,
455
+ summarizerModel,
456
+ systemPrompt: cfg.systemPrompt || undefined,
457
+ contextTokens: cfg.contextTokens,
458
+ });
459
+ }
460
+ const mem = new SimpleMemory(cfg.systemPrompt || undefined);
461
+ mem.setMeta(cfg.provider, cfg.model);
462
+ return mem;
463
+ }
464
+ // ---------------------------------------------------------------------------
465
+ // Output formatting
466
+ // ---------------------------------------------------------------------------
467
+ function formatOutput(text, format) {
468
+ switch (format) {
469
+ case "json":
470
+ return JSON.stringify({ result: text, type: "result" });
471
+ case "stream-json":
472
+ // For stream-json, individual tokens are already streamed.
473
+ // This is the final message.
474
+ return JSON.stringify({ result: text, type: "result" });
475
+ case "text":
476
+ default:
477
+ return text;
478
+ }
479
+ }
480
+ // ---------------------------------------------------------------------------
481
+ // Tool execution loop
482
+ // ---------------------------------------------------------------------------
483
+ /**
484
+ * Execute a single turn: call the model, handle tool calls, repeat until
485
+ * the model produces a final text response or we hit maxRounds.
486
+ */
487
+ async function executeTurn(driver, memory, mcp, cfg, sessionId) {
488
+ const tools = mcp.getToolDefinitions();
489
+ tools.push(agentpatchToolDefinition());
490
+ if (cfg.bash)
491
+ tools.push(bashToolDefinition());
492
+ tools.push(groVersionToolDefinition());
493
+ let finalText = "";
494
+ let turnTokensIn = 0;
495
+ let turnTokensOut = 0;
496
+ const onToken = cfg.outputFormat === "stream-json"
497
+ ? (t) => process.stdout.write(JSON.stringify({ type: "token", token: t }) + "\n")
498
+ : (t) => process.stdout.write(t);
499
+ let brokeCleanly = false;
500
+ let idleNudges = 0;
501
+ for (let round = 0; round < cfg.maxToolRounds; round++) {
502
+ const output = await driver.chat(memory.messages(), {
503
+ model: cfg.model,
504
+ tools: tools.length > 0 ? tools : undefined,
505
+ onToken,
506
+ });
507
+ // Track token usage for niki budget enforcement
508
+ if (output.usage) {
509
+ turnTokensIn += output.usage.inputTokens;
510
+ turnTokensOut += output.usage.outputTokens;
511
+ // Log cumulative usage to stderr — niki parses these patterns for budget enforcement
512
+ process.stderr.write(`"input_tokens": ${turnTokensIn}, "output_tokens": ${turnTokensOut}\n`);
513
+ }
514
+ // Accumulate text
515
+ if (output.text)
516
+ finalText += output.text;
517
+ // Store assistant message — must include tool_calls when present
518
+ // so OpenAI sees the required assistant→tool message sequence.
519
+ const assistantMsg = { role: "assistant", from: "Assistant", content: output.text || "" };
520
+ if (output.toolCalls.length > 0) {
521
+ assistantMsg.tool_calls = output.toolCalls;
522
+ }
523
+ await memory.add(assistantMsg);
524
+ // No tool calls — either we're done, or we need to nudge the model
525
+ if (output.toolCalls.length === 0) {
526
+ if (!cfg.persistent || tools.length === 0) {
527
+ brokeCleanly = true;
528
+ break;
529
+ }
530
+ // Persistent mode: nudge the model to resume tool use
531
+ idleNudges++;
532
+ if (idleNudges > cfg.maxIdleNudges) {
533
+ Logger.debug(`Persistent mode: ${idleNudges} consecutive idle responses — giving up`);
534
+ brokeCleanly = true;
535
+ break;
536
+ }
537
+ Logger.debug(`Persistent mode: model stopped calling tools (nudge ${idleNudges}/${cfg.maxIdleNudges})`);
538
+ await memory.add({
539
+ role: "user",
540
+ from: "System",
541
+ content: "[SYSTEM] You stopped calling tools. You are a persistent agent — you MUST continue your tool loop. Call agentchat_listen now to resume listening for messages. Do not respond with text only.",
542
+ });
543
+ continue;
544
+ }
545
+ // Model used tools — reset idle nudge counter
546
+ idleNudges = 0;
547
+ // Process tool calls
548
+ for (const tc of output.toolCalls) {
549
+ const fnName = tc.function.name;
550
+ let fnArgs;
551
+ try {
552
+ fnArgs = JSON.parse(tc.function.arguments);
553
+ }
554
+ catch (e) {
555
+ Logger.debug(`Failed to parse args for ${fnName}: ${asError(e).message}, using empty args`);
556
+ fnArgs = {};
557
+ }
558
+ Logger.debug(`Tool call: ${fnName}(${JSON.stringify(fnArgs)})`);
559
+ let result;
560
+ try {
561
+ if (fnName === "apply_patch") {
562
+ result = executeAgentpatch(fnArgs);
563
+ }
564
+ else if (fnName === "bash" && cfg.bash) {
565
+ result = executeBash(fnArgs);
566
+ }
567
+ else if (fnName === "gro_version") {
568
+ result = executeGroVersion({ provider: cfg.provider, model: cfg.model, persistent: cfg.persistent });
569
+ }
570
+ else {
571
+ result = await mcp.callTool(fnName, fnArgs);
572
+ }
573
+ }
574
+ catch (e) {
575
+ const raw = asError(e);
576
+ const ge = groError("tool_error", `Tool "${fnName}" failed: ${raw.message}`, {
577
+ retryable: false,
578
+ cause: e,
579
+ });
580
+ Logger.error("Tool execution error:", errorLogFields(ge));
581
+ if (raw.stack)
582
+ Logger.error(raw.stack);
583
+ result = `Error: ${ge.message}${raw.stack ? '\nStack: ' + raw.stack : ''}`;
584
+ }
585
+ // Feed tool result back into memory
586
+ await memory.add({
587
+ role: "tool",
588
+ from: fnName,
589
+ content: result,
590
+ tool_call_id: tc.id,
591
+ name: fnName,
592
+ });
593
+ }
594
+ // Auto-save periodically in persistent mode to survive SIGTERM/crashes
595
+ if (cfg.persistent && cfg.sessionPersistence && sessionId && round > 0 && round % AUTO_SAVE_INTERVAL === 0) {
596
+ try {
597
+ await memory.save(sessionId);
598
+ Logger.debug(`Auto-saved session ${sessionId} at round ${round}`);
599
+ }
600
+ catch (e) {
601
+ Logger.warn(`Auto-save failed at round ${round}: ${asError(e).message}`);
602
+ }
603
+ }
604
+ }
605
+ // If we exhausted maxToolRounds (loop didn't break via no-tool-calls),
606
+ // give the model one final turn with no tools so it can produce a closing response.
607
+ if (!brokeCleanly && tools.length > 0) {
608
+ Logger.debug("Max tool rounds reached — final turn with no tools");
609
+ const finalOutput = await driver.chat(memory.messages(), {
610
+ model: cfg.model,
611
+ onToken,
612
+ });
613
+ if (finalOutput.usage) {
614
+ turnTokensIn += finalOutput.usage.inputTokens;
615
+ turnTokensOut += finalOutput.usage.outputTokens;
616
+ process.stderr.write(`"input_tokens": ${turnTokensIn}, "output_tokens": ${turnTokensOut}\n`);
617
+ }
618
+ if (finalOutput.text)
619
+ finalText += finalOutput.text;
620
+ await memory.add({ role: "assistant", from: "Assistant", content: finalOutput.text || "" });
621
+ }
622
+ return finalText;
623
+ }
624
+ // ---------------------------------------------------------------------------
625
+ // Main modes
626
+ // ---------------------------------------------------------------------------
627
+ async function singleShot(cfg, driver, mcp, sessionId, positionalArgs) {
628
+ let prompt = (positionalArgs || []).join(" ").trim();
629
+ if (!prompt && !process.stdin.isTTY) {
630
+ const chunks = [];
631
+ for await (const chunk of process.stdin) {
632
+ chunks.push(chunk);
633
+ }
634
+ prompt = Buffer.concat(chunks).toString("utf-8").trim();
635
+ }
636
+ if (!prompt) {
637
+ Logger.error("gro: no prompt provided");
638
+ usage();
639
+ process.exit(1);
640
+ }
641
+ const memory = createMemory(cfg, driver);
642
+ // Register for graceful shutdown
643
+ _shutdownMemory = memory;
644
+ _shutdownSessionId = sessionId;
645
+ _shutdownSessionPersistence = cfg.sessionPersistence;
646
+ // Resume existing session if requested
647
+ if (cfg.continueSession || cfg.resumeSession) {
648
+ await memory.load(sessionId);
649
+ }
650
+ await memory.add({ role: "user", from: "User", content: prompt });
651
+ let text;
652
+ let fatalError = false;
653
+ try {
654
+ text = await executeTurn(driver, memory, mcp, cfg, sessionId);
655
+ }
656
+ catch (e) {
657
+ const ge = isGroError(e) ? e : groError("provider_error", asError(e).message, { cause: e });
658
+ Logger.error(C.red(`error: ${ge.message}`), errorLogFields(ge));
659
+ fatalError = true;
660
+ }
661
+ // Save session (even on error — preserve conversation state)
662
+ if (cfg.sessionPersistence) {
663
+ try {
664
+ await memory.save(sessionId);
665
+ }
666
+ catch (e) {
667
+ Logger.error(C.red(`session save failed: ${asError(e).message}`));
668
+ }
669
+ }
670
+ // Exit with non-zero code on fatal API errors so the supervisor
671
+ // can distinguish "finished cleanly" from "crashed on API call"
672
+ if (fatalError) {
673
+ process.exit(1);
674
+ }
675
+ if (text) {
676
+ if (cfg.outputFormat === "json") {
677
+ process.stdout.write(formatOutput(text, "json") + "\n");
678
+ }
679
+ else if (!text.endsWith("\n")) {
680
+ process.stdout.write("\n");
681
+ }
682
+ }
683
+ }
684
+ async function interactive(cfg, driver, mcp, sessionId) {
685
+ const memory = createMemory(cfg, driver);
686
+ const readline = await import("readline");
687
+ // Register for graceful shutdown
688
+ _shutdownMemory = memory;
689
+ _shutdownSessionId = sessionId;
690
+ _shutdownSessionPersistence = cfg.sessionPersistence;
691
+ // Resume existing session if requested
692
+ if (cfg.continueSession || cfg.resumeSession) {
693
+ await memory.load(sessionId);
694
+ const sess = loadSession(sessionId);
695
+ if (sess) {
696
+ const msgCount = sess.messages.filter((m) => m.role !== "system").length;
697
+ Logger.info(C.gray(`Resumed session ${sessionId} (${msgCount} messages)`));
698
+ }
699
+ }
700
+ const rl = readline.createInterface({
701
+ input: process.stdin,
702
+ output: process.stderr,
703
+ prompt: C.cyan("you > "),
704
+ });
705
+ const toolCount = mcp.getToolDefinitions().length;
706
+ Logger.info(C.gray(`gro interactive — ${cfg.provider}/${cfg.model} [${sessionId}]`));
707
+ if (cfg.summarizerModel)
708
+ Logger.info(C.gray(`summarizer: ${cfg.summarizerModel}`));
709
+ if (toolCount > 0)
710
+ Logger.info(C.gray(`${toolCount} MCP tool(s) available`));
711
+ Logger.info(C.gray("type 'exit' or Ctrl+D to quit\n"));
712
+ rl.prompt();
713
+ rl.on("line", async (line) => {
714
+ const input = line.trim();
715
+ if (!input) {
716
+ rl.prompt();
717
+ return;
718
+ }
719
+ if (input === "exit" || input === "quit") {
720
+ rl.close();
721
+ return;
722
+ }
723
+ try {
724
+ await memory.add({ role: "user", from: "User", content: input });
725
+ await executeTurn(driver, memory, mcp, cfg, sessionId);
726
+ }
727
+ catch (e) {
728
+ const ge = isGroError(e) ? e : groError("provider_error", asError(e).message, { cause: e });
729
+ Logger.error(C.red(`error: ${ge.message}`), errorLogFields(ge));
730
+ }
731
+ // Auto-save after each turn
732
+ if (cfg.sessionPersistence) {
733
+ try {
734
+ await memory.save(sessionId);
735
+ }
736
+ catch (e) {
737
+ Logger.error(C.red(`session save failed: ${asError(e).message}`));
738
+ }
739
+ }
740
+ process.stdout.write("\n");
741
+ rl.prompt();
742
+ });
743
+ rl.on("error", (e) => {
744
+ Logger.error(C.red(`readline error: ${e.message}`));
745
+ });
746
+ rl.on("close", async () => {
747
+ if (cfg.sessionPersistence) {
748
+ try {
749
+ await memory.save(sessionId);
750
+ }
751
+ catch (e) {
752
+ Logger.error(C.red(`session save failed: ${asError(e).message}`));
753
+ }
754
+ }
755
+ await mcp.disconnectAll();
756
+ Logger.info(C.gray(`\ngoodbye. session: ${sessionId}`));
757
+ process.exit(0);
758
+ });
759
+ }
760
+ // ---------------------------------------------------------------------------
761
+ // Entry point
762
+ // ---------------------------------------------------------------------------
763
+ async function main() {
764
+ const cfg = loadConfig();
765
+ if (cfg.verbose) {
766
+ process.env.GRO_LOG_LEVEL = "debug";
767
+ }
768
+ // Resolve session ID
769
+ let sessionId;
770
+ if (cfg.continueSession) {
771
+ const latest = findLatestSession();
772
+ if (!latest) {
773
+ Logger.error("gro: no session to continue");
774
+ process.exit(1);
775
+ }
776
+ sessionId = latest;
777
+ Logger.debug(`Continuing session: ${sessionId}`);
778
+ }
779
+ else if (cfg.resumeSession) {
780
+ if (cfg.resumeSession === "latest") {
781
+ const latest = findLatestSession();
782
+ if (!latest) {
783
+ Logger.error("gro: no session to resume");
784
+ process.exit(1);
785
+ }
786
+ sessionId = latest;
787
+ }
788
+ else {
789
+ sessionId = cfg.resumeSession;
790
+ }
791
+ Logger.debug(`Resuming session: ${sessionId}`);
792
+ }
793
+ else {
794
+ sessionId = newSessionId();
795
+ if (cfg.sessionPersistence) {
796
+ ensureGroDir();
797
+ }
798
+ }
799
+ const args = process.argv.slice(2);
800
+ const positional = [];
801
+ const flagsWithValues = [
802
+ "--provider", "-P", "--model", "-m", "--base-url",
803
+ "--system-prompt", "--system-prompt-file",
804
+ "--append-system-prompt", "--append-system-prompt-file",
805
+ "--context-tokens", "--max-tokens", "--max-tool-rounds", "--max-turns",
806
+ "--max-thinking-tokens", "--max-budget-usd",
807
+ "--summarizer-model", "--output-format", "--mcp-config",
808
+ "--resume", "-r",
809
+ ];
810
+ for (let i = 0; i < args.length; i++) {
811
+ if (args[i].startsWith("-")) {
812
+ if (flagsWithValues.includes(args[i]))
813
+ i++;
814
+ continue;
815
+ }
816
+ positional.push(args[i]);
817
+ }
818
+ const driver = createDriver(cfg);
819
+ // Connect to MCP servers
820
+ const mcp = new McpManager();
821
+ if (Object.keys(cfg.mcpServers).length > 0) {
822
+ await mcp.connectAll(cfg.mcpServers);
823
+ }
824
+ try {
825
+ if (cfg.interactive && positional.length === 0) {
826
+ await interactive(cfg, driver, mcp, sessionId);
827
+ }
828
+ else {
829
+ await singleShot(cfg, driver, mcp, sessionId, positional);
830
+ await mcp.disconnectAll();
831
+ }
832
+ }
833
+ catch (e) {
834
+ await mcp.disconnectAll();
835
+ throw e;
836
+ }
837
+ }
838
+ // Graceful shutdown on signals — save session before exiting
839
+ for (const sig of ["SIGTERM", "SIGHUP"]) {
840
+ process.on(sig, async () => {
841
+ Logger.info(C.gray(`\nreceived ${sig}, saving session and shutting down...`));
842
+ if (_shutdownMemory && _shutdownSessionId && _shutdownSessionPersistence) {
843
+ try {
844
+ await _shutdownMemory.save(_shutdownSessionId);
845
+ Logger.info(C.gray(`session ${_shutdownSessionId} saved on ${sig}`));
846
+ }
847
+ catch (e) {
848
+ Logger.error(C.red(`session save on ${sig} failed: ${asError(e).message}`));
849
+ }
850
+ }
851
+ process.exit(0);
852
+ });
853
+ }
854
+ // Catch unhandled promise rejections (e.g. background summarization)
855
+ process.on("unhandledRejection", (reason) => {
856
+ const err = asError(reason);
857
+ Logger.error(C.red(`unhandled rejection: ${err.message}`));
858
+ if (err.stack)
859
+ Logger.error(C.red(err.stack));
860
+ });
861
+ main().catch((e) => {
862
+ const err = asError(e);
863
+ Logger.error("gro:", err.message);
864
+ if (err.stack)
865
+ Logger.error(err.stack);
866
+ process.exit(1);
867
+ });