aws-cli-agent 0.4.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.
package/dist/config.js ADDED
@@ -0,0 +1,131 @@
1
+ import fs from 'node:fs';
2
+ import { z } from 'zod';
3
+ import { FILES, PATHS } from './paths.js';
4
+ /**
5
+ * Logging configuration. All three keys are optional in the file; defaults
6
+ * tilt toward "quiet but auditable" — a tool that writes to your AWS account
7
+ * should leave a paper trail by default, but shouldn't be noisy on the
8
+ * console unless you ask.
9
+ */
10
+ const LoggingSchema = z
11
+ .object({
12
+ level: z
13
+ .enum(['silent', 'error', 'warn', 'info', 'debug', 'trace'])
14
+ .default('error'),
15
+ auditLog: z.boolean().default(true),
16
+ reasoningLog: z.boolean().default(false),
17
+ usageLog: z.boolean().default(true),
18
+ })
19
+ // zod v4 requires .default() to receive a fully-typed value, not `{}`.
20
+ // We list every field explicitly here; values must match the inner
21
+ // .default()s above to avoid silently changing defaults.
22
+ .default({
23
+ level: 'error',
24
+ auditLog: true,
25
+ reasoningLog: false,
26
+ usageLog: true,
27
+ });
28
+ /**
29
+ * Bedrock-specific configuration. Only meaningful when `provider = bedrock`;
30
+ * both keys are optional within that.
31
+ */
32
+ const BedrockSchema = z
33
+ .object({
34
+ region: z.string().optional(),
35
+ profile: z.string().optional(),
36
+ })
37
+ .optional();
38
+ export const ConfigSchema = z.object({
39
+ /** Which remote AI provider to call. */
40
+ provider: z
41
+ .enum(['anthropic', 'openai', 'google', 'bedrock'])
42
+ .default('anthropic'),
43
+ /** Model identifier passed to the provider. */
44
+ model: z.string().default('claude-sonnet-4-5-20250929'),
45
+ /**
46
+ * Optional override for the env var name that holds the API key.
47
+ * Ignored when provider = "bedrock" (Bedrock uses the AWS credential chain).
48
+ */
49
+ apiKeyEnv: z.string().optional(),
50
+ /**
51
+ * Bedrock provider settings (region + profile). Only used when
52
+ * provider = "bedrock". See providers.ts for fallback chain.
53
+ */
54
+ bedrock: BedrockSchema,
55
+ /**
56
+ * Default AWS region for AWS CLI commands the agent executes. Used when the
57
+ * user didn't mention a region in the request and history didn't supply one.
58
+ * Overridable per-run with --region. Independent of `bedrock.region`.
59
+ */
60
+ defaultRegion: z.string().optional(),
61
+ /** Max reasoning/tool-use steps before the agent must conclude. */
62
+ maxSteps: z.number().int().min(1).max(50).default(15),
63
+ /** All logging knobs live here — see LoggingSchema for details. */
64
+ logging: LoggingSchema,
65
+ /**
66
+ * Prompt caching. When true, the system prompt + tool definitions (the
67
+ * long-lived prefix sent on every step) are marked cacheable for providers
68
+ * that support it. Cache hits cost ~10% of normal input tokens (Anthropic
69
+ * direct and Bedrock-via-Anthropic). OpenAI auto-caches large prompts and
70
+ * ignores this flag. Google Gemini's caching API isn't supported yet —
71
+ * this flag is silently ignored for that provider. Default true: most
72
+ * users invoke `aca` more than once every 5 minutes, so the cache pays
73
+ * for itself quickly; users running it rarely can disable it to avoid
74
+ * the small cache-write premium on each first call.
75
+ */
76
+ caching: z.boolean().default(true),
77
+ /**
78
+ * When true, reasoning steps are echoed to stderr in real time. Independent
79
+ * of `logging.reasoningLog`: you can have file logging on and console echo
80
+ * off, or vice versa. Overridable per-run with --verbose.
81
+ */
82
+ verbose: z.boolean().default(false),
83
+ /** Auto-approval policy for command/script execution. */
84
+ autoApprove: z
85
+ .object({
86
+ /** Auto-approve read-only AWS commands (describe-*, list-*, get-*, s3 ls). */
87
+ readOnly: z.boolean().default(true),
88
+ /** Auto-approve every command and script. Dangerous. */
89
+ all: z.boolean().default(false),
90
+ })
91
+ .default({ readOnly: true, all: false }),
92
+ /**
93
+ * When true, every AWS CLI command runs in interactive mode (stdio inherited
94
+ * from the parent terminal). This is the persistent equivalent of the
95
+ * `--interactive` / `-i` CLI flag — useful in rare edge cases where the
96
+ * pattern-based auto-detection misses a command that needs a TTY. Almost
97
+ * always you want to leave this unset and rely on either the CLI flag for
98
+ * one-off invocations or the per-tool-call override the agent can set.
99
+ */
100
+ forceInteractive: z.boolean().default(false),
101
+ /** Maximum number of history entries kept in memory. */
102
+ historyLimit: z.number().int().min(0).default(200),
103
+ /**
104
+ * Directory where the user may save generated bash scripts (offered as an
105
+ * alternative to executing them inline). Defaults to
106
+ * $XDG_DATA_HOME/aws-cli-agent/scripts.
107
+ */
108
+ scriptFolder: z.string().optional(),
109
+ });
110
+ export function loadConfig() {
111
+ if (!fs.existsSync(FILES.config)) {
112
+ return ConfigSchema.parse({});
113
+ }
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(fs.readFileSync(FILES.config, 'utf8'));
117
+ }
118
+ catch (err) {
119
+ throw new Error(`Config file at ${FILES.config} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
120
+ }
121
+ return ConfigSchema.parse(raw);
122
+ }
123
+ /** Write a default config file if none exists. Returns the path either way. */
124
+ export function writeDefaultConfig() {
125
+ fs.mkdirSync(PATHS.config, { recursive: true });
126
+ if (!fs.existsSync(FILES.config)) {
127
+ const defaults = ConfigSchema.parse({});
128
+ fs.writeFileSync(FILES.config, JSON.stringify(defaults, null, 2) + '\n');
129
+ }
130
+ return FILES.config;
131
+ }
@@ -0,0 +1,34 @@
1
+ export type HistoryEntry = {
2
+ timestamp: string;
3
+ input: string;
4
+ commands: string[];
5
+ profile: string | null;
6
+ /**
7
+ * Resources is currently not used.
8
+ * Intention: Capture the named resources the agent worked with on each run, keyed by type.
9
+ * So a history entry for "list buckets in my-account" might end up as:
10
+ * json{
11
+ * "input": "list buckets in my-account",
12
+ * "commands": ["aws s3 ls --profile my-account"],
13
+ * "profile": "my-account",
14
+ * "resources": {
15
+ * "account": "my-account"
16
+ * }
17
+ * }
18
+ */
19
+ resources: Record<string, string>;
20
+ success: boolean;
21
+ };
22
+ export declare class History {
23
+ private entries;
24
+ private readonly limit;
25
+ constructor(limit?: number);
26
+ load(): Promise<void>;
27
+ append(entry: HistoryEntry): void;
28
+ /**
29
+ * Simple token-overlap search across input, commands, profile, and resources.
30
+ * Returns highest-scoring entries first, newest as tiebreak.
31
+ */
32
+ search(query: string, limit?: number): HistoryEntry[];
33
+ recent(limit?: number): HistoryEntry[];
34
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import { FILES, PATHS } from './paths.js';
4
+ export class History {
5
+ entries = [];
6
+ limit;
7
+ constructor(limit = 200) {
8
+ this.limit = limit;
9
+ }
10
+ async load() {
11
+ if (!fs.existsSync(FILES.history))
12
+ return;
13
+ const lines = [];
14
+ const rl = readline.createInterface({
15
+ input: fs.createReadStream(FILES.history),
16
+ crlfDelay: Infinity,
17
+ });
18
+ for await (const line of rl) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed)
21
+ continue;
22
+ try {
23
+ lines.push(JSON.parse(trimmed));
24
+ }
25
+ catch {
26
+ // ignore malformed lines so a corrupt entry doesn't break everything
27
+ }
28
+ }
29
+ this.entries = lines.slice(-this.limit);
30
+ }
31
+ append(entry) {
32
+ fs.mkdirSync(PATHS.state, { recursive: true });
33
+ fs.appendFileSync(FILES.history, JSON.stringify(entry) + '\n');
34
+ this.entries.push(entry);
35
+ if (this.entries.length > this.limit) {
36
+ this.entries = this.entries.slice(-this.limit);
37
+ }
38
+ }
39
+ /**
40
+ * Simple token-overlap search across input, commands, profile, and resources.
41
+ * Returns highest-scoring entries first, newest as tiebreak.
42
+ */
43
+ search(query, limit = 10) {
44
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
45
+ if (tokens.length === 0)
46
+ return this.recent(limit);
47
+ const scored = this.entries.map((e, idx) => {
48
+ const hay = [
49
+ e.input,
50
+ ...e.commands,
51
+ e.profile ?? '',
52
+ ...Object.values(e.resources),
53
+ ]
54
+ .join(' ')
55
+ .toLowerCase();
56
+ let score = 0;
57
+ for (const t of tokens)
58
+ if (hay.includes(t))
59
+ score += 1;
60
+ return { entry: e, score, idx };
61
+ });
62
+ return scored
63
+ .filter((s) => s.score > 0)
64
+ .sort((a, b) => b.score - a.score || b.idx - a.idx)
65
+ .slice(0, limit)
66
+ .map((s) => s.entry);
67
+ }
68
+ recent(limit = 10) {
69
+ return this.entries.slice(-limit).reverse();
70
+ }
71
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ main(process.argv).catch((err) => {
4
+ process.stderr.write('Fatal: ' + (err instanceof Error ? err.message : String(err)) + '\n');
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,29 @@
1
+ declare const LEVELS: readonly ["silent", "error", "warn", "info", "debug", "trace"];
2
+ export type LogLevel = (typeof LEVELS)[number];
3
+ /**
4
+ * The general operational logger. Writes ONLY to `general.log`. Never echoes
5
+ * to the console — console output is reserved for:
6
+ *
7
+ * 1. The AWS CLI's verbatim stdout (on stdout).
8
+ * 2. Agent reasoning steps via ReasoningLogger.consoleEcho, gated by the
9
+ * `verbose` config / `--verbose` CLI flag (on stderr).
10
+ * 3. Approval prompts, errors, and the final status line (on stderr,
11
+ * written directly by callers in cli.ts and the execute tools).
12
+ *
13
+ * Everything operational (agent start, exit codes, debug info, warnings)
14
+ * goes exclusively to general.log. To watch it live:
15
+ * tail -f ~/.local/state/aws-cli-agent/general.log
16
+ */
17
+ export declare class Logger {
18
+ private readonly level;
19
+ private readonly fileStream;
20
+ constructor(level?: LogLevel);
21
+ private write;
22
+ error(msg: string, data?: unknown): void;
23
+ warn(msg: string, data?: unknown): void;
24
+ info(msg: string, data?: unknown): void;
25
+ debug(msg: string, data?: unknown): void;
26
+ trace(msg: string, data?: unknown): void;
27
+ close(): void;
28
+ }
29
+ export {};
package/dist/logger.js ADDED
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs';
2
+ import { FILES, PATHS } from './paths.js';
3
+ const LEVELS = ['silent', 'error', 'warn', 'info', 'debug', 'trace'];
4
+ function safeStringify(data) {
5
+ if (typeof data === 'string')
6
+ return data;
7
+ try {
8
+ return JSON.stringify(data, null, 2);
9
+ }
10
+ catch {
11
+ return String(data);
12
+ }
13
+ }
14
+ /**
15
+ * The general operational logger. Writes ONLY to `general.log`. Never echoes
16
+ * to the console — console output is reserved for:
17
+ *
18
+ * 1. The AWS CLI's verbatim stdout (on stdout).
19
+ * 2. Agent reasoning steps via ReasoningLogger.consoleEcho, gated by the
20
+ * `verbose` config / `--verbose` CLI flag (on stderr).
21
+ * 3. Approval prompts, errors, and the final status line (on stderr,
22
+ * written directly by callers in cli.ts and the execute tools).
23
+ *
24
+ * Everything operational (agent start, exit codes, debug info, warnings)
25
+ * goes exclusively to general.log. To watch it live:
26
+ * tail -f ~/.local/state/aws-cli-agent/general.log
27
+ */
28
+ export class Logger {
29
+ level;
30
+ fileStream;
31
+ constructor(level = 'error') {
32
+ this.level = LEVELS.indexOf(level);
33
+ if (level !== 'silent') {
34
+ fs.mkdirSync(PATHS.state, { recursive: true });
35
+ this.fileStream = fs.createWriteStream(FILES.log, { flags: 'a' });
36
+ }
37
+ else {
38
+ this.fileStream = null;
39
+ }
40
+ }
41
+ write(lvl, msg, data) {
42
+ const lvlIdx = LEVELS.indexOf(lvl);
43
+ if (lvlIdx === 0 || lvlIdx > this.level)
44
+ return;
45
+ const ts = new Date().toISOString();
46
+ const dataStr = data !== undefined ? ' ' + safeStringify(data) : '';
47
+ const line = `[${ts}] ${lvl.toUpperCase()} ${msg}${dataStr}\n`;
48
+ this.fileStream?.write(line);
49
+ }
50
+ error(msg, data) {
51
+ this.write('error', msg, data);
52
+ }
53
+ warn(msg, data) {
54
+ this.write('warn', msg, data);
55
+ }
56
+ info(msg, data) {
57
+ this.write('info', msg, data);
58
+ }
59
+ debug(msg, data) {
60
+ this.write('debug', msg, data);
61
+ }
62
+ trace(msg, data) {
63
+ this.write('trace', msg, data);
64
+ }
65
+ close() {
66
+ this.fileStream?.end();
67
+ }
68
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Directories aws-cli-agent uses:
3
+ *
4
+ * config — user config (`~/.config/aws-cli-agent`)
5
+ * state — history + logs (`~/.local/state/aws-cli-agent`)
6
+ * data — user-curated artifacts: default location for saved scripts
7
+ * (`~/.local/share/aws-cli-agent`). Only created on demand.
8
+ */
9
+ export declare const PATHS: {
10
+ readonly config: string;
11
+ readonly state: string;
12
+ readonly data: string;
13
+ };
14
+ export declare const FILES: {
15
+ readonly config: string;
16
+ readonly history: string;
17
+ readonly log: string;
18
+ readonly audit: string;
19
+ readonly reasoning: string;
20
+ readonly usage: string;
21
+ };
22
+ /** Default location for user-saved bash scripts. */
23
+ export declare const DEFAULT_SCRIPT_FOLDER: string;
package/dist/paths.js ADDED
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ /**
4
+ * Resolve an XDG path strictly per the Base Directory Specification.
5
+ * Falls back to the canonical default if the env var is unset OR empty.
6
+ * (env-paths is not used here because it diverges from XDG on macOS.)
7
+ */
8
+ function xdg(envVar, fallback) {
9
+ const v = process.env[envVar];
10
+ return v && v.length > 0 ? v : fallback;
11
+ }
12
+ const home = os.homedir();
13
+ const APP = 'aws-cli-agent';
14
+ /**
15
+ * Directories aws-cli-agent uses:
16
+ *
17
+ * config — user config (`~/.config/aws-cli-agent`)
18
+ * state — history + logs (`~/.local/state/aws-cli-agent`)
19
+ * data — user-curated artifacts: default location for saved scripts
20
+ * (`~/.local/share/aws-cli-agent`). Only created on demand.
21
+ */
22
+ export const PATHS = {
23
+ config: path.join(xdg('XDG_CONFIG_HOME', path.join(home, '.config')), APP),
24
+ state: path.join(xdg('XDG_STATE_HOME', path.join(home, '.local', 'state')), APP),
25
+ data: path.join(xdg('XDG_DATA_HOME', path.join(home, '.local', 'share')), APP),
26
+ };
27
+ export const FILES = {
28
+ config: path.join(PATHS.config, 'config.json'),
29
+ history: path.join(PATHS.state, 'history.jsonl'),
30
+ log: path.join(PATHS.state, 'general.log'),
31
+ audit: path.join(PATHS.state, 'audit.log'),
32
+ reasoning: path.join(PATHS.state, 'reasoning.log'),
33
+ usage: path.join(PATHS.state, 'usage.log'),
34
+ };
35
+ /** Default location for user-saved bash scripts. */
36
+ export const DEFAULT_SCRIPT_FOLDER = path.join(PATHS.data, 'scripts');
@@ -0,0 +1,16 @@
1
+ import type { LanguageModel } from 'ai';
2
+ import type { Config } from './config.js';
3
+ /**
4
+ * Build a LanguageModel from config.
5
+ *
6
+ * For anthropic / openai / google: API keys are read from environment
7
+ * variables only — never from the config file — to keep secrets out of disk
8
+ * state we don't own.
9
+ *
10
+ * For bedrock: no API key is needed. Authentication uses the standard AWS
11
+ * credential chain (env vars, AWS_PROFILE, ~/.aws/credentials, SSO, IMDS,
12
+ * container roles). Optionally `bedrockProfile` pins a specific named profile,
13
+ * which is useful when the account hosting Bedrock model access is different
14
+ * from the accounts the agent operates against.
15
+ */
16
+ export declare function createModel(config: Config): LanguageModel;
@@ -0,0 +1,59 @@
1
+ import { createAnthropic } from '@ai-sdk/anthropic';
2
+ import { createOpenAI } from '@ai-sdk/openai';
3
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
4
+ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
5
+ import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
6
+ const DEFAULT_KEY_ENV = {
7
+ anthropic: 'ANTHROPIC_API_KEY',
8
+ openai: 'OPENAI_API_KEY',
9
+ google: 'GOOGLE_GENERATIVE_AI_API_KEY',
10
+ };
11
+ /**
12
+ * Build a LanguageModel from config.
13
+ *
14
+ * For anthropic / openai / google: API keys are read from environment
15
+ * variables only — never from the config file — to keep secrets out of disk
16
+ * state we don't own.
17
+ *
18
+ * For bedrock: no API key is needed. Authentication uses the standard AWS
19
+ * credential chain (env vars, AWS_PROFILE, ~/.aws/credentials, SSO, IMDS,
20
+ * container roles). Optionally `bedrockProfile` pins a specific named profile,
21
+ * which is useful when the account hosting Bedrock model access is different
22
+ * from the accounts the agent operates against.
23
+ */
24
+ export function createModel(config) {
25
+ switch (config.provider) {
26
+ case 'anthropic': {
27
+ const apiKey = requireKey(config, 'anthropic');
28
+ return createAnthropic({ apiKey })(config.model);
29
+ }
30
+ case 'openai': {
31
+ const apiKey = requireKey(config, 'openai');
32
+ return createOpenAI({ apiKey })(config.model);
33
+ }
34
+ case 'google': {
35
+ const apiKey = requireKey(config, 'google');
36
+ return createGoogleGenerativeAI({ apiKey })(config.model);
37
+ }
38
+ case 'bedrock': {
39
+ const region = config.bedrock?.region ??
40
+ process.env.AWS_REGION ??
41
+ process.env.AWS_DEFAULT_REGION;
42
+ if (!region) {
43
+ throw new Error('Bedrock requires a region. Set "bedrock.region" in config or AWS_REGION env var.');
44
+ }
45
+ const credentialProvider = config.bedrock?.profile
46
+ ? fromNodeProviderChain({ profile: config.bedrock.profile })
47
+ : fromNodeProviderChain();
48
+ return createAmazonBedrock({ region, credentialProvider })(config.model);
49
+ }
50
+ }
51
+ }
52
+ function requireKey(config, provider) {
53
+ const envName = config.apiKeyEnv ?? DEFAULT_KEY_ENV[provider];
54
+ const apiKey = process.env[envName];
55
+ if (!apiKey) {
56
+ throw new Error(`Missing API key. Set environment variable ${envName} (or override "apiKeyEnv" in config).`);
57
+ }
58
+ return apiKey;
59
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reasoning log: human-readable record of the agent's per-step reasoning text
3
+ * and tool selections.
4
+ *
5
+ * Design note: in v0.3.0 we switched the agent loop from `generateText` to
6
+ * `streamText` so that reasoning text chunks arrive in stream order, BEFORE
7
+ * the model finalizes its tool call. The streaming loop in agent.ts is now
8
+ * the orchestrator: it decides when to print reasoning (after each step's
9
+ * text-end event, before the tool-call event). This class is a dumb printer
10
+ * — no state buffering, no step counter — so the streaming loop has clean
11
+ * control over ordering.
12
+ *
13
+ * The file log (reasoning.log) is written separately at the end of each
14
+ * step with the full text + tool calls in one block.
15
+ */
16
+ export declare class ReasoningLogger {
17
+ private readonly stream;
18
+ private readonly consoleEcho;
19
+ constructor(opts: {
20
+ enabled: boolean;
21
+ consoleEcho: boolean;
22
+ });
23
+ /** Whether the agent should call the echo* methods at all. Lets the
24
+ * streaming loop skip work when verbose is off. */
25
+ get echoEnabled(): boolean;
26
+ /** Mark the start of a new agent run with the user's input. */
27
+ beginRun(input: string): void;
28
+ /**
29
+ * Echo a step's reasoning text to the console. Called by the streaming
30
+ * loop AFTER the model's text stream ends for that step, BEFORE the
31
+ * tool-call event for that same step. The reasoning therefore appears
32
+ * above its associated tool call line — and above any approval prompt
33
+ * the tool's execute() might display.
34
+ */
35
+ echoReasoning(step: number, text: string): void;
36
+ /**
37
+ * Echo a step's tool call line to the console. Called by the streaming
38
+ * loop on the tool-call event, AFTER any reasoning for that step has
39
+ * been echoed, BEFORE the SDK invokes the tool's execute() function.
40
+ */
41
+ echoToolCall(step: number, toolName: string, toolInput: unknown): void;
42
+ /**
43
+ * Append a completed step to the reasoning file. Called by the streaming
44
+ * loop on the finish-step event. Ordering doesn't matter for the file —
45
+ * it's append-only and read post-hoc — so we batch the whole step's
46
+ * data into one block here.
47
+ */
48
+ logStepToFile(args: {
49
+ step: number;
50
+ reasoning: string;
51
+ toolCalls: Array<{
52
+ toolName: string;
53
+ args: unknown;
54
+ }>;
55
+ finishReason?: string;
56
+ }): void;
57
+ private writeFile;
58
+ close(): void;
59
+ }
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import chalk from 'chalk';
3
+ import { FILES, PATHS } from './paths.js';
4
+ /**
5
+ * Reasoning log: human-readable record of the agent's per-step reasoning text
6
+ * and tool selections.
7
+ *
8
+ * Design note: in v0.3.0 we switched the agent loop from `generateText` to
9
+ * `streamText` so that reasoning text chunks arrive in stream order, BEFORE
10
+ * the model finalizes its tool call. The streaming loop in agent.ts is now
11
+ * the orchestrator: it decides when to print reasoning (after each step's
12
+ * text-end event, before the tool-call event). This class is a dumb printer
13
+ * — no state buffering, no step counter — so the streaming loop has clean
14
+ * control over ordering.
15
+ *
16
+ * The file log (reasoning.log) is written separately at the end of each
17
+ * step with the full text + tool calls in one block.
18
+ */
19
+ export class ReasoningLogger {
20
+ stream;
21
+ consoleEcho;
22
+ constructor(opts) {
23
+ this.consoleEcho = opts.consoleEcho;
24
+ if (!opts.enabled) {
25
+ this.stream = null;
26
+ return;
27
+ }
28
+ fs.mkdirSync(PATHS.state, { recursive: true });
29
+ this.stream = fs.createWriteStream(FILES.reasoning, { flags: 'a' });
30
+ }
31
+ /** Whether the agent should call the echo* methods at all. Lets the
32
+ * streaming loop skip work when verbose is off. */
33
+ get echoEnabled() {
34
+ return this.consoleEcho;
35
+ }
36
+ /** Mark the start of a new agent run with the user's input. */
37
+ beginRun(input) {
38
+ const ts = new Date().toISOString();
39
+ const block = `\n========== run @ ${ts} ==========\n` +
40
+ `input: ${input}\n`;
41
+ this.writeFile(block);
42
+ // Don't echo the run header to console — already visible from the prompt.
43
+ }
44
+ /**
45
+ * Echo a step's reasoning text to the console. Called by the streaming
46
+ * loop AFTER the model's text stream ends for that step, BEFORE the
47
+ * tool-call event for that same step. The reasoning therefore appears
48
+ * above its associated tool call line — and above any approval prompt
49
+ * the tool's execute() might display.
50
+ */
51
+ echoReasoning(step, text) {
52
+ if (!this.consoleEcho)
53
+ return;
54
+ if (text.trim().length === 0)
55
+ return;
56
+ process.stderr.write(chalk.dim(`[${pad2(step)}] `) + text.trim() + '\n');
57
+ }
58
+ /**
59
+ * Echo a step's tool call line to the console. Called by the streaming
60
+ * loop on the tool-call event, AFTER any reasoning for that step has
61
+ * been echoed, BEFORE the SDK invokes the tool's execute() function.
62
+ */
63
+ echoToolCall(step, toolName, toolInput) {
64
+ if (!this.consoleEcho)
65
+ return;
66
+ const argStr = safeStringify(toolInput, 120);
67
+ process.stderr.write(chalk.dim(`[${pad2(step)}] `) + `tool: ${toolName}(${argStr})\n`);
68
+ }
69
+ /**
70
+ * Append a completed step to the reasoning file. Called by the streaming
71
+ * loop on the finish-step event. Ordering doesn't matter for the file —
72
+ * it's append-only and read post-hoc — so we batch the whole step's
73
+ * data into one block here.
74
+ */
75
+ logStepToFile(args) {
76
+ const ts = new Date().toISOString();
77
+ const lines = [];
78
+ lines.push(`[${ts}] step ${pad2(args.step)} (finish=${args.finishReason ?? 'n/a'})`);
79
+ if (args.reasoning.trim().length > 0) {
80
+ for (const l of args.reasoning.trim().split('\n')) {
81
+ lines.push(` reasoning: ${l}`);
82
+ }
83
+ }
84
+ for (const call of args.toolCalls) {
85
+ const argStr = safeStringify(call.args, 200);
86
+ lines.push(` tool_call: ${call.toolName}(${argStr})`);
87
+ }
88
+ const block = lines.join('\n') + '\n';
89
+ this.writeFile(block);
90
+ }
91
+ writeFile(text) {
92
+ if (!this.stream)
93
+ return;
94
+ try {
95
+ this.stream.write(text);
96
+ }
97
+ catch {
98
+ // Same philosophy as audit: never crash the agent on log failure.
99
+ }
100
+ }
101
+ close() {
102
+ this.stream?.end();
103
+ }
104
+ }
105
+ function safeStringify(v, limit) {
106
+ let s;
107
+ try {
108
+ s = JSON.stringify(v);
109
+ }
110
+ catch {
111
+ s = String(v);
112
+ }
113
+ if (s.length <= limit)
114
+ return s;
115
+ return s.slice(0, limit) + '…';
116
+ }
117
+ /**
118
+ * Format an integer as a 2-digit zero-padded string. Used so step labels
119
+ * line up visually: "step 01" through "step 09" align with "step 10" and
120
+ * beyond. For numbers ≥ 100 the value is emitted as-is (we don't truncate),
121
+ * so a runaway 150-step run is still readable, just unaligned.
122
+ */
123
+ function pad2(n) {
124
+ return n.toString().padStart(2, '0');
125
+ }