ikarmic-forge 0.0.0-stage → 4.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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/acp/server.d.ts +53 -0
- package/dist/acp/server.d.ts.map +1 -0
- package/dist/acp/server.js +235 -0
- package/dist/acp/server.js.map +1 -0
- package/dist/app.d.ts +22 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +159 -0
- package/dist/app.js.map +1 -0
- package/dist/bin/forge.d.ts +3 -0
- package/dist/bin/forge.d.ts.map +1 -0
- package/dist/bin/forge.js +747 -0
- package/dist/bin/forge.js.map +1 -0
- package/dist/chat-files.d.ts +37 -0
- package/dist/chat-files.d.ts.map +1 -0
- package/dist/chat-files.js +152 -0
- package/dist/chat-files.js.map +1 -0
- package/dist/commands/acp.d.ts +4 -0
- package/dist/commands/acp.d.ts.map +1 -0
- package/dist/commands/acp.js +36 -0
- package/dist/commands/acp.js.map +1 -0
- package/dist/commands/ai-comments.d.ts +9 -0
- package/dist/commands/ai-comments.d.ts.map +1 -0
- package/dist/commands/ai-comments.js +43 -0
- package/dist/commands/ai-comments.js.map +1 -0
- package/dist/commands/architect.d.ts +9 -0
- package/dist/commands/architect.d.ts.map +1 -0
- package/dist/commands/architect.js +27 -0
- package/dist/commands/architect.js.map +1 -0
- package/dist/commands/checkpoint.d.ts +10 -0
- package/dist/commands/checkpoint.d.ts.map +1 -0
- package/dist/commands/checkpoint.js +168 -0
- package/dist/commands/checkpoint.js.map +1 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +180 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/diff.d.ts +29 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +90 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/hook.d.ts +10 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/hook.js +121 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/mcp.d.ts +10 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +110 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/orchestrator.d.ts +22 -0
- package/dist/commands/orchestrator.d.ts.map +1 -0
- package/dist/commands/orchestrator.js +113 -0
- package/dist/commands/orchestrator.js.map +1 -0
- package/dist/commands/plugin.d.ts +3 -0
- package/dist/commands/plugin.d.ts.map +1 -0
- package/dist/commands/plugin.js +62 -0
- package/dist/commands/plugin.js.map +1 -0
- package/dist/commands/policy.d.ts +2 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +21 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/skill.d.ts +13 -0
- package/dist/commands/skill.d.ts.map +1 -0
- package/dist/commands/skill.js +194 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/spec.d.ts +13 -0
- package/dist/commands/spec.d.ts.map +1 -0
- package/dist/commands/spec.js +76 -0
- package/dist/commands/spec.js.map +1 -0
- package/dist/commands/watch.d.ts +15 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +83 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/commands/worktree.d.ts +22 -0
- package/dist/commands/worktree.d.ts.map +1 -0
- package/dist/commands/worktree.js +187 -0
- package/dist/commands/worktree.js.map +1 -0
- package/dist/components/Approval.d.ts +9 -0
- package/dist/components/Approval.d.ts.map +1 -0
- package/dist/components/Approval.js +37 -0
- package/dist/components/Approval.js.map +1 -0
- package/dist/components/Footer.d.ts +22 -0
- package/dist/components/Footer.d.ts.map +1 -0
- package/dist/components/Footer.js +59 -0
- package/dist/components/Footer.js.map +1 -0
- package/dist/components/Header.d.ts +11 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +23 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Spinner.d.ts +3 -0
- package/dist/components/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner.js +8 -0
- package/dist/components/Spinner.js.map +1 -0
- package/dist/components/Stream.d.ts +10 -0
- package/dist/components/Stream.d.ts.map +1 -0
- package/dist/components/Stream.js +13 -0
- package/dist/components/Stream.js.map +1 -0
- package/dist/components/ToolEvent.d.ts +13 -0
- package/dist/components/ToolEvent.d.ts.map +1 -0
- package/dist/components/ToolEvent.js +54 -0
- package/dist/components/ToolEvent.js.map +1 -0
- package/dist/credential-store.d.ts +8 -0
- package/dist/credential-store.d.ts.map +1 -0
- package/dist/credential-store.js +37 -0
- package/dist/credential-store.js.map +1 -0
- package/dist/git-workflow.d.ts +16 -0
- package/dist/git-workflow.d.ts.map +1 -0
- package/dist/git-workflow.js +38 -0
- package/dist/git-workflow.js.map +1 -0
- package/dist/mcp-loader.d.ts +25 -0
- package/dist/mcp-loader.d.ts.map +1 -0
- package/dist/mcp-loader.js +99 -0
- package/dist/mcp-loader.js.map +1 -0
- package/dist/notify.d.ts +12 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +47 -0
- package/dist/notify.js.map +1 -0
- package/dist/options.d.ts +407 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +792 -0
- package/dist/options.js.map +1 -0
- package/dist/repl-commands.d.ts +53 -0
- package/dist/repl-commands.d.ts.map +1 -0
- package/dist/repl-commands.js +141 -0
- package/dist/repl-commands.js.map +1 -0
- package/dist/repl.d.ts +52 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +1174 -0
- package/dist/repl.js.map +1 -0
- package/dist/runner.d.ts +64 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +61 -0
- package/dist/runner.js.map +1 -0
- package/dist/session-checkpoints.d.ts +6 -0
- package/dist/session-checkpoints.d.ts.map +1 -0
- package/dist/session-checkpoints.js +25 -0
- package/dist/session-checkpoints.js.map +1 -0
- package/dist/specialists-loader.d.ts +22 -0
- package/dist/specialists-loader.d.ts.map +1 -0
- package/dist/specialists-loader.js +38 -0
- package/dist/specialists-loader.js.map +1 -0
- package/dist/tool-display.d.ts +19 -0
- package/dist/tool-display.d.ts.map +1 -0
- package/dist/tool-display.js +57 -0
- package/dist/tool-display.js.map +1 -0
- package/package.json +70 -3
package/dist/repl.js
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { Agent, formatSteeringLine, defaultSessionDir, saveSession, SPEC_READONLY_TOOLS, SpecStore, splitSpecSections, slugifyTaskForSpecId, } from '@ikarmic/forge-core';
|
|
5
|
+
import { rm } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { buildRepoMap, changedFiles, createExploreEditRouter, lintFile, commitFiles, diffForFiles, generateCommitMessage, isGitRepo, undoLastForgeCommit, } from '@ikarmic/forge-core';
|
|
8
|
+
import { Approval } from './components/Approval.js';
|
|
9
|
+
import { ChatFiles, stripChatFiles } from './chat-files.js';
|
|
10
|
+
import { formatCommitLine, gitCommitOptions } from './git-workflow.js';
|
|
11
|
+
import { OK_TASK, describeModel, estimateContextTokens, fixTask, formatModels, formatTokens, parseSlashCommand, runContextNote, runShell, switchModel, } from './repl-commands.js';
|
|
12
|
+
import { Header } from './components/Header.js';
|
|
13
|
+
import { Spinner } from './components/Spinner.js';
|
|
14
|
+
import { sessionCheckpointStore, sessionCheckpointsConfig } from './session-checkpoints.js';
|
|
15
|
+
import { getUnifiedDiffText, summarizeResult as summarizeToolResult } from './tool-display.js';
|
|
16
|
+
export function Repl({ baseConfig, model: initialModel, onExit, sessionId, initialHistory, initialLabel, sessionDir, display = {}, gitWorkflow = null, gitOptions = { noAttribute: false, gitCommitVerify: false }, commitModel, notify, aliases, }) {
|
|
17
|
+
const [messages, setMessages] = useState([]);
|
|
18
|
+
const [input, setInput] = useState('');
|
|
19
|
+
const [running, setRunning] = useState(false);
|
|
20
|
+
const [pendingApproval, setPendingApproval] = useState(null);
|
|
21
|
+
const [status, setStatus] = useState('idle');
|
|
22
|
+
const [inputTokens, setInputTokens] = useState(0);
|
|
23
|
+
const [outputTokens, setOutputTokens] = useState(0);
|
|
24
|
+
const [iter, setIter] = useState(0);
|
|
25
|
+
const [maxIter, setMaxIter] = useState(baseConfig.maxIterations ?? 50);
|
|
26
|
+
const [currentSessionId, setCurrentSessionId] = useState(sessionId);
|
|
27
|
+
const [currentLabel, setCurrentLabel] = useState(initialLabel);
|
|
28
|
+
const [costUsd, setCostUsd] = useState(undefined);
|
|
29
|
+
const [redactedCount, setRedactedCount] = useState(0);
|
|
30
|
+
const [cacheHits, setCacheHits] = useState(0);
|
|
31
|
+
const [cacheMisses, setCacheMisses] = useState(0);
|
|
32
|
+
/** When set, the user is reviewing a proposed plan. */
|
|
33
|
+
const [pendingPlan, setPendingPlan] = useState(null);
|
|
34
|
+
/** Task that the plan was generated FOR: re-runs with this when approved. */
|
|
35
|
+
const [pendingPlanTask, setPendingPlanTask] = useState(null);
|
|
36
|
+
/** When set, the user is reviewing a proposed spec (raw, unsplit text). */
|
|
37
|
+
const [pendingSpec, setPendingSpec] = useState(null);
|
|
38
|
+
/** Task that the spec was generated FOR: used as the spec's `task` field on approve. */
|
|
39
|
+
const [pendingSpecTask, setPendingSpecTask] = useState(null);
|
|
40
|
+
// History sent to the agent: seeded from the resumed session if any,
|
|
41
|
+
// otherwise starts empty (the agent creates the system prompt on turn 1).
|
|
42
|
+
const historyRef = useRef(initialHistory ?? []);
|
|
43
|
+
const agentConfigRef = useRef(baseConfig);
|
|
44
|
+
/** The model in use; `/model` changes it for the rest of the session. */
|
|
45
|
+
const [model, setModel] = useState(initialModel);
|
|
46
|
+
/** Files attached with /add and /read-only; sent with every turn. */
|
|
47
|
+
const chatFilesRef = useRef(new ChatFiles(baseConfig.cwd ?? process.cwd()));
|
|
48
|
+
/** Output of /run commands, handed to the model with the next turn. */
|
|
49
|
+
const pendingNotesRef = useRef([]);
|
|
50
|
+
/** `/weak-model`: cheap model for read-only exploration steps and commit messages. */
|
|
51
|
+
const [weakModel, setWeakModel] = useState(undefined);
|
|
52
|
+
// Per-turn AbortController so Escape can cancel a running agent turn (see
|
|
53
|
+
// the useInput handler below). Stored in a ref so it survives re-renders
|
|
54
|
+
// across the async run and stays reachable from the key handler.
|
|
55
|
+
const abortControllerRef = useRef(null);
|
|
56
|
+
// Aggregate token + cost counters across turns so we can write them into
|
|
57
|
+
// the session on save.
|
|
58
|
+
const tokensRef = useRef({ input: 0, output: 0 });
|
|
59
|
+
const costRef = useRef(0);
|
|
60
|
+
const redactionsRef = useRef(0);
|
|
61
|
+
const handleSubmit = useCallback(async (raw) => {
|
|
62
|
+
const task = raw.trim();
|
|
63
|
+
if (!task || running)
|
|
64
|
+
return;
|
|
65
|
+
setInput('');
|
|
66
|
+
// Slash commands
|
|
67
|
+
if (task === '/exit' || task === '/quit') {
|
|
68
|
+
onExit();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (task === '/clear') {
|
|
72
|
+
setMessages([]);
|
|
73
|
+
historyRef.current = [];
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (task.startsWith('/save')) {
|
|
77
|
+
const label = task.replace(/^\/save\s*/, '').trim();
|
|
78
|
+
if (label)
|
|
79
|
+
setCurrentLabel(label);
|
|
80
|
+
const savedPath = await persistSession({
|
|
81
|
+
id: currentSessionId,
|
|
82
|
+
createdAt: new Date().toISOString(),
|
|
83
|
+
updatedAt: new Date().toISOString(),
|
|
84
|
+
...(label || currentLabel ? { label: label || currentLabel } : {}),
|
|
85
|
+
cwd: process.cwd(),
|
|
86
|
+
model,
|
|
87
|
+
messages: historyRef.current,
|
|
88
|
+
totalInputTokens: tokensRef.current.input,
|
|
89
|
+
totalOutputTokens: tokensRef.current.output,
|
|
90
|
+
totalCostUsd: costRef.current,
|
|
91
|
+
costKnown: costUsd !== undefined,
|
|
92
|
+
});
|
|
93
|
+
setMessages((m) => [
|
|
94
|
+
...m,
|
|
95
|
+
{
|
|
96
|
+
kind: 'status',
|
|
97
|
+
id: cryptoId(),
|
|
98
|
+
text: savedPath
|
|
99
|
+
? `saved session ${currentSessionId}${label ? ` [${label}]` : ''} → ${savedPath}`
|
|
100
|
+
: 'save failed: no completed turns yet',
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (task === '/help') {
|
|
106
|
+
setMessages((m) => [
|
|
107
|
+
...m,
|
|
108
|
+
{
|
|
109
|
+
kind: 'status',
|
|
110
|
+
id: cryptoId(),
|
|
111
|
+
text: 'Slash commands: /help /clear /reset /save [label] /plan <task> /spec <task> /compact /rewind /ok /model /weak-model /models /add /read-only /drop /ls /run /test /lint /tokens /settings /commit /undo /diff /map /map-refresh /exit /quit.\n' +
|
|
112
|
+
'/commit commits all pending changes with a drafted message; /undo reverts the last Forge commit; /diff shows uncommitted changes.\n' +
|
|
113
|
+
'/model [id|alias|provider:model] switches (or shows) the model; /weak-model sets the cheap model; /models [query] lists aliases and known models.\n' +
|
|
114
|
+
'/add <files> and /read-only <files> attach files to every turn; /drop [files] detaches; /ls lists them. /run <cmd> (or !<cmd>) runs a command and shows the model its output on the next turn; /test [cmd] and /lint [files] send failures to the model to fix.\n' +
|
|
115
|
+
'/ok says "go ahead"; /reset detaches files and clears the conversation; /tokens shows context use; /settings shows the effective settings.\n' +
|
|
116
|
+
'/map shows the repo map (--map-tokens sets the size sent to the model); /map-refresh re-parses every file first.\n' +
|
|
117
|
+
`Otherwise type a task and press Enter.\n` +
|
|
118
|
+
`Session: ${currentSessionId} (auto-saved after each turn).\n` +
|
|
119
|
+
'Plan review: when a plan is proposed, type a (approve), e (edit), or c (cancel).\n' +
|
|
120
|
+
'Spec review: same a/e/c keys: approve writes requirements.md/design.md/tasks.md to .forge/specs/<id>/.',
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (task === '/id') {
|
|
126
|
+
setMessages((m) => [
|
|
127
|
+
...m,
|
|
128
|
+
{
|
|
129
|
+
kind: 'status',
|
|
130
|
+
id: cryptoId(),
|
|
131
|
+
text: `session id: ${currentSessionId}: pick up later with: forge --resume ${currentSessionId}`,
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// /rewind: list the agent's per-turn checkpoints so the user can
|
|
137
|
+
// restore a file's pre-tool state. Pair with the `forge checkpoint …`
|
|
138
|
+
// CLI for non-interactive restore (which requires --yes). Within
|
|
139
|
+
// the REPL we just list; the next iteration of this command will
|
|
140
|
+
// accept an `<id>` argument and call `store.restore()` directly.
|
|
141
|
+
if (task === '/rewind' || task.startsWith('/rewind ')) {
|
|
142
|
+
const arg = task.replace(/^\/rewind\s*/, '').trim();
|
|
143
|
+
const store = sessionCheckpointStore(sessionDir, currentSessionId, agentConfigRef.current.cwd ?? process.cwd());
|
|
144
|
+
const items = await store.list();
|
|
145
|
+
if (items.length === 0) {
|
|
146
|
+
setMessages((m) => [
|
|
147
|
+
...m,
|
|
148
|
+
{ kind: 'status', id: cryptoId(), text: 'no checkpoints yet (they appear after the first file-mutating tool call)' },
|
|
149
|
+
]);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!arg) {
|
|
153
|
+
const lines = items.slice(-10).map((cp) => ` ${cp.id} turn ${cp.turnIndex} ${cp.description} (${cp.fileCount} files)`);
|
|
154
|
+
setMessages((m) => [
|
|
155
|
+
...m,
|
|
156
|
+
{
|
|
157
|
+
kind: 'status',
|
|
158
|
+
id: cryptoId(),
|
|
159
|
+
text: `checkpoints (most recent 10 of ${items.length}):\n` +
|
|
160
|
+
lines.join('\n') +
|
|
161
|
+
`\nusage: /rewind <id>: restore files to that checkpoint\n` +
|
|
162
|
+
` forge checkpoint restore <id> --yes: same, from another shell`,
|
|
163
|
+
},
|
|
164
|
+
]);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Restore requires explicit confirmation: refuse without it.
|
|
168
|
+
if (arg !== '!') {
|
|
169
|
+
setMessages((m) => [
|
|
170
|
+
...m,
|
|
171
|
+
{
|
|
172
|
+
kind: 'status',
|
|
173
|
+
id: cryptoId(),
|
|
174
|
+
text: `restore requires confirmation. Re-run as: /rewind ${arg} ! (note the trailing '!')`,
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const targetId = task.replace(/^\/rewind\s+/, '').replace(/\s+!\s*$/, '').trim();
|
|
180
|
+
const result = await store.restore(targetId);
|
|
181
|
+
if (!result) {
|
|
182
|
+
setMessages((m) => [
|
|
183
|
+
...m,
|
|
184
|
+
{ kind: 'status', id: cryptoId(), text: `checkpoint "${targetId}" not found` },
|
|
185
|
+
]);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const parts = [];
|
|
189
|
+
if (result.restored.length > 0)
|
|
190
|
+
parts.push(`${result.restored.length} restored`);
|
|
191
|
+
if (result.modified.length > 0)
|
|
192
|
+
parts.push(`${result.modified.length} modified`);
|
|
193
|
+
if (result.removed.length > 0)
|
|
194
|
+
parts.push(`${result.removed.length} removed`);
|
|
195
|
+
setMessages((m) => [
|
|
196
|
+
...m,
|
|
197
|
+
{ kind: 'status', id: cryptoId(), text: `restored ${targetId}: ${parts.join(', ') || 'no changes'}` },
|
|
198
|
+
]);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Plan-mode approval flow.
|
|
202
|
+
if (pendingPlan !== null) {
|
|
203
|
+
const action = task.toLowerCase();
|
|
204
|
+
if (action === 'a' || action === 'approve') {
|
|
205
|
+
const approvedPlan = pendingPlan;
|
|
206
|
+
const approvedTask = pendingPlanTask ?? task;
|
|
207
|
+
setPendingPlan(null);
|
|
208
|
+
setPendingPlanTask(null);
|
|
209
|
+
await runWithPlan(approvedTask, approvedPlan);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (action === 'c' || action === 'cancel') {
|
|
213
|
+
setPendingPlan(null);
|
|
214
|
+
setPendingPlanTask(null);
|
|
215
|
+
setMessages((m) => [
|
|
216
|
+
...m,
|
|
217
|
+
{ kind: 'status', id: cryptoId(), text: 'plan cancelled.' },
|
|
218
|
+
]);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (action === 'e' || action === 'edit') {
|
|
222
|
+
// User wants to edit the plan: the NEXT input they type becomes
|
|
223
|
+
// the edited plan. We signal this with a sentinel value.
|
|
224
|
+
setMessages((m) => [
|
|
225
|
+
...m,
|
|
226
|
+
{ kind: 'status', id: cryptoId(), text: 'enter the edited plan (empty line to cancel):' },
|
|
227
|
+
]);
|
|
228
|
+
setPendingPlan('\u0001edit\u0001');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Sentinel: we're capturing the next input as the edited plan.
|
|
232
|
+
if (pendingPlan === '\u0001edit\u0001') {
|
|
233
|
+
if (!task.trim()) {
|
|
234
|
+
setPendingPlan(null);
|
|
235
|
+
setPendingPlanTask(null);
|
|
236
|
+
setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: 'edit cancelled.' }]);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
setPendingPlan(task);
|
|
240
|
+
setMessages((m) => [
|
|
241
|
+
...m,
|
|
242
|
+
{ kind: 'status', id: cryptoId(), text: 'edited plan saved. approve (a) or cancel (c)?' },
|
|
243
|
+
]);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Unknown action while a real plan is pending.
|
|
247
|
+
setMessages((m) => [
|
|
248
|
+
...m,
|
|
249
|
+
{ kind: 'status', id: cryptoId(), text: 'enter: a (approve), e (edit), c (cancel)' },
|
|
250
|
+
]);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Spec-mode approval flow. Mirrors the plan-mode block above, except
|
|
254
|
+
// "approve" writes the three documents to disk via SpecStore instead
|
|
255
|
+
// of re-running the task: spec approval is terminal, per design
|
|
256
|
+
// decision #5 (no auto-chain into execution). Positioned before the
|
|
257
|
+
// /plan and /spec slash-command checks below: same precedence as
|
|
258
|
+
// pendingPlan above it: so a stray /plan or /spec while a spec
|
|
259
|
+
// review is pending falls through to "enter: a/e/c" instead of
|
|
260
|
+
// silently starting a new run.
|
|
261
|
+
if (pendingSpec !== null) {
|
|
262
|
+
const action = task.toLowerCase();
|
|
263
|
+
if (action === 'a' || action === 'approve') {
|
|
264
|
+
try {
|
|
265
|
+
const sections = splitSpecSections(pendingSpec);
|
|
266
|
+
const specTask = pendingSpecTask ?? task;
|
|
267
|
+
const id = slugifyTaskForSpecId(specTask);
|
|
268
|
+
const store = new SpecStore(process.cwd());
|
|
269
|
+
const summary = await store.approve(id, specTask, sections);
|
|
270
|
+
setPendingSpec(null);
|
|
271
|
+
setPendingSpecTask(null);
|
|
272
|
+
setMessages((m) => [
|
|
273
|
+
...m,
|
|
274
|
+
{
|
|
275
|
+
kind: 'status',
|
|
276
|
+
id: cryptoId(),
|
|
277
|
+
text: `✓ spec approved → ${summary.dir}\n requirements.md, design.md, tasks.md written.`,
|
|
278
|
+
},
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
// Splitting failed (e.g. an edit removed a section header):
|
|
283
|
+
// keep pendingSpec set so the user can fix it with 'e' rather
|
|
284
|
+
// than losing their edit.
|
|
285
|
+
setMessages((m) => [
|
|
286
|
+
...m,
|
|
287
|
+
{
|
|
288
|
+
kind: 'status',
|
|
289
|
+
id: cryptoId(),
|
|
290
|
+
text: `error: ${err.message}\nFix with e (edit) or discard with c (cancel).`,
|
|
291
|
+
isError: true,
|
|
292
|
+
},
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (action === 'c' || action === 'cancel') {
|
|
298
|
+
setPendingSpec(null);
|
|
299
|
+
setPendingSpecTask(null);
|
|
300
|
+
setMessages((m) => [
|
|
301
|
+
...m,
|
|
302
|
+
{ kind: 'status', id: cryptoId(), text: 'spec cancelled: nothing written to disk.' },
|
|
303
|
+
]);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (action === 'e' || action === 'edit') {
|
|
307
|
+
setMessages((m) => [
|
|
308
|
+
...m,
|
|
309
|
+
{ kind: 'status', id: cryptoId(), text: 'enter the edited spec text (empty line to cancel):' },
|
|
310
|
+
]);
|
|
311
|
+
setPendingSpec('\u0001edit\u0001');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (pendingSpec === '\u0001edit\u0001') {
|
|
315
|
+
if (!task.trim()) {
|
|
316
|
+
setPendingSpec(null);
|
|
317
|
+
setPendingSpecTask(null);
|
|
318
|
+
setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: 'edit cancelled.' }]);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
setPendingSpec(task);
|
|
322
|
+
setMessages((m) => [
|
|
323
|
+
...m,
|
|
324
|
+
{ kind: 'status', id: cryptoId(), text: 'edited spec saved. approve (a) or cancel (c)?' },
|
|
325
|
+
]);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
setMessages((m) => [
|
|
329
|
+
...m,
|
|
330
|
+
{ kind: 'status', id: cryptoId(), text: 'enter: a (approve), e (edit), c (cancel)' },
|
|
331
|
+
]);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (task.startsWith('/plan')) {
|
|
335
|
+
const planTask = task.replace(/^\/plan\s*/, '').trim();
|
|
336
|
+
if (!planTask) {
|
|
337
|
+
setMessages((m) => [
|
|
338
|
+
...m,
|
|
339
|
+
{ kind: 'status', id: cryptoId(), text: 'usage: /plan <task to plan>' },
|
|
340
|
+
]);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await runInPlanMode(planTask);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (task.startsWith('/spec')) {
|
|
347
|
+
const specTask = task.replace(/^\/spec\s*/, '').trim();
|
|
348
|
+
if (!specTask) {
|
|
349
|
+
setMessages((m) => [
|
|
350
|
+
...m,
|
|
351
|
+
{ kind: 'status', id: cryptoId(), text: 'usage: /spec <task to design>' },
|
|
352
|
+
]);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await runInSpecMode(specTask);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (task === '/compact') {
|
|
359
|
+
await compactNow();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const slash = parseSlashCommand(task);
|
|
363
|
+
if (slash && ['model', 'weak-model', 'models', 'run', 'test', 'lint', 'ok', 'add', 'read-only', 'drop', 'ls', 'reset', 'tokens', 'settings'].includes(slash.name)) {
|
|
364
|
+
const say = (text, isError) => setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text, ...(isError ? { isError } : {}) }]);
|
|
365
|
+
const cwd = agentConfigRef.current.cwd ?? process.cwd();
|
|
366
|
+
const { name, args } = slash;
|
|
367
|
+
try {
|
|
368
|
+
if (name === 'model' || name === 'weak-model') {
|
|
369
|
+
const weak = name === 'weak-model';
|
|
370
|
+
if (!args) {
|
|
371
|
+
say(weak ? `weak model: ${weakModel ? describeModel(weakModel) : '(none; --cheap-model or /weak-model <model>)'}` : `model: ${describeModel(model)}`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const res = switchModel(args, model, aliases);
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
say(res.error, true);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const next = weak ? model : res.model;
|
|
380
|
+
const cheap = weak ? res.model : weakModel;
|
|
381
|
+
if (weak)
|
|
382
|
+
setWeakModel(res.model);
|
|
383
|
+
else
|
|
384
|
+
setModel(res.model);
|
|
385
|
+
agentConfigRef.current = {
|
|
386
|
+
...agentConfigRef.current,
|
|
387
|
+
model: next,
|
|
388
|
+
...(cheap ? { modelRouter: createExploreEditRouter({ strong: next, cheap }) } : {}),
|
|
389
|
+
};
|
|
390
|
+
say(weak ? `weak model → ${describeModel(res.model)} (read-only exploration steps and commit messages)` : `model → ${describeModel(res.model)}`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (name === 'models') {
|
|
394
|
+
say(formatModels(aliases, args));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (name === 'ls') {
|
|
398
|
+
const { editable, readOnly } = chatFilesRef.current.list();
|
|
399
|
+
say(editable.length + readOnly.length === 0
|
|
400
|
+
? 'no files attached (/add <file>, /read-only <file>)'
|
|
401
|
+
: [...editable.map((f) => ` ${f}`), ...readOnly.map((f) => ` ${f} (read-only)`)].join('\n'));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (name === 'add' || name === 'read-only') {
|
|
405
|
+
const res = await chatFilesRef.current.add(args, name === 'add' ? 'editable' : 'read-only');
|
|
406
|
+
const lines = [
|
|
407
|
+
...(res.added.length > 0 ? [`attached ${res.added.join(', ')}${name === 'read-only' ? ' (read-only)' : ''}`] : []),
|
|
408
|
+
...res.errors,
|
|
409
|
+
];
|
|
410
|
+
say(lines.join('\n'), res.added.length === 0);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (name === 'drop') {
|
|
414
|
+
const res = chatFilesRef.current.drop(args);
|
|
415
|
+
const lines = [
|
|
416
|
+
...(res.dropped.length > 0 ? [`detached ${res.dropped.join(', ')}`] : args ? [] : ['nothing attached']),
|
|
417
|
+
...res.notFound.map((f) => `${f}: not attached`),
|
|
418
|
+
];
|
|
419
|
+
say(lines.join('\n'), res.dropped.length === 0 && args !== '');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (name === 'reset') {
|
|
423
|
+
chatFilesRef.current.drop('');
|
|
424
|
+
pendingNotesRef.current = [];
|
|
425
|
+
historyRef.current = [];
|
|
426
|
+
setMessages([]);
|
|
427
|
+
say('detached all files and cleared the conversation');
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (name === 'tokens') {
|
|
431
|
+
say(formatTokens({
|
|
432
|
+
inputTokens,
|
|
433
|
+
outputTokens,
|
|
434
|
+
costUsd,
|
|
435
|
+
model,
|
|
436
|
+
contextTokens: estimateContextTokens(historyRef.current),
|
|
437
|
+
attachedFiles: chatFilesRef.current.size,
|
|
438
|
+
}));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (name === 'settings') {
|
|
442
|
+
const c = agentConfigRef.current;
|
|
443
|
+
say([
|
|
444
|
+
`model: ${describeModel(model)}${weakModel ? ` weak model: ${describeModel(weakModel)}` : ''}`,
|
|
445
|
+
`mode: ${c.auto ? 'autonomous' : 'interactive'} max iterations: ${c.maxIterations ?? 50} cwd: ${cwd}`,
|
|
446
|
+
`repo map: ${c.repoMap?.tokens ? `${c.repoMap.tokens} tokens` : 'off'} auto-lint: ${c.autoLint ? 'on' : 'off'} auto-commit: ${gitWorkflow ? 'on' : 'off'}`,
|
|
447
|
+
`verify: ${c.verify?.commands.length ? c.verify.commands.join(' && ') : 'off'}`,
|
|
448
|
+
`attached files: ${chatFilesRef.current.size} session: ${currentSessionId}`,
|
|
449
|
+
].join('\n'));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (name === 'ok') {
|
|
453
|
+
setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: OK_TASK }]);
|
|
454
|
+
await runAgent(OK_TASK, { planMode: false });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (name === 'lint') {
|
|
458
|
+
const aut = agentConfigRef.current.autoLint ?? { syntax: true };
|
|
459
|
+
const named = args ? args.split(/\s+/).filter(Boolean) : [];
|
|
460
|
+
const targets = named.length > 0 ? named : [...new Set([...chatFilesRef.current.focusFiles(), ...(await isGitRepo(cwd) ? await changedFiles(cwd) : [])])];
|
|
461
|
+
if (targets.length === 0) {
|
|
462
|
+
say('nothing to lint: attach files with /add, or have uncommitted changes');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
setRunning(true);
|
|
466
|
+
setStatus('running');
|
|
467
|
+
const problems = [];
|
|
468
|
+
try {
|
|
469
|
+
for (const file of targets) {
|
|
470
|
+
const rep = await lintFile(file, aut, { cwd });
|
|
471
|
+
problems.push(...rep.problems);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
setRunning(false);
|
|
476
|
+
setStatus('idle');
|
|
477
|
+
}
|
|
478
|
+
if (problems.length === 0) {
|
|
479
|
+
say(`lint clean (${targets.length} file${targets.length === 1 ? '' : 's'})`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const report = problems.join('\n');
|
|
483
|
+
say(`✘ lint: ${problems.length} problem${problems.length === 1 ? '' : 's'}\n${report}`, true);
|
|
484
|
+
const fix = `Linting found problems. Fix them:\n\n${report}`;
|
|
485
|
+
setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: fix }]);
|
|
486
|
+
await runAgent(fix, { planMode: false });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// /run and /test
|
|
490
|
+
const command = name === 'test' && !args ? agentConfigRef.current.verify?.commands[0] ?? '' : args;
|
|
491
|
+
if (!command) {
|
|
492
|
+
say(name === 'test' ? 'usage: /test <command> (or start with --verify <command>)' : 'usage: /run <command> (or !<command>)', true);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
say(`$ ${command}`);
|
|
496
|
+
setRunning(true);
|
|
497
|
+
setStatus('running');
|
|
498
|
+
let result;
|
|
499
|
+
try {
|
|
500
|
+
result = await runShell(command, cwd);
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
setRunning(false);
|
|
504
|
+
setStatus('idle');
|
|
505
|
+
}
|
|
506
|
+
const tail = result.output.trim().split('\n').slice(-40).join('\n');
|
|
507
|
+
say(`${tail || '(no output)'}\n[${result.timedOut ? 'timed out' : `exit ${result.exitCode ?? 'none'}`}]`, result.exitCode !== 0);
|
|
508
|
+
if (name === 'run') {
|
|
509
|
+
pendingNotesRef.current.push(runContextNote(command, result));
|
|
510
|
+
say('(output will be sent to the model with your next message)');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (result.exitCode === 0)
|
|
514
|
+
return;
|
|
515
|
+
const fix = fixTask('test', command, result);
|
|
516
|
+
setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: fix }]);
|
|
517
|
+
await runAgent(fix, { planMode: false });
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
setRunning(false);
|
|
521
|
+
setStatus('idle');
|
|
522
|
+
say(`/${name}: ${err.message}`, true);
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (task === '/map' || task === '/map-refresh') {
|
|
527
|
+
const say = (text, isError) => setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text, ...(isError ? { isError } : {}) }]);
|
|
528
|
+
const cwd = agentConfigRef.current.cwd ?? process.cwd();
|
|
529
|
+
try {
|
|
530
|
+
// /map-refresh drops the cached symbol index so every file is parsed again.
|
|
531
|
+
if (task === '/map-refresh')
|
|
532
|
+
await rm(path.join(cwd, '.forge', 'index', 'symbols.json'), { force: true });
|
|
533
|
+
const map = await buildRepoMap(cwd, { tokens: agentConfigRef.current.repoMap?.tokens });
|
|
534
|
+
say(map.text ? `${map.text}\n\n(${map.files.length} file${map.files.length === 1 ? '' : 's'}, ~${map.tokens} tokens)` : 'no source files to map');
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
say(`${task}: ${err.message}`, true);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (task === '/tokens') {
|
|
542
|
+
setMessages((m) => [
|
|
543
|
+
...m,
|
|
544
|
+
{
|
|
545
|
+
kind: 'status',
|
|
546
|
+
id: cryptoId(),
|
|
547
|
+
text: `tokens this session: ${inputTokens} in, ${outputTokens} out${costUsd !== undefined ? `, $${costUsd.toFixed(4)}` : ''}`,
|
|
548
|
+
},
|
|
549
|
+
]);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (task === '/commit' || task === '/undo' || task === '/diff') {
|
|
553
|
+
const say = (text, isError) => setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text, ...(isError ? { isError } : {}) }]);
|
|
554
|
+
const cwd = agentConfigRef.current.cwd ?? process.cwd();
|
|
555
|
+
try {
|
|
556
|
+
if (!(await isGitRepo(cwd))) {
|
|
557
|
+
say(`${task}: ${cwd} is not a git repository`, true);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (task === '/undo') {
|
|
561
|
+
const result = await undoLastForgeCommit(cwd);
|
|
562
|
+
say(result.ok
|
|
563
|
+
? `undid ${result.commit.slice(0, 7)} ${result.message.split('\n')[0]} (${result.files.length} file${result.files.length === 1 ? '' : 's'} restored)`
|
|
564
|
+
: result.reason, !result.ok);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const files = await changedFiles(cwd);
|
|
568
|
+
if (files.length === 0) {
|
|
569
|
+
say('no uncommitted changes');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const diff = await diffForFiles(cwd, files);
|
|
573
|
+
if (task === '/diff') {
|
|
574
|
+
say(diff.trim() || `${files.length} changed file(s), no textual diff:\n${files.join('\n')}`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const message = await generateCommitMessage(diff, files, weakModel ?? commitModel ?? model);
|
|
578
|
+
const commit = await commitFiles(cwd, files, message, gitCommitOptions(gitOptions));
|
|
579
|
+
say(commit ? formatCommitLine(commit) : 'nothing to commit');
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
say(`${task}: ${err.message}`, true);
|
|
583
|
+
}
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const userMessageId = cryptoId();
|
|
587
|
+
setMessages((m) => [...m, { kind: 'user', id: userMessageId, text: task }]);
|
|
588
|
+
await runAgent(task, { planMode: false });
|
|
589
|
+
}, [
|
|
590
|
+
running,
|
|
591
|
+
onExit,
|
|
592
|
+
inputTokens,
|
|
593
|
+
outputTokens,
|
|
594
|
+
currentSessionId,
|
|
595
|
+
currentLabel,
|
|
596
|
+
model,
|
|
597
|
+
sessionDir,
|
|
598
|
+
costUsd,
|
|
599
|
+
pendingPlan,
|
|
600
|
+
pendingSpec,
|
|
601
|
+
]);
|
|
602
|
+
/**
|
|
603
|
+
* Run the agent for a single turn. `options.planMode: true` makes the
|
|
604
|
+
* agent emit a plan instead of executing; the returned finalText holds
|
|
605
|
+
* the plan. `options.planContext` prepends an `<approved-plan>` block
|
|
606
|
+
* to the system prompt so the agent knows what to do.
|
|
607
|
+
*
|
|
608
|
+
* Declared as a regular function (not `useCallback`) so it can be
|
|
609
|
+
* referenced by `handleSubmit` below: function declarations are hoisted
|
|
610
|
+
* while `const` callbacks are not.
|
|
611
|
+
*/
|
|
612
|
+
async function runAgent(task, options) {
|
|
613
|
+
setRunning(true);
|
|
614
|
+
setStatus('running');
|
|
615
|
+
const controller = new AbortController();
|
|
616
|
+
abortControllerRef.current = controller;
|
|
617
|
+
const approvals = new Map();
|
|
618
|
+
const overrides = {
|
|
619
|
+
planMode: options.planMode,
|
|
620
|
+
specMode: options.specMode === true,
|
|
621
|
+
...(options.planContext
|
|
622
|
+
? {
|
|
623
|
+
systemPrompt: (agentConfigRef.current.systemPrompt ?? '') +
|
|
624
|
+
`\n\n<approved-plan>\n${options.planContext}\n</approved-plan>\nFollow the plan above. You may deviate if you find an issue, but explain why in your final summary.`,
|
|
625
|
+
}
|
|
626
|
+
: {}),
|
|
627
|
+
// Spec mode always gets exactly the six read-only tools: a hard
|
|
628
|
+
// override of whatever toolSandbox the user configured via
|
|
629
|
+
// --allow-tools/--deny-tools, matching options.ts's toAgentConfig
|
|
630
|
+
// wiring for one-shot `--spec`.
|
|
631
|
+
...(options.specMode === true ? { toolSandbox: { allowTools: [...SPEC_READONLY_TOOLS] } } : {}),
|
|
632
|
+
};
|
|
633
|
+
const localConfig = {
|
|
634
|
+
...agentConfigRef.current,
|
|
635
|
+
...overrides,
|
|
636
|
+
checkpoints: agentConfigRef.current.checkpoints ?? sessionCheckpointsConfig(sessionDir, currentSessionId, agentConfigRef.current.cwd ?? process.cwd()),
|
|
637
|
+
abortSignal: controller.signal,
|
|
638
|
+
approve: (req) => new Promise((resolve) => {
|
|
639
|
+
approvals.set(req.id, resolve);
|
|
640
|
+
setPendingApproval({ request: req, resolve });
|
|
641
|
+
}),
|
|
642
|
+
};
|
|
643
|
+
// Attached files are re-read every turn; /run output is handed over once.
|
|
644
|
+
const attached = await chatFilesRef.current.render();
|
|
645
|
+
for (const note of attached.notes)
|
|
646
|
+
setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: note }]);
|
|
647
|
+
const turnContext = [...pendingNotesRef.current.splice(0), attached.block].filter((part) => part.length > 0).join('\n\n');
|
|
648
|
+
if (localConfig.repoMap && chatFilesRef.current.size > 0) {
|
|
649
|
+
localConfig.repoMap = { ...localConfig.repoMap, focusFiles: chatFilesRef.current.focusFiles() };
|
|
650
|
+
}
|
|
651
|
+
const agent = new Agent(localConfig);
|
|
652
|
+
const gitRun = gitWorkflow && !options.planMode && !options.specMode ? gitWorkflow : null;
|
|
653
|
+
if (gitRun) {
|
|
654
|
+
const saved = await gitRun.before().catch(() => null);
|
|
655
|
+
if (saved)
|
|
656
|
+
setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: `${formatCommitLine(saved)} (your earlier changes)` }]);
|
|
657
|
+
}
|
|
658
|
+
const messageIdsByToolCallId = new Map();
|
|
659
|
+
let agentRunResult;
|
|
660
|
+
try {
|
|
661
|
+
agentRunResult = await agent.run({
|
|
662
|
+
task,
|
|
663
|
+
...(turnContext ? { context: turnContext } : {}),
|
|
664
|
+
history: historyRef.current,
|
|
665
|
+
abortSignal: controller.signal,
|
|
666
|
+
onEvent: (event) => {
|
|
667
|
+
switch (event.type) {
|
|
668
|
+
case 'iteration':
|
|
669
|
+
setIter(event.iteration);
|
|
670
|
+
setMaxIter(event.maxIterations);
|
|
671
|
+
break;
|
|
672
|
+
case 'text_delta': {
|
|
673
|
+
setMessages((prev) => {
|
|
674
|
+
const idx = prev.findIndex((m) => m.kind === 'assistant' && m.streaming);
|
|
675
|
+
if (idx === -1) {
|
|
676
|
+
return [
|
|
677
|
+
...prev,
|
|
678
|
+
{
|
|
679
|
+
kind: 'assistant',
|
|
680
|
+
id: cryptoId(),
|
|
681
|
+
text: event.text,
|
|
682
|
+
streaming: true,
|
|
683
|
+
},
|
|
684
|
+
];
|
|
685
|
+
}
|
|
686
|
+
const copy = prev.slice();
|
|
687
|
+
const cur = copy[idx];
|
|
688
|
+
copy[idx] = { ...cur, text: (cur.text ?? '') + event.text };
|
|
689
|
+
return copy;
|
|
690
|
+
});
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case 'text_done': {
|
|
694
|
+
setMessages((prev) => {
|
|
695
|
+
const idx = prev.findIndex((m) => m.kind === 'assistant' && m.streaming);
|
|
696
|
+
if (idx === -1) {
|
|
697
|
+
return [
|
|
698
|
+
...prev,
|
|
699
|
+
{
|
|
700
|
+
kind: 'assistant',
|
|
701
|
+
id: cryptoId(),
|
|
702
|
+
text: event.text,
|
|
703
|
+
},
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
const copy = prev.slice();
|
|
707
|
+
copy[idx] = { ...copy[idx], text: event.text, streaming: false };
|
|
708
|
+
return copy;
|
|
709
|
+
});
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case 'reasoning_delta': {
|
|
713
|
+
setMessages((prev) => {
|
|
714
|
+
const idx = prev.findIndex((m) => m.kind === 'reasoning' && m.streaming);
|
|
715
|
+
if (idx === -1) {
|
|
716
|
+
return [...prev, { kind: 'reasoning', id: cryptoId(), text: event.text, streaming: true }];
|
|
717
|
+
}
|
|
718
|
+
const copy = prev.slice();
|
|
719
|
+
const cur = copy[idx];
|
|
720
|
+
copy[idx] = { ...cur, text: (cur.text ?? '') + event.text };
|
|
721
|
+
return copy;
|
|
722
|
+
});
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
case 'reasoning_done': {
|
|
726
|
+
setMessages((prev) => {
|
|
727
|
+
const idx = prev.findIndex((m) => m.kind === 'reasoning' && m.streaming);
|
|
728
|
+
if (idx === -1)
|
|
729
|
+
return prev;
|
|
730
|
+
const copy = prev.slice();
|
|
731
|
+
copy[idx] = { ...copy[idx], text: event.text, streaming: false };
|
|
732
|
+
return copy;
|
|
733
|
+
});
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
case 'plan': {
|
|
737
|
+
// Plan-mode result: render the plan and set pendingPlan so
|
|
738
|
+
// the user can approve / edit / cancel.
|
|
739
|
+
setMessages((prev) => [
|
|
740
|
+
...prev,
|
|
741
|
+
{
|
|
742
|
+
kind: 'status',
|
|
743
|
+
id: cryptoId(),
|
|
744
|
+
text: `📋 plan:\n\n${event.plan}`,
|
|
745
|
+
},
|
|
746
|
+
]);
|
|
747
|
+
setPendingPlan(event.plan);
|
|
748
|
+
setPendingPlanTask(task);
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
case 'spec': {
|
|
752
|
+
// Spec-mode result: render the raw three-section text and
|
|
753
|
+
// set pendingSpec so the user can approve / edit / cancel.
|
|
754
|
+
setMessages((prev) => [
|
|
755
|
+
...prev,
|
|
756
|
+
{
|
|
757
|
+
kind: 'status',
|
|
758
|
+
id: cryptoId(),
|
|
759
|
+
text: `📐 spec drafted:\n\n${event.spec}`,
|
|
760
|
+
},
|
|
761
|
+
]);
|
|
762
|
+
setPendingSpec(event.spec);
|
|
763
|
+
setPendingSpecTask(task);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case 'tool_call': {
|
|
767
|
+
const id = cryptoId();
|
|
768
|
+
messageIdsByToolCallId.set(event.id, id);
|
|
769
|
+
setMessages((prev) => [
|
|
770
|
+
...prev,
|
|
771
|
+
{
|
|
772
|
+
kind: 'tool_call',
|
|
773
|
+
id,
|
|
774
|
+
toolName: event.name,
|
|
775
|
+
toolArgs: event.args,
|
|
776
|
+
},
|
|
777
|
+
]);
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
case 'tool_result': {
|
|
781
|
+
if (event.redacted !== undefined && event.redacted > 0) {
|
|
782
|
+
redactionsRef.current += event.redacted;
|
|
783
|
+
setRedactedCount(redactionsRef.current);
|
|
784
|
+
setMessages((prev) => [
|
|
785
|
+
...prev,
|
|
786
|
+
{
|
|
787
|
+
kind: 'status',
|
|
788
|
+
id: cryptoId(),
|
|
789
|
+
text: `🔒 ${event.redacted} secret${event.redacted === 1 ? '' : 's'} redacted from ${event.name} result`,
|
|
790
|
+
},
|
|
791
|
+
]);
|
|
792
|
+
}
|
|
793
|
+
setMessages((prev) => {
|
|
794
|
+
const id = messageIdsByToolCallId.get(event.id);
|
|
795
|
+
if (!id)
|
|
796
|
+
return prev;
|
|
797
|
+
return [
|
|
798
|
+
...prev,
|
|
799
|
+
{
|
|
800
|
+
kind: 'tool_result',
|
|
801
|
+
id: cryptoId(),
|
|
802
|
+
toolName: event.name,
|
|
803
|
+
toolResult: event.result,
|
|
804
|
+
isError: event.isError,
|
|
805
|
+
},
|
|
806
|
+
];
|
|
807
|
+
});
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case 'approval_requested':
|
|
811
|
+
break;
|
|
812
|
+
case 'usage':
|
|
813
|
+
tokensRef.current.input = event.inputTokens;
|
|
814
|
+
tokensRef.current.output = event.outputTokens;
|
|
815
|
+
setInputTokens(event.inputTokens);
|
|
816
|
+
setOutputTokens(event.outputTokens);
|
|
817
|
+
break;
|
|
818
|
+
case 'cost':
|
|
819
|
+
costRef.current = event.runningCostUsd;
|
|
820
|
+
setCostUsd(event.runningCostUsd);
|
|
821
|
+
break;
|
|
822
|
+
case 'cache_stats':
|
|
823
|
+
setCacheHits(event.hits);
|
|
824
|
+
setCacheMisses(event.misses);
|
|
825
|
+
break;
|
|
826
|
+
case 'compaction':
|
|
827
|
+
setMessages((prev) => [
|
|
828
|
+
...prev,
|
|
829
|
+
{
|
|
830
|
+
kind: 'status',
|
|
831
|
+
id: cryptoId(),
|
|
832
|
+
text: `🗜 compacted ${event.droppedCount} older turn${event.droppedCount === 1 ? '' : 's'} (saved ~${event.savedTokens} tokens)`,
|
|
833
|
+
},
|
|
834
|
+
]);
|
|
835
|
+
break;
|
|
836
|
+
case 'steering':
|
|
837
|
+
setMessages((prev) => [...prev, { kind: 'status', id: cryptoId(), text: formatSteeringLine(event) }]);
|
|
838
|
+
break;
|
|
839
|
+
case 'verify':
|
|
840
|
+
setMessages((prev) => [
|
|
841
|
+
...prev,
|
|
842
|
+
{
|
|
843
|
+
kind: 'status',
|
|
844
|
+
id: cryptoId(),
|
|
845
|
+
text: event.passed
|
|
846
|
+
? `✔ verification passed (pass ${event.attempt})`
|
|
847
|
+
: `✘ verification failed (pass ${event.attempt}): ${event.failedCommands.join(', ')}`,
|
|
848
|
+
},
|
|
849
|
+
]);
|
|
850
|
+
break;
|
|
851
|
+
case 'lint':
|
|
852
|
+
if (!event.passed) {
|
|
853
|
+
setMessages((prev) => [
|
|
854
|
+
...prev,
|
|
855
|
+
{ kind: 'status', id: cryptoId(), text: `✘ lint: ${event.problems} problem${event.problems === 1 ? '' : 's'} in ${event.path}` },
|
|
856
|
+
]);
|
|
857
|
+
}
|
|
858
|
+
break;
|
|
859
|
+
case 'model_route':
|
|
860
|
+
setMessages((prev) => [
|
|
861
|
+
...prev,
|
|
862
|
+
{ kind: 'status', id: cryptoId(), text: `⇄ step ${event.iteration} → ${event.provider}/${event.model}` },
|
|
863
|
+
]);
|
|
864
|
+
break;
|
|
865
|
+
case 'error':
|
|
866
|
+
setMessages((prev) => [
|
|
867
|
+
...prev,
|
|
868
|
+
{ kind: 'status', id: cryptoId(), text: `error: ${event.error}`, isError: true },
|
|
869
|
+
]);
|
|
870
|
+
break;
|
|
871
|
+
case 'done':
|
|
872
|
+
setStatus(event.reason === 'error' ? 'error' : 'done');
|
|
873
|
+
setMessages((prev) => [
|
|
874
|
+
...prev,
|
|
875
|
+
{
|
|
876
|
+
kind: 'status',
|
|
877
|
+
id: cryptoId(),
|
|
878
|
+
text: `done (${event.reason})`,
|
|
879
|
+
inputTokens,
|
|
880
|
+
outputTokens,
|
|
881
|
+
},
|
|
882
|
+
]);
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
catch (err) {
|
|
889
|
+
setMessages((prev) => [
|
|
890
|
+
...prev,
|
|
891
|
+
{ kind: 'status', id: cryptoId(), text: `error: ${err.message}`, isError: true },
|
|
892
|
+
]);
|
|
893
|
+
setStatus('error');
|
|
894
|
+
}
|
|
895
|
+
finally {
|
|
896
|
+
setRunning(false);
|
|
897
|
+
setPendingApproval(null);
|
|
898
|
+
approvals.clear();
|
|
899
|
+
if (abortControllerRef.current === controller)
|
|
900
|
+
abortControllerRef.current = null;
|
|
901
|
+
}
|
|
902
|
+
notify?.(agentRunResult ? `Forge finished (${agentRunResult.reason}) - waiting for you` : 'Forge stopped - waiting for you');
|
|
903
|
+
if (gitRun && agentRunResult?.reason === 'completed') {
|
|
904
|
+
const commit = await gitRun.after(task).catch(() => null);
|
|
905
|
+
if (commit)
|
|
906
|
+
setMessages((m) => [...m, { kind: 'status', id: cryptoId(), text: formatCommitLine(commit) }]);
|
|
907
|
+
}
|
|
908
|
+
if (agentRunResult) {
|
|
909
|
+
// The attached files were for this turn; keep them out of the stored history.
|
|
910
|
+
historyRef.current = attached.block
|
|
911
|
+
? agentRunResult.messages.map((m) => (m.role === 'user' && typeof m.content === 'string' ? { ...m, content: stripChatFiles(m.content) } : m))
|
|
912
|
+
: agentRunResult.messages;
|
|
913
|
+
if (historyRef.current.length > 0 && !options.planMode && !options.specMode) {
|
|
914
|
+
const savedPath = await persistSession({
|
|
915
|
+
id: currentSessionId,
|
|
916
|
+
createdAt: new Date().toISOString(),
|
|
917
|
+
updatedAt: new Date().toISOString(),
|
|
918
|
+
...(currentLabel ? { label: currentLabel } : {}),
|
|
919
|
+
cwd: process.cwd(),
|
|
920
|
+
model,
|
|
921
|
+
messages: historyRef.current,
|
|
922
|
+
totalInputTokens: tokensRef.current.input,
|
|
923
|
+
totalOutputTokens: tokensRef.current.output,
|
|
924
|
+
totalCostUsd: costRef.current,
|
|
925
|
+
costKnown: costUsd !== undefined,
|
|
926
|
+
});
|
|
927
|
+
if (!savedPath) {
|
|
928
|
+
setMessages((m) => [
|
|
929
|
+
...m,
|
|
930
|
+
{
|
|
931
|
+
kind: 'status',
|
|
932
|
+
id: cryptoId(),
|
|
933
|
+
text: `warning: auto-save failed (couldn't write ${currentSessionId}.json)`,
|
|
934
|
+
isError: true,
|
|
935
|
+
},
|
|
936
|
+
]);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/** Run the agent in plan mode for a task. */
|
|
942
|
+
function runInPlanMode(planTask) {
|
|
943
|
+
setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: `/plan ${planTask}` }]);
|
|
944
|
+
return runAgent(planTask, { planMode: true });
|
|
945
|
+
}
|
|
946
|
+
/** Run a normal (non-plan) agent turn with a previously-approved plan injected. */
|
|
947
|
+
function runWithPlan(realTask, plan) {
|
|
948
|
+
setMessages((m) => [
|
|
949
|
+
...m,
|
|
950
|
+
{ kind: 'status', id: cryptoId(), text: '✓ plan approved: executing…' },
|
|
951
|
+
{ kind: 'user', id: cryptoId(), text: realTask },
|
|
952
|
+
]);
|
|
953
|
+
return runAgent(realTask, { planMode: false, planContext: plan });
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Run the agent in spec mode for a task. Unlike `runWithPlan`, there is
|
|
957
|
+
* no re-run-on-approve counterpart: spec approval writes files directly
|
|
958
|
+
* via `SpecStore.approve` in the pendingSpec branch above, with no
|
|
959
|
+
* second agent run: spec approval is terminal (design decision #5).
|
|
960
|
+
*/
|
|
961
|
+
function runInSpecMode(specTask) {
|
|
962
|
+
setMessages((m) => [...m, { kind: 'user', id: cryptoId(), text: `/spec ${specTask}` }]);
|
|
963
|
+
return runAgent(specTask, { planMode: false, specMode: true });
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Force a compaction pass now. Useful in long sessions where the user
|
|
967
|
+
* wants to free context space before the next turn. Uses the agent's
|
|
968
|
+
* configured summarizer (default: the same model the agent runs on).
|
|
969
|
+
*/
|
|
970
|
+
async function compactNow() {
|
|
971
|
+
if (historyRef.current.length <= 2) {
|
|
972
|
+
setMessages((m) => [
|
|
973
|
+
...m,
|
|
974
|
+
{ kind: 'status', id: cryptoId(), text: 'nothing to compact (history too short)' },
|
|
975
|
+
]);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
setRunning(true);
|
|
979
|
+
setStatus('running');
|
|
980
|
+
try {
|
|
981
|
+
const summarizer = agentConfigRef.current.modelFactory
|
|
982
|
+
? await import('@ikarmic/forge-core').then((m) => m.buildLlmSummarizer(agentConfigRef.current.compactionModel ?? agentConfigRef.current.model, agentConfigRef.current.modelFactory))
|
|
983
|
+
: await import('@ikarmic/forge-core').then((m) => m.buildLlmSummarizer(agentConfigRef.current.compactionModel ?? agentConfigRef.current.model));
|
|
984
|
+
const result = await (await import('@ikarmic/forge-core')).compactMessages(historyRef.current, summarizer, {
|
|
985
|
+
keepRecent: agentConfigRef.current.compactionKeepRecent,
|
|
986
|
+
});
|
|
987
|
+
if (result.droppedCount > 0) {
|
|
988
|
+
historyRef.current = result.messages;
|
|
989
|
+
setMessages((m) => [
|
|
990
|
+
...m,
|
|
991
|
+
{
|
|
992
|
+
kind: 'status',
|
|
993
|
+
id: cryptoId(),
|
|
994
|
+
text: `🗜 compacted ${result.droppedCount} older turn${result.droppedCount === 1 ? '' : 's'} (saved ~${result.savedTokens} tokens)`,
|
|
995
|
+
},
|
|
996
|
+
]);
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
setMessages((m) => [
|
|
1000
|
+
...m,
|
|
1001
|
+
{ kind: 'status', id: cryptoId(), text: 'nothing to compact (everything is recent)' },
|
|
1002
|
+
]);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
setMessages((m) => [
|
|
1007
|
+
...m,
|
|
1008
|
+
{ kind: 'status', id: cryptoId(), text: `compact failed: ${err.message}`, isError: true },
|
|
1009
|
+
]);
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
setStatus('idle');
|
|
1013
|
+
setRunning(false);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const handleApproval = useCallback((approved) => {
|
|
1017
|
+
if (pendingApproval) {
|
|
1018
|
+
pendingApproval.resolve(approved);
|
|
1019
|
+
setPendingApproval(null);
|
|
1020
|
+
}
|
|
1021
|
+
}, [pendingApproval]);
|
|
1022
|
+
// Esc cancels the in-flight turn. Only active while a turn is running
|
|
1023
|
+
// (Approval.tsx handles Escape itself during an approval prompt, and
|
|
1024
|
+
// TextInput owns input while idle).
|
|
1025
|
+
useInput((_input, key) => {
|
|
1026
|
+
if (key.escape)
|
|
1027
|
+
abortControllerRef.current?.abort();
|
|
1028
|
+
}, { isActive: running });
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
// Welcome banner
|
|
1031
|
+
setMessages([
|
|
1032
|
+
{
|
|
1033
|
+
kind: 'status',
|
|
1034
|
+
id: cryptoId(),
|
|
1035
|
+
text: `Interactive REPL - type a task and press Enter. Type /help for the slash commands`,
|
|
1036
|
+
},
|
|
1037
|
+
]);
|
|
1038
|
+
}, []);
|
|
1039
|
+
return (React.createElement(Box, { flexDirection: "column", paddingX: 1 },
|
|
1040
|
+
React.createElement(Header, { model: model, iteration: iter, maxIterations: maxIter, status: status, auto: baseConfig.auto ?? false }),
|
|
1041
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1 },
|
|
1042
|
+
messages.map((m) => (React.createElement(MessageView, { key: m.id, message: m, display: display }))),
|
|
1043
|
+
pendingApproval && (React.createElement(Approval, { request: pendingApproval.request, onResolve: handleApproval }))),
|
|
1044
|
+
React.createElement(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, flexDirection: "row" },
|
|
1045
|
+
React.createElement(Text, { color: "cyan", bold: true }, "forge \u276F"),
|
|
1046
|
+
React.createElement(Box, { marginLeft: 1, flexGrow: 1 }, running ? (React.createElement(Box, null,
|
|
1047
|
+
React.createElement(Spinner, null),
|
|
1048
|
+
React.createElement(Text, { color: "gray" }, " running\u2026 (esc to cancel)"))) : (React.createElement(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: "ask forge anything\u2026" })))),
|
|
1049
|
+
React.createElement(Box, { marginTop: 1 },
|
|
1050
|
+
React.createElement(Text, { color: "gray" },
|
|
1051
|
+
"\u2191",
|
|
1052
|
+
inputTokens,
|
|
1053
|
+
" \u2193",
|
|
1054
|
+
outputTokens,
|
|
1055
|
+
" tokens",
|
|
1056
|
+
costUsd !== undefined && (React.createElement(React.Fragment, null,
|
|
1057
|
+
' · ',
|
|
1058
|
+
React.createElement(Text, { color: "green" }, costUsd < 0.01 ? '$' + costUsd.toFixed(4) : '$' + costUsd.toFixed(3)))),
|
|
1059
|
+
redactedCount > 0 && (React.createElement(React.Fragment, null,
|
|
1060
|
+
' · ',
|
|
1061
|
+
React.createElement(Text, { color: "red" },
|
|
1062
|
+
"\uD83D\uDD12 ",
|
|
1063
|
+
redactedCount))),
|
|
1064
|
+
' · ',
|
|
1065
|
+
running ? 'busy' : 'ready'))));
|
|
1066
|
+
}
|
|
1067
|
+
function MessageView({ message, display, }) {
|
|
1068
|
+
switch (message.kind) {
|
|
1069
|
+
case 'user':
|
|
1070
|
+
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
1071
|
+
React.createElement(Text, { color: "gray" }, "\u250C\u2500 you"),
|
|
1072
|
+
React.createElement(Box, { marginLeft: 2 },
|
|
1073
|
+
React.createElement(Text, null, message.text))));
|
|
1074
|
+
case 'assistant':
|
|
1075
|
+
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
1076
|
+
React.createElement(Text, { color: "gray" }, "\u251C\u2500 forge"),
|
|
1077
|
+
React.createElement(Box, { marginLeft: 2 },
|
|
1078
|
+
React.createElement(Text, null,
|
|
1079
|
+
message.text,
|
|
1080
|
+
message.streaming ? '▌' : ''))));
|
|
1081
|
+
case 'reasoning':
|
|
1082
|
+
if (display?.showReasoning === false)
|
|
1083
|
+
return null;
|
|
1084
|
+
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
1085
|
+
React.createElement(Text, { color: "gray" }, "\u251C\u2500 reasoning"),
|
|
1086
|
+
React.createElement(Box, { marginLeft: 2 },
|
|
1087
|
+
React.createElement(Text, { color: "gray" },
|
|
1088
|
+
message.text,
|
|
1089
|
+
message.streaming ? '▌' : ''))));
|
|
1090
|
+
case 'tool_call':
|
|
1091
|
+
return (React.createElement(Box, { marginLeft: 2 },
|
|
1092
|
+
React.createElement(Text, { color: "blue", bold: true },
|
|
1093
|
+
"\u25CC ",
|
|
1094
|
+
message.toolName),
|
|
1095
|
+
React.createElement(Text, { color: "gray" },
|
|
1096
|
+
" ",
|
|
1097
|
+
summarizeArgs(message.toolArgs))));
|
|
1098
|
+
case 'tool_result': {
|
|
1099
|
+
const diffText = getUnifiedDiffText(message.toolName, message.toolArgs, message.toolResult);
|
|
1100
|
+
return (React.createElement(Box, { marginLeft: 2, flexDirection: "column" },
|
|
1101
|
+
React.createElement(Box, null,
|
|
1102
|
+
React.createElement(Text, { color: message.isError ? 'red' : 'green', bold: true },
|
|
1103
|
+
message.isError ? '✗' : '✓',
|
|
1104
|
+
" ",
|
|
1105
|
+
message.toolName)),
|
|
1106
|
+
diffText && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, diffText.replace(/\n$/, '').split('\n').map((line, i) => (React.createElement(Text, { key: i, color: line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray' }, line))))),
|
|
1107
|
+
!diffText && message.toolResult !== undefined && (React.createElement(Box, { marginLeft: 2 },
|
|
1108
|
+
React.createElement(Text, { color: message.isError ? 'red' : 'gray' }, summarizeToolResult(message.toolResult, message.isError, display?.detail ?? 'compact'))))));
|
|
1109
|
+
}
|
|
1110
|
+
case 'status':
|
|
1111
|
+
return (React.createElement(Box, { marginLeft: 2, marginTop: 1 },
|
|
1112
|
+
React.createElement(Text, { color: message.isError ? 'red' : 'gray' },
|
|
1113
|
+
message.text,
|
|
1114
|
+
message.inputTokens !== undefined && ` · ↑${message.inputTokens} ↓${message.outputTokens}`)));
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
function summarizeArgs(args) {
|
|
1118
|
+
if (args === undefined || args === null)
|
|
1119
|
+
return '()';
|
|
1120
|
+
const obj = args;
|
|
1121
|
+
if (typeof obj !== 'object')
|
|
1122
|
+
return `(${String(args)})`;
|
|
1123
|
+
const keys = Object.keys(obj);
|
|
1124
|
+
if (keys.length === 0)
|
|
1125
|
+
return '()';
|
|
1126
|
+
return `(${keys
|
|
1127
|
+
.slice(0, 3)
|
|
1128
|
+
.map((k) => `${k}=${shortVal(obj[k])}`)
|
|
1129
|
+
.join(' ')}${keys.length > 3 ? ` +${keys.length - 3}` : ''})`;
|
|
1130
|
+
}
|
|
1131
|
+
function summarizeResult(result) {
|
|
1132
|
+
let text;
|
|
1133
|
+
if (typeof result === 'string') {
|
|
1134
|
+
text = result;
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
try {
|
|
1138
|
+
text = JSON.stringify(result);
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
text = String(result);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (text.length > 240)
|
|
1145
|
+
text = text.slice(0, 240) + '…';
|
|
1146
|
+
return text;
|
|
1147
|
+
}
|
|
1148
|
+
function shortVal(v) {
|
|
1149
|
+
const s = typeof v === 'string' ? JSON.stringify(v) : JSON.stringify(v);
|
|
1150
|
+
return s.length > 40 ? s.slice(0, 37) + '…' : s;
|
|
1151
|
+
}
|
|
1152
|
+
async function persistSession(s) {
|
|
1153
|
+
try {
|
|
1154
|
+
return await saveSession(s, defaultSessionDir());
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
let _id = 0;
|
|
1161
|
+
function cryptoId() {
|
|
1162
|
+
_id++;
|
|
1163
|
+
return `${Date.now().toString(36)}-${_id}`;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Build a CoreMessage history from the agent run. Removed in favor of
|
|
1167
|
+
* snapshotting the final `messages` array directly from `Agent.run()`. Kept
|
|
1168
|
+
* here as a placeholder for future session-export functionality.
|
|
1169
|
+
*/
|
|
1170
|
+
function _unusedBuildHistory(_initial, _agent, _task) {
|
|
1171
|
+
return [];
|
|
1172
|
+
}
|
|
1173
|
+
void _unusedBuildHistory;
|
|
1174
|
+
//# sourceMappingURL=repl.js.map
|