@phren/agent 0.0.1
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/agent-loop.js +328 -0
- package/dist/bin.js +3 -0
- package/dist/checkpoint.js +103 -0
- package/dist/commands.js +292 -0
- package/dist/config.js +139 -0
- package/dist/context/pruner.js +62 -0
- package/dist/context/token-counter.js +28 -0
- package/dist/cost.js +71 -0
- package/dist/index.js +284 -0
- package/dist/mcp-client.js +168 -0
- package/dist/memory/anti-patterns.js +69 -0
- package/dist/memory/auto-capture.js +72 -0
- package/dist/memory/context-flush.js +24 -0
- package/dist/memory/context.js +170 -0
- package/dist/memory/error-recovery.js +58 -0
- package/dist/memory/project-context.js +77 -0
- package/dist/memory/session.js +100 -0
- package/dist/multi/agent-colors.js +41 -0
- package/dist/multi/child-entry.js +173 -0
- package/dist/multi/coordinator.js +263 -0
- package/dist/multi/diff-renderer.js +175 -0
- package/dist/multi/markdown.js +96 -0
- package/dist/multi/presets.js +107 -0
- package/dist/multi/progress.js +32 -0
- package/dist/multi/spawner.js +219 -0
- package/dist/multi/tui-multi.js +626 -0
- package/dist/multi/types.js +7 -0
- package/dist/permissions/allowlist.js +61 -0
- package/dist/permissions/checker.js +111 -0
- package/dist/permissions/prompt.js +190 -0
- package/dist/permissions/sandbox.js +95 -0
- package/dist/permissions/shell-safety.js +74 -0
- package/dist/permissions/types.js +2 -0
- package/dist/plan.js +38 -0
- package/dist/providers/anthropic.js +170 -0
- package/dist/providers/codex-auth.js +197 -0
- package/dist/providers/codex.js +265 -0
- package/dist/providers/ollama.js +142 -0
- package/dist/providers/openai-compat.js +163 -0
- package/dist/providers/openrouter.js +116 -0
- package/dist/providers/resolve.js +39 -0
- package/dist/providers/retry.js +55 -0
- package/dist/providers/types.js +2 -0
- package/dist/repl.js +180 -0
- package/dist/spinner.js +46 -0
- package/dist/system-prompt.js +31 -0
- package/dist/tools/edit-file.js +31 -0
- package/dist/tools/git.js +98 -0
- package/dist/tools/glob.js +65 -0
- package/dist/tools/grep.js +108 -0
- package/dist/tools/lint-test.js +76 -0
- package/dist/tools/phren-finding.js +35 -0
- package/dist/tools/phren-search.js +44 -0
- package/dist/tools/phren-tasks.js +71 -0
- package/dist/tools/read-file.js +44 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/shell.js +48 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/write-file.js +27 -0
- package/dist/tui.js +451 -0
- package/package.json +39 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { estimateMessageTokens } from "./context/token-counter.js";
|
|
2
|
+
import { pruneMessages } from "./context/pruner.js";
|
|
3
|
+
import { listPresets, loadPreset, savePreset, deletePreset, formatPreset } from "./multi/presets.js";
|
|
4
|
+
import { renderMarkdown } from "./multi/markdown.js";
|
|
5
|
+
const DIM = "\x1b[2m";
|
|
6
|
+
const BOLD = "\x1b[1m";
|
|
7
|
+
const CYAN = "\x1b[36m";
|
|
8
|
+
const GREEN = "\x1b[32m";
|
|
9
|
+
const RED = "\x1b[31m";
|
|
10
|
+
const YELLOW = "\x1b[33m";
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const HISTORY_MAX_LINES = 5;
|
|
13
|
+
export function createCommandContext(session, contextLimit) {
|
|
14
|
+
return {
|
|
15
|
+
session,
|
|
16
|
+
contextLimit,
|
|
17
|
+
undoStack: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Truncate text to N lines, appending [+M lines] if overflow. */
|
|
21
|
+
function truncateText(text, maxLines) {
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
if (lines.length <= maxLines)
|
|
24
|
+
return text;
|
|
25
|
+
const overflow = lines.length - maxLines;
|
|
26
|
+
return lines.slice(0, maxLines).join("\n") + `\n${DIM}[+${overflow} lines]${RESET}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Try to handle a slash command. Returns true if the input was a command.
|
|
30
|
+
*/
|
|
31
|
+
export function handleCommand(input, ctx) {
|
|
32
|
+
const parts = input.trim().split(/\s+/);
|
|
33
|
+
const name = parts[0];
|
|
34
|
+
switch (name) {
|
|
35
|
+
case "/help":
|
|
36
|
+
process.stderr.write(`${DIM}Commands:
|
|
37
|
+
/help Show this help
|
|
38
|
+
/turns Show turn and tool call counts
|
|
39
|
+
/clear Clear conversation history
|
|
40
|
+
/cost Show token usage and estimated cost
|
|
41
|
+
/plan Show conversation plan (tool calls so far)
|
|
42
|
+
/undo Undo last user message and response
|
|
43
|
+
/history [n|full] Show last N messages (default 10) with rich formatting
|
|
44
|
+
/compact Compact conversation to save context space
|
|
45
|
+
/mode Toggle input mode (steering ↔ queue)
|
|
46
|
+
/spawn <name> <task> Spawn a background agent
|
|
47
|
+
/agents List running agents
|
|
48
|
+
/preset [name|save|delete|list] Config presets
|
|
49
|
+
/exit Exit the REPL${RESET}\n`);
|
|
50
|
+
return true;
|
|
51
|
+
case "/turns": {
|
|
52
|
+
const tokens = estimateMessageTokens(ctx.session.messages);
|
|
53
|
+
const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
|
|
54
|
+
const costLine = ctx.costTracker ? ` Cost: $${ctx.costTracker.totalCost.toFixed(4)}` : "";
|
|
55
|
+
process.stderr.write(`${DIM}Turns: ${ctx.session.turns} Tool calls: ${ctx.session.toolCalls} ` +
|
|
56
|
+
`Messages: ${ctx.session.messages.length} Tokens: ~${tokens} (${pct}%)${costLine}${RESET}\n`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
case "/clear":
|
|
60
|
+
ctx.session.messages.length = 0;
|
|
61
|
+
ctx.session.turns = 0;
|
|
62
|
+
ctx.session.toolCalls = 0;
|
|
63
|
+
ctx.undoStack.length = 0;
|
|
64
|
+
process.stderr.write(`${DIM}Conversation cleared.${RESET}\n`);
|
|
65
|
+
return true;
|
|
66
|
+
case "/cost": {
|
|
67
|
+
const ct = ctx.costTracker;
|
|
68
|
+
if (ct) {
|
|
69
|
+
process.stderr.write(`${DIM}Tokens — input: ${ct.inputTokens} output: ${ct.outputTokens} est. cost: $${ct.totalCost.toFixed(4)}${RESET}\n`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
process.stderr.write(`${DIM}Cost tracking not available.${RESET}\n`);
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
case "/plan": {
|
|
77
|
+
const tools = [];
|
|
78
|
+
for (const msg of ctx.session.messages) {
|
|
79
|
+
if (typeof msg.content !== "string") {
|
|
80
|
+
for (const block of msg.content) {
|
|
81
|
+
if (block.type === "tool_use") {
|
|
82
|
+
tools.push(block.name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (tools.length === 0) {
|
|
88
|
+
process.stderr.write(`${DIM}No tool calls yet.${RESET}\n`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
process.stderr.write(`${DIM}Tool calls (${tools.length}): ${tools.join(" → ")}${RESET}\n`);
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
case "/undo": {
|
|
96
|
+
if (ctx.session.messages.length < 2) {
|
|
97
|
+
process.stderr.write(`${DIM}Nothing to undo.${RESET}\n`);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
// Remove messages back to the previous user message
|
|
101
|
+
let removed = 0;
|
|
102
|
+
while (ctx.session.messages.length > 0) {
|
|
103
|
+
const last = ctx.session.messages.pop();
|
|
104
|
+
removed++;
|
|
105
|
+
if (last?.role === "user" && typeof last.content === "string")
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
process.stderr.write(`${DIM}Undid ${removed} messages.${RESET}\n`);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
case "/history": {
|
|
112
|
+
const msgs = ctx.session.messages;
|
|
113
|
+
if (msgs.length === 0) {
|
|
114
|
+
process.stderr.write(`${DIM}No messages yet.${RESET}\n`);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const arg = parts[1];
|
|
118
|
+
const isFull = arg === "full";
|
|
119
|
+
const count = isFull ? msgs.length : Math.min(parseInt(arg, 10) || 10, msgs.length);
|
|
120
|
+
const slice = msgs.slice(-count);
|
|
121
|
+
const tokens = estimateMessageTokens(msgs);
|
|
122
|
+
const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
|
|
123
|
+
process.stderr.write(`${DIM}── History (${slice.length}/${msgs.length} messages, ~${tokens} tokens, ${pct}% context) ──${RESET}\n`);
|
|
124
|
+
for (const msg of slice) {
|
|
125
|
+
if (msg.role === "user") {
|
|
126
|
+
if (typeof msg.content === "string") {
|
|
127
|
+
const truncated = truncateText(msg.content, isFull ? Infinity : HISTORY_MAX_LINES);
|
|
128
|
+
process.stderr.write(`\n${CYAN}${BOLD}You:${RESET} ${truncated}\n`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Tool results
|
|
132
|
+
for (const block of msg.content) {
|
|
133
|
+
if (block.type === "tool_result") {
|
|
134
|
+
const icon = block.is_error ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
|
|
135
|
+
const preview = (block.content ?? "").slice(0, 80).replace(/\n/g, " ");
|
|
136
|
+
process.stderr.write(`${DIM} ${icon} tool_result ${preview}${preview.length >= 80 ? "..." : ""}${RESET}\n`);
|
|
137
|
+
}
|
|
138
|
+
else if (block.type === "text") {
|
|
139
|
+
process.stderr.write(`${DIM} ${block.text.slice(0, 100)}${RESET}\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (msg.role === "assistant") {
|
|
145
|
+
if (typeof msg.content === "string") {
|
|
146
|
+
const rendered = isFull ? renderMarkdown(msg.content) : truncateText(msg.content, HISTORY_MAX_LINES);
|
|
147
|
+
process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
for (const block of msg.content) {
|
|
151
|
+
if (block.type === "text") {
|
|
152
|
+
const text = block.text;
|
|
153
|
+
const rendered = isFull ? renderMarkdown(text) : truncateText(text, HISTORY_MAX_LINES);
|
|
154
|
+
process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
|
|
155
|
+
}
|
|
156
|
+
else if (block.type === "tool_use") {
|
|
157
|
+
const tb = block;
|
|
158
|
+
const inputPreview = JSON.stringify(tb.input).slice(0, 60);
|
|
159
|
+
process.stderr.write(`${YELLOW} ⚡ ${tb.name}${RESET}${DIM}(${inputPreview})${RESET}\n`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
process.stderr.write(`${DIM}── end ──${RESET}\n`);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
case "/compact": {
|
|
169
|
+
const before = ctx.session.messages.length;
|
|
170
|
+
ctx.session.messages = pruneMessages(ctx.session.messages, { contextLimit: ctx.contextLimit, keepRecentTurns: 4 });
|
|
171
|
+
const after = ctx.session.messages.length;
|
|
172
|
+
process.stderr.write(`${DIM}Compacted: ${before} → ${after} messages.${RESET}\n`);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
case "/spawn": {
|
|
176
|
+
if (!ctx.spawner) {
|
|
177
|
+
process.stderr.write(`${DIM}Spawner not available. Start with --multi or --team to enable.${RESET}\n`);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
const spawnName = parts[1];
|
|
181
|
+
const spawnTask = parts.slice(2).join(" ");
|
|
182
|
+
if (!spawnName || !spawnTask) {
|
|
183
|
+
process.stderr.write(`${DIM}Usage: /spawn <name> <task>${RESET}\n`);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
const agentId = ctx.spawner.spawn({ task: spawnTask, cwd: process.cwd() });
|
|
187
|
+
process.stderr.write(`${DIM}Spawned agent "${spawnName}" (${agentId}): ${spawnTask}${RESET}\n`);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
case "/agents": {
|
|
191
|
+
if (!ctx.spawner) {
|
|
192
|
+
process.stderr.write(`${DIM}No spawner available. Start with --multi or --team to enable.${RESET}\n`);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
const agents = ctx.spawner.listAgents();
|
|
196
|
+
if (agents.length === 0) {
|
|
197
|
+
process.stderr.write(`${DIM}No agents running.${RESET}\n`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const lines = agents.map((a) => {
|
|
201
|
+
const elapsed = a.finishedAt
|
|
202
|
+
? `${((a.finishedAt - a.startedAt) / 1000).toFixed(1)}s`
|
|
203
|
+
: `${((Date.now() - a.startedAt) / 1000).toFixed(0)}s`;
|
|
204
|
+
return ` ${a.id} [${a.status}] ${elapsed} — ${a.task.slice(0, 60)}`;
|
|
205
|
+
});
|
|
206
|
+
process.stderr.write(`${DIM}Agents (${agents.length}):\n${lines.join("\n")}${RESET}\n`);
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
case "/preset": {
|
|
211
|
+
const sub = parts[1]?.toLowerCase();
|
|
212
|
+
if (!sub || sub === "list") {
|
|
213
|
+
const all = listPresets();
|
|
214
|
+
if (all.length === 0) {
|
|
215
|
+
process.stderr.write(`${DIM}No presets.${RESET}\n`);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const lines = all.map((p) => ` ${formatPreset(p.name, p.preset, p.builtin)}`);
|
|
219
|
+
process.stderr.write(`${DIM}Presets:\n${lines.join("\n")}${RESET}\n`);
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
if (sub === "save") {
|
|
224
|
+
// /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]
|
|
225
|
+
const presetName = parts[2];
|
|
226
|
+
if (!presetName) {
|
|
227
|
+
process.stderr.write(`${DIM}Usage: /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]${RESET}\n`);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
const preset = {};
|
|
231
|
+
for (const arg of parts.slice(3)) {
|
|
232
|
+
const [k, v] = arg.split("=", 2);
|
|
233
|
+
if (k === "provider")
|
|
234
|
+
preset.provider = v;
|
|
235
|
+
else if (k === "model")
|
|
236
|
+
preset.model = v;
|
|
237
|
+
else if (k === "permissions")
|
|
238
|
+
preset.permissions = v;
|
|
239
|
+
else if (k === "max-turns")
|
|
240
|
+
preset.maxTurns = parseInt(v, 10) || undefined;
|
|
241
|
+
else if (k === "budget")
|
|
242
|
+
preset.budget = v === "none" ? null : parseFloat(v) || undefined;
|
|
243
|
+
else if (k === "plan")
|
|
244
|
+
preset.plan = true;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
savePreset(presetName, preset);
|
|
248
|
+
process.stderr.write(`${DIM}Saved preset "${presetName}".${RESET}\n`);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
if (sub === "delete") {
|
|
256
|
+
const presetName = parts[2];
|
|
257
|
+
if (!presetName) {
|
|
258
|
+
process.stderr.write(`${DIM}Usage: /preset delete <name>${RESET}\n`);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const ok = deletePreset(presetName);
|
|
263
|
+
process.stderr.write(`${DIM}${ok ? `Deleted "${presetName}".` : `Preset "${presetName}" not found.`}${RESET}\n`);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
// /preset <name> — show preset details (use --preset <name> on CLI to apply at startup)
|
|
271
|
+
const preset = loadPreset(sub);
|
|
272
|
+
if (!preset) {
|
|
273
|
+
process.stderr.write(`${DIM}Preset "${sub}" not found. Use /preset list to see available presets.${RESET}\n`);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const isBuiltin = ["fast", "careful", "yolo"].includes(sub);
|
|
277
|
+
process.stderr.write(`${DIM}${formatPreset(sub, preset, isBuiltin)}\nUse: phren-agent --preset ${sub} <task>${RESET}\n`);
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
case "/exit":
|
|
282
|
+
case "/quit":
|
|
283
|
+
case "/q":
|
|
284
|
+
process.exit(0);
|
|
285
|
+
default:
|
|
286
|
+
if (input.startsWith("/")) {
|
|
287
|
+
process.stderr.write(`${DIM}Unknown command: ${name}. Type /help for commands.${RESET}\n`);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const HELP = `
|
|
2
|
+
phren-agent — coding agent with persistent memory
|
|
3
|
+
|
|
4
|
+
Usage: phren-agent [options] <task>
|
|
5
|
+
|
|
6
|
+
Options:
|
|
7
|
+
--provider <name> Force provider: openrouter, anthropic, openai, codex, ollama
|
|
8
|
+
--model <model> Override LLM model
|
|
9
|
+
--project <name> Force phren project context
|
|
10
|
+
--max-turns <n> Max tool-use turns (default: 50)
|
|
11
|
+
--budget <dollars> Max spend in USD (aborts when exceeded)
|
|
12
|
+
--plan Plan mode: show plan before executing tools
|
|
13
|
+
--permissions <mode> Permission mode: suggest, auto-confirm, full-auto (default: auto-confirm)
|
|
14
|
+
--interactive, -i Interactive REPL mode (multi-turn conversation)
|
|
15
|
+
--resume Resume last session's conversation
|
|
16
|
+
--lint-cmd <cmd> Override auto-detected lint command
|
|
17
|
+
--test-cmd <cmd> Override auto-detected test command
|
|
18
|
+
--mcp <command> Connect to an MCP server via stdio (repeatable)
|
|
19
|
+
--mcp-config <path> Load MCP server config from JSON file
|
|
20
|
+
--team <name> Start in team mode with named team coordination
|
|
21
|
+
--multi Start in multi-agent TUI mode
|
|
22
|
+
--dry-run Show system prompt and exit
|
|
23
|
+
--verbose Show tool calls as they execute
|
|
24
|
+
--version Show version
|
|
25
|
+
--help Show this help
|
|
26
|
+
|
|
27
|
+
Providers (auto-detected from env, or use --provider):
|
|
28
|
+
openrouter OPENROUTER_API_KEY — routes to any model (default)
|
|
29
|
+
anthropic ANTHROPIC_API_KEY — Claude direct
|
|
30
|
+
openai OPENAI_API_KEY — OpenAI direct
|
|
31
|
+
codex Uses your ChatGPT/Codex subscription directly
|
|
32
|
+
(no API key needed, flat rate via your subscription)
|
|
33
|
+
Setup: phren-agent auth login
|
|
34
|
+
ollama PHREN_OLLAMA_URL — local models (default: localhost:11434)
|
|
35
|
+
|
|
36
|
+
Environment:
|
|
37
|
+
PHREN_AGENT_PROVIDER Force provider via env
|
|
38
|
+
PHREN_AGENT_MODEL Override model via env
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
phren-agent "fix the login bug"
|
|
42
|
+
phren-agent --provider codex "add input validation"
|
|
43
|
+
phren-agent --provider anthropic --verbose "refactor the database layer"
|
|
44
|
+
`.trim();
|
|
45
|
+
export function parseArgs(argv) {
|
|
46
|
+
const args = {
|
|
47
|
+
task: "",
|
|
48
|
+
permissions: "auto-confirm",
|
|
49
|
+
maxTurns: 50,
|
|
50
|
+
budget: null,
|
|
51
|
+
plan: false,
|
|
52
|
+
dryRun: false,
|
|
53
|
+
verbose: false,
|
|
54
|
+
interactive: false,
|
|
55
|
+
resume: false,
|
|
56
|
+
mcp: [],
|
|
57
|
+
multi: false,
|
|
58
|
+
help: false,
|
|
59
|
+
version: false,
|
|
60
|
+
};
|
|
61
|
+
const positional = [];
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < argv.length) {
|
|
64
|
+
const arg = argv[i];
|
|
65
|
+
if (arg === "--help" || arg === "-h") {
|
|
66
|
+
args.help = true;
|
|
67
|
+
}
|
|
68
|
+
else if (arg === "--version" || arg === "-v") {
|
|
69
|
+
args.version = true;
|
|
70
|
+
}
|
|
71
|
+
else if (arg === "--dry-run") {
|
|
72
|
+
args.dryRun = true;
|
|
73
|
+
}
|
|
74
|
+
else if (arg === "--verbose") {
|
|
75
|
+
args.verbose = true;
|
|
76
|
+
}
|
|
77
|
+
else if (arg === "--interactive" || arg === "-i") {
|
|
78
|
+
args.interactive = true;
|
|
79
|
+
}
|
|
80
|
+
else if (arg === "--plan") {
|
|
81
|
+
args.plan = true;
|
|
82
|
+
}
|
|
83
|
+
else if (arg === "--resume") {
|
|
84
|
+
args.resume = true;
|
|
85
|
+
}
|
|
86
|
+
else if (arg === "--lint-cmd" && argv[i + 1]) {
|
|
87
|
+
args.lintCmd = argv[++i];
|
|
88
|
+
}
|
|
89
|
+
else if (arg === "--test-cmd" && argv[i + 1]) {
|
|
90
|
+
args.testCmd = argv[++i];
|
|
91
|
+
}
|
|
92
|
+
else if (arg === "--mcp" && argv[i + 1]) {
|
|
93
|
+
args.mcp.push(argv[++i]);
|
|
94
|
+
}
|
|
95
|
+
else if (arg === "--mcp-config" && argv[i + 1]) {
|
|
96
|
+
args.mcpConfig = argv[++i];
|
|
97
|
+
}
|
|
98
|
+
else if (arg === "--team" && argv[i + 1]) {
|
|
99
|
+
args.team = argv[++i];
|
|
100
|
+
}
|
|
101
|
+
else if (arg === "--multi") {
|
|
102
|
+
args.multi = true;
|
|
103
|
+
}
|
|
104
|
+
else if (arg === "--provider" && argv[i + 1]) {
|
|
105
|
+
args.provider = argv[++i];
|
|
106
|
+
}
|
|
107
|
+
else if (arg === "--model" && argv[i + 1]) {
|
|
108
|
+
args.model = argv[++i];
|
|
109
|
+
}
|
|
110
|
+
else if (arg === "--project" && argv[i + 1]) {
|
|
111
|
+
args.project = argv[++i];
|
|
112
|
+
}
|
|
113
|
+
else if (arg === "--max-turns" && argv[i + 1]) {
|
|
114
|
+
args.maxTurns = parseInt(argv[++i], 10) || 50;
|
|
115
|
+
}
|
|
116
|
+
else if (arg === "--budget" && argv[i + 1]) {
|
|
117
|
+
args.budget = parseFloat(argv[++i]) || null;
|
|
118
|
+
}
|
|
119
|
+
else if (arg === "--permissions" && argv[i + 1]) {
|
|
120
|
+
const mode = argv[++i];
|
|
121
|
+
if (mode === "suggest" || mode === "auto-confirm" || mode === "full-auto") {
|
|
122
|
+
args.permissions = mode;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (!arg.startsWith("-")) {
|
|
126
|
+
positional.push(arg);
|
|
127
|
+
}
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
args.task = positional.join(" ");
|
|
131
|
+
// Also check env for model override
|
|
132
|
+
if (!args.model && process.env.PHREN_AGENT_MODEL) {
|
|
133
|
+
args.model = process.env.PHREN_AGENT_MODEL;
|
|
134
|
+
}
|
|
135
|
+
return args;
|
|
136
|
+
}
|
|
137
|
+
export function printHelp() {
|
|
138
|
+
console.log(HELP);
|
|
139
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { estimateTokens, estimateMessageTokens } from "./token-counter.js";
|
|
2
|
+
const DEFAULT_CONFIG = {
|
|
3
|
+
contextLimit: 200_000,
|
|
4
|
+
keepRecentTurns: 6,
|
|
5
|
+
};
|
|
6
|
+
/** Returns true when the conversation is approaching context limits. */
|
|
7
|
+
export function shouldPrune(systemPrompt, messages, config) {
|
|
8
|
+
const limit = config?.contextLimit ?? DEFAULT_CONFIG.contextLimit;
|
|
9
|
+
const systemTokens = estimateTokens(systemPrompt);
|
|
10
|
+
const msgTokens = estimateMessageTokens(messages);
|
|
11
|
+
return (systemTokens + msgTokens) > limit * 0.75;
|
|
12
|
+
}
|
|
13
|
+
/** Prune messages, keeping the first (original task) and last N turn pairs. */
|
|
14
|
+
export function pruneMessages(messages, config) {
|
|
15
|
+
const keepRecent = config?.keepRecentTurns ?? DEFAULT_CONFIG.keepRecentTurns;
|
|
16
|
+
const keepRecentMessages = keepRecent * 2; // each turn = user + assistant
|
|
17
|
+
// Not enough messages to prune
|
|
18
|
+
if (messages.length <= keepRecentMessages + 1) {
|
|
19
|
+
return messages;
|
|
20
|
+
}
|
|
21
|
+
const first = messages[0]; // original task
|
|
22
|
+
// Walk backwards from split point to ensure tail starts with a user text message,
|
|
23
|
+
// not a tool_result-only message (which would be orphaned without its tool_use).
|
|
24
|
+
let splitIdx = messages.length - keepRecentMessages;
|
|
25
|
+
while (splitIdx > 1) {
|
|
26
|
+
const msg = messages[splitIdx];
|
|
27
|
+
if (msg.role === "user") {
|
|
28
|
+
// Check if this is a text message (not just tool_results)
|
|
29
|
+
if (typeof msg.content === "string")
|
|
30
|
+
break;
|
|
31
|
+
const hasText = msg.content.some((b) => b.type === "text");
|
|
32
|
+
if (hasText)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
splitIdx--;
|
|
36
|
+
}
|
|
37
|
+
const middle = messages.slice(1, splitIdx);
|
|
38
|
+
const tail = messages.slice(splitIdx);
|
|
39
|
+
// Collect tool names used in the pruned middle section
|
|
40
|
+
const toolsUsed = new Set();
|
|
41
|
+
for (const msg of middle) {
|
|
42
|
+
if (typeof msg.content !== "string") {
|
|
43
|
+
for (const block of msg.content) {
|
|
44
|
+
if (block.type === "tool_use") {
|
|
45
|
+
toolsUsed.add(block.name);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const summaryText = [
|
|
51
|
+
`[Context compacted: ${middle.length} messages removed]`,
|
|
52
|
+
toolsUsed.size > 0 ? `Tools used: ${[...toolsUsed].join(", ")}` : null,
|
|
53
|
+
`Key points: ${middle.length} intermediate turns were compacted to fit context window.`,
|
|
54
|
+
]
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join("\n");
|
|
57
|
+
const summaryMessage = {
|
|
58
|
+
role: "user",
|
|
59
|
+
content: summaryText,
|
|
60
|
+
};
|
|
61
|
+
return [first, summaryMessage, ...tail];
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Rough token estimate: ~4 chars per token. */
|
|
2
|
+
export function estimateTokens(text) {
|
|
3
|
+
return Math.ceil(text.length / 4);
|
|
4
|
+
}
|
|
5
|
+
/** Estimate total tokens across a message array. */
|
|
6
|
+
export function estimateMessageTokens(messages) {
|
|
7
|
+
let total = 0;
|
|
8
|
+
for (const msg of messages) {
|
|
9
|
+
total += 4; // per-message overhead
|
|
10
|
+
if (typeof msg.content === "string") {
|
|
11
|
+
total += estimateTokens(msg.content);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
for (const block of msg.content) {
|
|
15
|
+
if (block.type === "text") {
|
|
16
|
+
total += estimateTokens(block.text);
|
|
17
|
+
}
|
|
18
|
+
else if (block.type === "tool_result") {
|
|
19
|
+
total += estimateTokens(block.content);
|
|
20
|
+
}
|
|
21
|
+
else if (block.type === "tool_use") {
|
|
22
|
+
total += estimateTokens(JSON.stringify(block.input));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return total;
|
|
28
|
+
}
|
package/dist/cost.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Cost tracking for LLM API usage. */
|
|
2
|
+
// Pricing table — prefix match (most specific wins)
|
|
3
|
+
const PRICING = [
|
|
4
|
+
// Anthropic
|
|
5
|
+
["claude-opus-4", { inputPer1M: 15, outputPer1M: 75 }],
|
|
6
|
+
["claude-sonnet-4", { inputPer1M: 3, outputPer1M: 15 }],
|
|
7
|
+
["claude-haiku-4", { inputPer1M: 0.80, outputPer1M: 4 }],
|
|
8
|
+
["claude-3-5-sonnet", { inputPer1M: 3, outputPer1M: 15 }],
|
|
9
|
+
["claude-3-5-haiku", { inputPer1M: 0.80, outputPer1M: 4 }],
|
|
10
|
+
["claude-3-opus", { inputPer1M: 15, outputPer1M: 75 }],
|
|
11
|
+
// OpenAI
|
|
12
|
+
["gpt-5", { inputPer1M: 2.50, outputPer1M: 10 }],
|
|
13
|
+
["gpt-4.1", { inputPer1M: 2, outputPer1M: 8 }],
|
|
14
|
+
["gpt-4o", { inputPer1M: 2.50, outputPer1M: 10 }],
|
|
15
|
+
["gpt-4-turbo", { inputPer1M: 10, outputPer1M: 30 }],
|
|
16
|
+
["gpt-4", { inputPer1M: 30, outputPer1M: 60 }],
|
|
17
|
+
["o3", { inputPer1M: 2, outputPer1M: 8 }],
|
|
18
|
+
["o4-mini", { inputPer1M: 1.10, outputPer1M: 4.40 }],
|
|
19
|
+
// OpenRouter prefixed
|
|
20
|
+
["anthropic/claude-opus-4", { inputPer1M: 15, outputPer1M: 75 }],
|
|
21
|
+
["anthropic/claude-sonnet-4", { inputPer1M: 3, outputPer1M: 15 }],
|
|
22
|
+
["anthropic/claude-haiku-4", { inputPer1M: 0.80, outputPer1M: 4 }],
|
|
23
|
+
["openai/gpt-4o", { inputPer1M: 2.50, outputPer1M: 10 }],
|
|
24
|
+
// Local (free)
|
|
25
|
+
["ollama", { inputPer1M: 0, outputPer1M: 0 }],
|
|
26
|
+
];
|
|
27
|
+
PRICING.sort((a, b) => b[0].length - a[0].length); // longest prefix first
|
|
28
|
+
function lookupPricing(model) {
|
|
29
|
+
const lower = model.toLowerCase();
|
|
30
|
+
for (const [prefix, pricing] of PRICING) {
|
|
31
|
+
if (lower.startsWith(prefix))
|
|
32
|
+
return pricing;
|
|
33
|
+
}
|
|
34
|
+
// Default fallback — assume mid-tier pricing
|
|
35
|
+
return { inputPer1M: 3, outputPer1M: 15 };
|
|
36
|
+
}
|
|
37
|
+
export function createCostTracker(model, budget = null) {
|
|
38
|
+
const pricing = lookupPricing(model);
|
|
39
|
+
const tracker = {
|
|
40
|
+
totalInputTokens: 0,
|
|
41
|
+
totalOutputTokens: 0,
|
|
42
|
+
totalCost: 0,
|
|
43
|
+
budget,
|
|
44
|
+
recordUsage(inputTokens, outputTokens) {
|
|
45
|
+
tracker.totalInputTokens += inputTokens;
|
|
46
|
+
tracker.totalOutputTokens += outputTokens;
|
|
47
|
+
tracker.totalCost +=
|
|
48
|
+
(inputTokens / 1_000_000) * pricing.inputPer1M +
|
|
49
|
+
(outputTokens / 1_000_000) * pricing.outputPer1M;
|
|
50
|
+
},
|
|
51
|
+
isOverBudget() {
|
|
52
|
+
return budget !== null && tracker.totalCost >= budget;
|
|
53
|
+
},
|
|
54
|
+
formatCost() {
|
|
55
|
+
const cost = tracker.totalCost < 0.01
|
|
56
|
+
? `$${tracker.totalCost.toFixed(4)}`
|
|
57
|
+
: `$${tracker.totalCost.toFixed(2)}`;
|
|
58
|
+
const tokens = `${tracker.totalInputTokens + tracker.totalOutputTokens} tokens`;
|
|
59
|
+
const budgetStr = budget !== null ? ` / $${budget.toFixed(2)} budget` : "";
|
|
60
|
+
return `${cost} (${tokens}${budgetStr})`;
|
|
61
|
+
},
|
|
62
|
+
formatTurnCost(inputTokens, outputTokens) {
|
|
63
|
+
const turnCost = (inputTokens / 1_000_000) * pricing.inputPer1M +
|
|
64
|
+
(outputTokens / 1_000_000) * pricing.outputPer1M;
|
|
65
|
+
return turnCost < 0.01
|
|
66
|
+
? `$${turnCost.toFixed(4)}`
|
|
67
|
+
: `$${turnCost.toFixed(2)}`;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return tracker;
|
|
71
|
+
}
|