@phren/agent 0.1.3 → 0.1.5

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 (42) hide show
  1. package/dist/agent-loop/index.js +214 -0
  2. package/dist/agent-loop/stream.js +124 -0
  3. package/dist/agent-loop/types.js +13 -0
  4. package/dist/agent-loop.js +7 -333
  5. package/dist/commands/info.js +146 -0
  6. package/dist/commands/memory.js +165 -0
  7. package/dist/commands/model.js +138 -0
  8. package/dist/commands/session.js +213 -0
  9. package/dist/commands.js +24 -643
  10. package/dist/index.js +9 -4
  11. package/dist/mcp-client.js +11 -7
  12. package/dist/multi/multi-commands.js +170 -0
  13. package/dist/multi/multi-events.js +81 -0
  14. package/dist/multi/multi-render.js +146 -0
  15. package/dist/multi/pane.js +28 -0
  16. package/dist/multi/tui-multi.js +39 -454
  17. package/dist/permissions/allowlist.js +2 -2
  18. package/dist/providers/anthropic.js +4 -2
  19. package/dist/providers/codex.js +9 -4
  20. package/dist/providers/openai-compat.js +6 -1
  21. package/dist/tools/glob.js +30 -6
  22. package/dist/tui/ansi.js +48 -0
  23. package/dist/tui/components/AgentMessage.js +5 -0
  24. package/dist/tui/components/App.js +68 -0
  25. package/dist/tui/components/Banner.js +44 -0
  26. package/dist/tui/components/ChatMessage.js +23 -0
  27. package/dist/tui/components/InputArea.js +23 -0
  28. package/dist/tui/components/Separator.js +7 -0
  29. package/dist/tui/components/StatusBar.js +25 -0
  30. package/dist/tui/components/SteerQueue.js +7 -0
  31. package/dist/tui/components/StreamingText.js +5 -0
  32. package/dist/tui/components/ThinkingIndicator.js +26 -0
  33. package/dist/tui/components/ToolCall.js +11 -0
  34. package/dist/tui/components/UserMessage.js +5 -0
  35. package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
  36. package/dist/tui/hooks/useSlashCommands.js +52 -0
  37. package/dist/tui/index.js +5 -0
  38. package/dist/tui/ink-entry.js +287 -0
  39. package/dist/tui/menu-mode.js +86 -0
  40. package/dist/tui/tool-render.js +43 -0
  41. package/dist/tui.js +149 -280
  42. package/package.json +9 -2
@@ -0,0 +1,214 @@
1
+ import { createSpinner, formatTurnHeader, formatToolCall } from "../spinner.js";
2
+ import { shouldPrune, pruneMessages } from "../context/pruner.js";
3
+ import { estimateMessageTokens } from "../context/token-counter.js";
4
+ import { withRetry } from "../providers/retry.js";
5
+ import { checkFlushNeeded } from "../memory/context-flush.js";
6
+ import { injectPlanPrompt, requestPlanApproval } from "../plan.js";
7
+ import { detectLintCommand, detectTestCommand, runPostEditCheck } from "../tools/lint-test.js";
8
+ import { createCheckpoint } from "../checkpoint.js";
9
+ import { createSession } from "./types.js";
10
+ import { consumeStream, executeToolBlocks } from "./stream.js";
11
+ export { createSession };
12
+ export async function runTurn(userInput, session, config, hooks) {
13
+ const { provider, registry, maxTurns, verbose, costTracker } = config;
14
+ let systemPrompt = config.systemPrompt;
15
+ const toolDefs = registry.getDefinitions();
16
+ const spinner = createSpinner();
17
+ const useStream = typeof provider.chatStream === "function";
18
+ const status = hooks?.onStatus ?? ((msg) => process.stderr.write(msg));
19
+ // Plan mode: modify system prompt for first turn
20
+ let planPending = config.plan && session.turns === 0;
21
+ if (planPending) {
22
+ systemPrompt = injectPlanPrompt(systemPrompt);
23
+ }
24
+ // Append user message
25
+ session.messages.push({ role: "user", content: userInput });
26
+ let turnToolCalls = 0;
27
+ const turnStart = session.turns;
28
+ while (session.turns - turnStart < maxTurns) {
29
+ // Budget check
30
+ if (costTracker?.isOverBudget()) {
31
+ status(`\x1b[33m[budget exceeded: ${costTracker.formatCost()}]\x1b[0m\n`);
32
+ break;
33
+ }
34
+ if (verbose && session.turns > turnStart) {
35
+ status(`\n${formatTurnHeader(session.turns + 1, turnToolCalls)}\n`);
36
+ }
37
+ // Check if context flush is needed (one-time per session) — must run before pruning
38
+ const contextLimit = provider.contextWindow ?? 200_000;
39
+ const flushPrompt = checkFlushNeeded(systemPrompt, session.messages, session.flushConfig);
40
+ if (flushPrompt) {
41
+ session.messages.push({ role: "user", content: flushPrompt });
42
+ if (verbose)
43
+ status("[context flush injected]\n");
44
+ }
45
+ // Prune context if approaching limit
46
+ if (shouldPrune(systemPrompt, session.messages, { contextLimit })) {
47
+ const preCount = session.messages.length;
48
+ const preTokens = estimateMessageTokens(session.messages);
49
+ session.messages = pruneMessages(session.messages, { contextLimit, keepRecentTurns: 6 });
50
+ const postCount = session.messages.length;
51
+ const postTokens = estimateMessageTokens(session.messages);
52
+ const reduction = preTokens > 0 ? ((1 - postTokens / preTokens) * 100).toFixed(0) : "0";
53
+ const fmtPre = preTokens >= 1000 ? `${(preTokens / 1000).toFixed(1)}k` : String(preTokens);
54
+ const fmtPost = postTokens >= 1000 ? `${(postTokens / 1000).toFixed(1)}k` : String(postTokens);
55
+ status(`\x1b[2m[context pruned: ${preCount} → ${postCount} messages, ~${fmtPre} → ~${fmtPost} tokens, ${reduction}% reduction]\x1b[0m\n`);
56
+ }
57
+ // For plan mode first turn, pass empty tools so LLM can't call any
58
+ const turnTools = planPending ? [] : toolDefs;
59
+ let assistantContent;
60
+ let stopReason;
61
+ if (useStream) {
62
+ // Streaming path — retry the initial connection (before consuming deltas)
63
+ const stream = await withRetry(async () => provider.chatStream(systemPrompt, session.messages, turnTools), undefined, verbose);
64
+ const result = await consumeStream(stream, costTracker, hooks?.onTextDelta);
65
+ assistantContent = result.content;
66
+ stopReason = result.stop_reason;
67
+ }
68
+ else {
69
+ // Batch path
70
+ spinner.start("Thinking...");
71
+ const response = await withRetry(() => provider.chat(systemPrompt, session.messages, turnTools), undefined, verbose);
72
+ spinner.stop();
73
+ assistantContent = response.content;
74
+ stopReason = response.stop_reason;
75
+ // Track cost from batch response
76
+ if (costTracker && response.usage) {
77
+ costTracker.recordUsage(response.usage.input_tokens, response.usage.output_tokens);
78
+ }
79
+ // Print text blocks (streaming already prints inline)
80
+ for (const block of assistantContent) {
81
+ if (block.type === "text" && block.text) {
82
+ if (hooks?.onTextBlock) {
83
+ hooks.onTextBlock(block.text);
84
+ }
85
+ else {
86
+ process.stdout.write(block.text);
87
+ if (!block.text.endsWith("\n"))
88
+ process.stdout.write("\n");
89
+ }
90
+ }
91
+ }
92
+ }
93
+ session.messages.push({ role: "assistant", content: assistantContent });
94
+ session.turns++;
95
+ // Show turn cost
96
+ if (verbose && costTracker) {
97
+ status(`\x1b[2m cost: ${costTracker.formatCost()}\x1b[0m\n`);
98
+ }
99
+ // Plan mode gate: after first response, ask for approval
100
+ if (planPending) {
101
+ planPending = false;
102
+ const { approved, feedback } = await requestPlanApproval();
103
+ if (!approved) {
104
+ // Always restore original system prompt on rejection to prevent plan prompt leaking
105
+ systemPrompt = config.systemPrompt;
106
+ const msg = feedback
107
+ ? `The user rejected the plan with feedback: ${feedback}\nPlease revise your plan.`
108
+ : "The user rejected the plan. Task aborted.";
109
+ if (feedback) {
110
+ // Let the LLM revise — add feedback as user message and continue
111
+ session.messages.push({ role: "user", content: msg });
112
+ continue;
113
+ }
114
+ break;
115
+ }
116
+ // Approved — restore original system prompt and continue with tools enabled
117
+ systemPrompt = config.systemPrompt;
118
+ session.messages.push({ role: "user", content: "Plan approved. Proceed with execution." });
119
+ continue;
120
+ }
121
+ // If max_tokens, warn user and inject continuation prompt
122
+ if (stopReason === "max_tokens") {
123
+ status("\x1b[33m[response truncated: max_tokens reached, requesting continuation]\x1b[0m\n");
124
+ session.messages.push({ role: "user", content: "Your response was truncated due to length. Please continue where you left off." });
125
+ continue;
126
+ }
127
+ // If no tool use, we're done
128
+ if (stopReason !== "tool_use")
129
+ break;
130
+ // Execute tool calls with concurrency
131
+ const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use");
132
+ // Log all tool calls upfront
133
+ if (hooks?.onToolStart) {
134
+ for (const block of toolUseBlocks)
135
+ hooks.onToolStart(block.name, block.input, toolUseBlocks.length);
136
+ }
137
+ else {
138
+ for (const block of toolUseBlocks)
139
+ status(formatToolCall(block.name, block.input) + "\n");
140
+ }
141
+ if (!hooks?.onToolStart)
142
+ spinner.start(`Running ${toolUseBlocks.length} tool${toolUseBlocks.length > 1 ? "s" : ""}...`);
143
+ const { results: toolResults, toolCallCount } = await executeToolBlocks(toolUseBlocks, {
144
+ registry, verbose, status, hooks,
145
+ antiPatterns: session.antiPatterns,
146
+ captureState: session.captureState,
147
+ phrenCtx: config.phrenCtx,
148
+ });
149
+ if (!hooks?.onToolStart)
150
+ spinner.stop();
151
+ session.toolCalls += toolCallCount;
152
+ turnToolCalls += toolCallCount;
153
+ // Post-edit lint/test check
154
+ const mutatingTools = new Set(["edit_file", "write_file"]);
155
+ const hasMutation = toolUseBlocks.some(b => mutatingTools.has(b.name));
156
+ if (hasMutation && config.lintTestConfig) {
157
+ const cwd = process.cwd();
158
+ const lintCmd = config.lintTestConfig.lintCmd ?? detectLintCommand(cwd);
159
+ const testCmd = config.lintTestConfig.testCmd ?? detectTestCommand(cwd);
160
+ const lintFailures = [];
161
+ for (const cmd of [lintCmd, testCmd].filter(Boolean)) {
162
+ const check = runPostEditCheck(cmd, cwd);
163
+ if (!check.passed) {
164
+ if (verbose)
165
+ status(`\x1b[33m[post-edit check failed: ${cmd}]\x1b[0m\n`);
166
+ lintFailures.push(`Post-edit check failed (${cmd}):\n${check.output.slice(0, 2000)}`);
167
+ }
168
+ }
169
+ if (lintFailures.length > 0) {
170
+ // Inject as plain text in the tool results user message (not as a fabricated tool_result)
171
+ toolResults.push({
172
+ type: "text",
173
+ text: lintFailures.join("\n\n"),
174
+ });
175
+ }
176
+ }
177
+ // Create checkpoint before mutating tool results are committed to conversation
178
+ if (hasMutation) {
179
+ createCheckpoint(process.cwd(), `turn-${session.turns}`);
180
+ }
181
+ // Add tool results as a user message
182
+ session.messages.push({ role: "user", content: toolResults });
183
+ // Steering input injection (TUI mid-turn input)
184
+ const steer = hooks?.getSteeringInput?.();
185
+ if (steer) {
186
+ session.messages.push({ role: "user", content: steer });
187
+ }
188
+ }
189
+ // Extract text from the last assistant message in this turn
190
+ const lastAssistant = [...session.messages].reverse().find((m) => m.role === "assistant");
191
+ let text = "";
192
+ if (lastAssistant && Array.isArray(lastAssistant.content)) {
193
+ text = lastAssistant.content
194
+ .filter((b) => b.type === "text")
195
+ .map((b) => b.text)
196
+ .join("\n");
197
+ }
198
+ else if (lastAssistant && typeof lastAssistant.content === "string") {
199
+ text = lastAssistant.content;
200
+ }
201
+ return { text, turns: session.turns - turnStart, toolCalls: turnToolCalls };
202
+ }
203
+ export async function runAgent(task, config) {
204
+ const contextLimit = config.provider.contextWindow ?? 200_000;
205
+ const session = createSession(contextLimit);
206
+ const result = await runTurn(task, session, config, config.hooks);
207
+ return {
208
+ finalText: result.text,
209
+ turns: result.turns,
210
+ toolCalls: result.toolCalls,
211
+ totalCost: config.costTracker?.formatCost(),
212
+ messages: session.messages,
213
+ };
214
+ }
@@ -0,0 +1,124 @@
1
+ import { searchErrorRecovery } from "../memory/error-recovery.js";
2
+ import { analyzeAndCapture } from "../memory/auto-capture.js";
3
+ const MAX_TOOL_CONCURRENCY = 5;
4
+ /** Run tool blocks with concurrency limit. Tracks execution duration per tool. */
5
+ export async function runToolsConcurrently(blocks, registry) {
6
+ const results = [];
7
+ for (let i = 0; i < blocks.length; i += MAX_TOOL_CONCURRENCY) {
8
+ const batch = blocks.slice(i, i + MAX_TOOL_CONCURRENCY);
9
+ const batchResults = await Promise.all(batch.map(async (block) => {
10
+ const TOOL_TIMEOUT_MS = 120_000;
11
+ const start = Date.now();
12
+ try {
13
+ let timer;
14
+ const result = await Promise.race([
15
+ registry.execute(block.name, block.input),
16
+ new Promise((_, reject) => {
17
+ timer = setTimeout(() => reject(new Error(`Tool '${block.name}' timed out after ${TOOL_TIMEOUT_MS / 1000}s`)), TOOL_TIMEOUT_MS);
18
+ }),
19
+ ]);
20
+ clearTimeout(timer);
21
+ return { block, output: result.output, is_error: !!result.is_error, durationMs: Date.now() - start };
22
+ }
23
+ catch (err) {
24
+ const msg = err instanceof Error ? err.message : String(err);
25
+ return { block, output: msg, is_error: true, durationMs: Date.now() - start };
26
+ }
27
+ }));
28
+ results.push(...batchResults);
29
+ }
30
+ return results;
31
+ }
32
+ /** Consume a chatStream into ContentBlock[] + stop_reason, streaming text via callback. */
33
+ export async function consumeStream(stream, costTracker, onTextDelta) {
34
+ const content = [];
35
+ let stop_reason = "end_turn";
36
+ let currentText = "";
37
+ // Map block index -> tool state for Anthropic-style index-based IDs
38
+ const toolsByIndex = new Map();
39
+ for await (const delta of stream) {
40
+ if (delta.type === "text_delta") {
41
+ (onTextDelta ?? process.stdout.write.bind(process.stdout))(delta.text);
42
+ currentText += delta.text;
43
+ }
44
+ else if (delta.type === "tool_use_start") {
45
+ // Flush accumulated text
46
+ if (currentText) {
47
+ content.push({ type: "text", text: currentText });
48
+ currentText = "";
49
+ }
50
+ toolsByIndex.set(delta.id, { id: delta.id, name: delta.name, jsonParts: [] });
51
+ }
52
+ else if (delta.type === "tool_use_delta") {
53
+ const tool = toolsByIndex.get(delta.id);
54
+ if (tool)
55
+ tool.jsonParts.push(delta.json);
56
+ }
57
+ else if (delta.type === "tool_use_end") {
58
+ const tool = toolsByIndex.get(delta.id);
59
+ if (tool) {
60
+ const jsonStr = tool.jsonParts.join("");
61
+ let input = {};
62
+ try {
63
+ input = JSON.parse(jsonStr);
64
+ }
65
+ catch {
66
+ process.stderr.write(`\x1b[33m[warning] Malformed tool_use JSON for ${tool.name} (${tool.id}), skipping block\x1b[0m\n`);
67
+ continue;
68
+ }
69
+ content.push({ type: "tool_use", id: tool.id, name: tool.name, input });
70
+ }
71
+ }
72
+ else if (delta.type === "done") {
73
+ stop_reason = delta.stop_reason;
74
+ if (costTracker && delta.usage) {
75
+ costTracker.recordUsage(delta.usage.input_tokens, delta.usage.output_tokens);
76
+ }
77
+ }
78
+ }
79
+ // Flush remaining text
80
+ if (currentText) {
81
+ if (!currentText.endsWith("\n")) {
82
+ (onTextDelta ?? process.stdout.write.bind(process.stdout))("\n");
83
+ }
84
+ content.push({ type: "text", text: currentText });
85
+ }
86
+ return { content, stop_reason };
87
+ }
88
+ /** Execute tool blocks, collect results with error recovery and anti-pattern tracking. */
89
+ export async function executeToolBlocks(toolUseBlocks, ctx) {
90
+ const execResults = await runToolsConcurrently(toolUseBlocks, ctx.registry);
91
+ const results = [];
92
+ let toolCallCount = 0;
93
+ for (const { block, output, is_error, durationMs } of execResults) {
94
+ toolCallCount++;
95
+ let finalOutput = output;
96
+ ctx.antiPatterns.recordAttempt(block.name, block.input, !is_error, output);
97
+ if (is_error && ctx.phrenCtx) {
98
+ try {
99
+ const recovery = await searchErrorRecovery(ctx.phrenCtx, output);
100
+ if (recovery)
101
+ finalOutput += recovery;
102
+ }
103
+ catch { /* best effort */ }
104
+ try {
105
+ await analyzeAndCapture(ctx.phrenCtx, output, ctx.captureState);
106
+ }
107
+ catch { /* best effort */ }
108
+ }
109
+ if (ctx.hooks?.onToolEnd) {
110
+ ctx.hooks.onToolEnd(block.name, block.input, finalOutput, is_error, durationMs);
111
+ }
112
+ else if (ctx.verbose) {
113
+ const preview = finalOutput.slice(0, 200);
114
+ ctx.status(`\x1b[2m ← ${is_error ? "ERROR: " : ""}${preview}${finalOutput.length > 200 ? "..." : ""}\x1b[0m\n`);
115
+ }
116
+ results.push({
117
+ type: "tool_result",
118
+ tool_use_id: block.id,
119
+ content: finalOutput,
120
+ is_error,
121
+ });
122
+ }
123
+ return { results, toolCallCount };
124
+ }
@@ -0,0 +1,13 @@
1
+ import { createCaptureState } from "../memory/auto-capture.js";
2
+ import { AntiPatternTracker } from "../memory/anti-patterns.js";
3
+ import { createFlushConfig } from "../memory/context-flush.js";
4
+ export function createSession(contextLimit) {
5
+ return {
6
+ messages: [],
7
+ turns: 0,
8
+ toolCalls: 0,
9
+ captureState: createCaptureState(),
10
+ antiPatterns: new AntiPatternTracker(),
11
+ flushConfig: createFlushConfig(contextLimit ?? 200_000),
12
+ };
13
+ }