@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.
Files changed (61) hide show
  1. package/dist/agent-loop.js +328 -0
  2. package/dist/bin.js +3 -0
  3. package/dist/checkpoint.js +103 -0
  4. package/dist/commands.js +292 -0
  5. package/dist/config.js +139 -0
  6. package/dist/context/pruner.js +62 -0
  7. package/dist/context/token-counter.js +28 -0
  8. package/dist/cost.js +71 -0
  9. package/dist/index.js +284 -0
  10. package/dist/mcp-client.js +168 -0
  11. package/dist/memory/anti-patterns.js +69 -0
  12. package/dist/memory/auto-capture.js +72 -0
  13. package/dist/memory/context-flush.js +24 -0
  14. package/dist/memory/context.js +170 -0
  15. package/dist/memory/error-recovery.js +58 -0
  16. package/dist/memory/project-context.js +77 -0
  17. package/dist/memory/session.js +100 -0
  18. package/dist/multi/agent-colors.js +41 -0
  19. package/dist/multi/child-entry.js +173 -0
  20. package/dist/multi/coordinator.js +263 -0
  21. package/dist/multi/diff-renderer.js +175 -0
  22. package/dist/multi/markdown.js +96 -0
  23. package/dist/multi/presets.js +107 -0
  24. package/dist/multi/progress.js +32 -0
  25. package/dist/multi/spawner.js +219 -0
  26. package/dist/multi/tui-multi.js +626 -0
  27. package/dist/multi/types.js +7 -0
  28. package/dist/permissions/allowlist.js +61 -0
  29. package/dist/permissions/checker.js +111 -0
  30. package/dist/permissions/prompt.js +190 -0
  31. package/dist/permissions/sandbox.js +95 -0
  32. package/dist/permissions/shell-safety.js +74 -0
  33. package/dist/permissions/types.js +2 -0
  34. package/dist/plan.js +38 -0
  35. package/dist/providers/anthropic.js +170 -0
  36. package/dist/providers/codex-auth.js +197 -0
  37. package/dist/providers/codex.js +265 -0
  38. package/dist/providers/ollama.js +142 -0
  39. package/dist/providers/openai-compat.js +163 -0
  40. package/dist/providers/openrouter.js +116 -0
  41. package/dist/providers/resolve.js +39 -0
  42. package/dist/providers/retry.js +55 -0
  43. package/dist/providers/types.js +2 -0
  44. package/dist/repl.js +180 -0
  45. package/dist/spinner.js +46 -0
  46. package/dist/system-prompt.js +31 -0
  47. package/dist/tools/edit-file.js +31 -0
  48. package/dist/tools/git.js +98 -0
  49. package/dist/tools/glob.js +65 -0
  50. package/dist/tools/grep.js +108 -0
  51. package/dist/tools/lint-test.js +76 -0
  52. package/dist/tools/phren-finding.js +35 -0
  53. package/dist/tools/phren-search.js +44 -0
  54. package/dist/tools/phren-tasks.js +71 -0
  55. package/dist/tools/read-file.js +44 -0
  56. package/dist/tools/registry.js +46 -0
  57. package/dist/tools/shell.js +48 -0
  58. package/dist/tools/types.js +2 -0
  59. package/dist/tools/write-file.js +27 -0
  60. package/dist/tui.js +451 -0
  61. package/package.json +39 -0
@@ -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
+ }