@phren/agent 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/agent-loop/index.js +214 -0
  2. package/dist/agent-loop/stream.js +124 -0
  3. package/dist/agent-loop/types.js +13 -0
  4. package/dist/agent-loop.js +7 -333
  5. package/dist/commands/info.js +146 -0
  6. package/dist/commands/memory.js +165 -0
  7. package/dist/commands/model.js +138 -0
  8. package/dist/commands/session.js +213 -0
  9. package/dist/commands.js +24 -643
  10. package/dist/index.js +9 -4
  11. package/dist/mcp-client.js +11 -7
  12. package/dist/multi/multi-commands.js +170 -0
  13. package/dist/multi/multi-events.js +81 -0
  14. package/dist/multi/multi-render.js +146 -0
  15. package/dist/multi/pane.js +28 -0
  16. package/dist/multi/tui-multi.js +39 -454
  17. package/dist/permissions/allowlist.js +2 -2
  18. package/dist/providers/anthropic.js +4 -2
  19. package/dist/providers/codex.js +9 -4
  20. package/dist/providers/openai-compat.js +6 -1
  21. package/dist/tools/glob.js +30 -6
  22. package/dist/tui/ansi.js +48 -0
  23. package/dist/tui/components/AgentMessage.js +5 -0
  24. package/dist/tui/components/App.js +70 -0
  25. package/dist/tui/components/Banner.js +44 -0
  26. package/dist/tui/components/ChatMessage.js +23 -0
  27. package/dist/tui/components/InputArea.js +23 -0
  28. package/dist/tui/components/Separator.js +7 -0
  29. package/dist/tui/components/StatusBar.js +25 -0
  30. package/dist/tui/components/SteerQueue.js +7 -0
  31. package/dist/tui/components/StreamingText.js +5 -0
  32. package/dist/tui/components/ThinkingIndicator.js +20 -0
  33. package/dist/tui/components/ToolCall.js +11 -0
  34. package/dist/tui/components/UserMessage.js +5 -0
  35. package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
  36. package/dist/tui/hooks/useSlashCommands.js +52 -0
  37. package/dist/tui/index.js +5 -0
  38. package/dist/tui/ink-entry.js +271 -0
  39. package/dist/tui/menu-mode.js +86 -0
  40. package/dist/tui/tool-render.js +43 -0
  41. package/dist/tui.js +149 -280
  42. package/package.json +9 -2
@@ -0,0 +1,138 @@
1
+ import { listPresets, loadPreset, savePreset, deletePreset, formatPreset } from "../multi/presets.js";
2
+ import { showModelPicker } from "../multi/model-picker.js";
3
+ import { formatProviderList, formatModelAddHelp, addCustomModel, removeCustomModel } from "../multi/provider-manager.js";
4
+ const DIM = "\x1b[2m";
5
+ const GREEN = "\x1b[32m";
6
+ const RESET = "\x1b[0m";
7
+ export function modelCommand(parts, ctx) {
8
+ const sub = parts[1]?.toLowerCase();
9
+ // /model add <id> [provider=X] [context=N] [reasoning=X]
10
+ if (sub === "add") {
11
+ const modelId = parts[2];
12
+ if (!modelId) {
13
+ process.stderr.write(formatModelAddHelp() + "\n");
14
+ return true;
15
+ }
16
+ let provider = ctx.providerName ?? "openrouter";
17
+ let contextWindow = 128_000;
18
+ let reasoning = null;
19
+ const reasoningRange = [];
20
+ for (const arg of parts.slice(3)) {
21
+ const [k, v] = arg.split("=", 2);
22
+ if (k === "provider")
23
+ provider = v;
24
+ else if (k === "context")
25
+ contextWindow = parseInt(v, 10) || 128_000;
26
+ else if (k === "reasoning") {
27
+ reasoning = v;
28
+ reasoningRange.push("low", "medium", "high");
29
+ if (v === "max")
30
+ reasoningRange.push("max");
31
+ }
32
+ }
33
+ addCustomModel(modelId, provider, { contextWindow, reasoning, reasoningRange });
34
+ process.stderr.write(`${GREEN}-> Added ${modelId} to ${provider}${RESET}\n`);
35
+ return true;
36
+ }
37
+ // /model remove <id>
38
+ if (sub === "remove" || sub === "rm") {
39
+ const modelId = parts[2];
40
+ if (!modelId) {
41
+ process.stderr.write(`${DIM}Usage: /model remove <model-id>${RESET}\n`);
42
+ return true;
43
+ }
44
+ const ok = removeCustomModel(modelId);
45
+ process.stderr.write(ok ? `${GREEN}-> Removed ${modelId}${RESET}\n` : `${DIM}Model "${modelId}" not found in custom models.${RESET}\n`);
46
+ return true;
47
+ }
48
+ // /model (no sub) -- interactive picker
49
+ if (!ctx.providerName) {
50
+ process.stderr.write(`${DIM}Provider not configured. Start with --provider to set one.${RESET}\n`);
51
+ return true;
52
+ }
53
+ showModelPicker(ctx.providerName, ctx.currentModel, process.stdout).then((result) => {
54
+ if (result && ctx.onModelChange) {
55
+ ctx.onModelChange(result);
56
+ const reasoningLabel = result.reasoning ? ` (reasoning: ${result.reasoning})` : "";
57
+ process.stderr.write(`${GREEN}-> ${result.model}${reasoningLabel}${RESET}\n`);
58
+ }
59
+ else if (result) {
60
+ process.stderr.write(`${DIM}Model selected: ${result.model} -- restart to apply.${RESET}\n`);
61
+ }
62
+ });
63
+ return true;
64
+ }
65
+ export function providerCommand(_parts, _ctx) {
66
+ process.stderr.write(formatProviderList());
67
+ return true;
68
+ }
69
+ export function presetCommand(parts, _ctx) {
70
+ const sub = parts[1]?.toLowerCase();
71
+ if (!sub || sub === "list") {
72
+ const all = listPresets();
73
+ if (all.length === 0) {
74
+ process.stderr.write(`${DIM}No presets.${RESET}\n`);
75
+ }
76
+ else {
77
+ const lines = all.map((p) => ` ${formatPreset(p.name, p.preset, p.builtin)}`);
78
+ process.stderr.write(`${DIM}Presets:\n${lines.join("\n")}${RESET}\n`);
79
+ }
80
+ return true;
81
+ }
82
+ if (sub === "save") {
83
+ const presetName = parts[2];
84
+ if (!presetName) {
85
+ process.stderr.write(`${DIM}Usage: /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]${RESET}\n`);
86
+ return true;
87
+ }
88
+ const preset = {};
89
+ for (const arg of parts.slice(3)) {
90
+ const [k, v] = arg.split("=", 2);
91
+ if (k === "provider")
92
+ preset.provider = v;
93
+ else if (k === "model")
94
+ preset.model = v;
95
+ else if (k === "permissions")
96
+ preset.permissions = v;
97
+ else if (k === "max-turns")
98
+ preset.maxTurns = parseInt(v, 10) || undefined;
99
+ else if (k === "budget")
100
+ preset.budget = v === "none" ? null : parseFloat(v) || undefined;
101
+ else if (k === "plan")
102
+ preset.plan = true;
103
+ }
104
+ try {
105
+ savePreset(presetName, preset);
106
+ process.stderr.write(`${DIM}Saved preset "${presetName}".${RESET}\n`);
107
+ }
108
+ catch (err) {
109
+ process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
110
+ }
111
+ return true;
112
+ }
113
+ if (sub === "delete") {
114
+ const presetName = parts[2];
115
+ if (!presetName) {
116
+ process.stderr.write(`${DIM}Usage: /preset delete <name>${RESET}\n`);
117
+ return true;
118
+ }
119
+ try {
120
+ const ok = deletePreset(presetName);
121
+ process.stderr.write(`${DIM}${ok ? `Deleted "${presetName}".` : `Preset "${presetName}" not found.`}${RESET}\n`);
122
+ }
123
+ catch (err) {
124
+ process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
125
+ }
126
+ return true;
127
+ }
128
+ // /preset <name> -- show preset details
129
+ const preset = loadPreset(sub);
130
+ if (!preset) {
131
+ process.stderr.write(`${DIM}Preset "${sub}" not found. Use /preset list to see available presets.${RESET}\n`);
132
+ }
133
+ else {
134
+ const isBuiltin = ["fast", "careful", "yolo"].includes(sub);
135
+ process.stderr.write(`${DIM}${formatPreset(sub, preset, isBuiltin)}\nUse: phren-agent --preset ${sub} <task>${RESET}\n`);
136
+ }
137
+ return true;
138
+ }
@@ -0,0 +1,213 @@
1
+ import { estimateMessageTokens } from "../context/token-counter.js";
2
+ import { pruneMessages } from "../context/pruner.js";
3
+ import { renderMarkdown } from "../multi/markdown.js";
4
+ import { saveSessionMessages } from "../memory/session.js";
5
+ import { execSync } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import * as os from "node:os";
9
+ const DIM = "\x1b[2m";
10
+ const BOLD = "\x1b[1m";
11
+ const CYAN = "\x1b[36m";
12
+ const GREEN = "\x1b[32m";
13
+ const RED = "\x1b[31m";
14
+ const YELLOW = "\x1b[33m";
15
+ const RESET = "\x1b[0m";
16
+ const HISTORY_MAX_LINES = 5;
17
+ /** Format elapsed milliseconds as human-readable duration. */
18
+ function formatElapsed(ms) {
19
+ const secs = Math.floor(ms / 1000);
20
+ if (secs < 60)
21
+ return `${secs}s`;
22
+ const mins = Math.floor(secs / 60);
23
+ if (mins < 60)
24
+ return `${mins}m ${secs % 60}s`;
25
+ const hrs = Math.floor(mins / 60);
26
+ return `${hrs}h ${mins % 60}m`;
27
+ }
28
+ /** Truncate text to N lines, appending [+M lines] if overflow. */
29
+ function truncateText(text, maxLines) {
30
+ const lines = text.split("\n");
31
+ if (lines.length <= maxLines)
32
+ return text;
33
+ const overflow = lines.length - maxLines;
34
+ return lines.slice(0, maxLines).join("\n") + `\n${DIM}[+${overflow} lines]${RESET}`;
35
+ }
36
+ export function sessionCommand(parts, ctx) {
37
+ const sub = parts[1]?.toLowerCase();
38
+ if (sub === "save") {
39
+ if (!ctx.phrenPath || !ctx.sessionId) {
40
+ process.stderr.write(`${DIM}No active phren session to save.${RESET}\n`);
41
+ return true;
42
+ }
43
+ try {
44
+ saveSessionMessages(ctx.phrenPath, ctx.sessionId, ctx.session.messages);
45
+ process.stderr.write(`${GREEN}-> Checkpoint saved (${ctx.session.messages.length} messages)${RESET}\n`);
46
+ }
47
+ catch (err) {
48
+ process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
49
+ }
50
+ return true;
51
+ }
52
+ if (sub === "export") {
53
+ const exportDir = path.join(os.homedir(), ".phren-agent", "exports");
54
+ fs.mkdirSync(exportDir, { recursive: true });
55
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
56
+ const exportFile = path.join(exportDir, `session-${ts}.json`);
57
+ try {
58
+ fs.writeFileSync(exportFile, JSON.stringify(ctx.session.messages, null, 2) + "\n");
59
+ process.stderr.write(`${GREEN}-> Exported to ${exportFile}${RESET}\n`);
60
+ }
61
+ catch (err) {
62
+ process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
63
+ }
64
+ return true;
65
+ }
66
+ // Default: show session info
67
+ const duration = ctx.startTime ? formatElapsed(Date.now() - ctx.startTime) : "unknown";
68
+ const lines = [];
69
+ if (ctx.sessionId)
70
+ lines.push(` Session: ${ctx.sessionId}`);
71
+ lines.push(` Turns: ${ctx.session.turns}`);
72
+ lines.push(` Tools: ${ctx.session.toolCalls}`);
73
+ lines.push(` Messages: ${ctx.session.messages.length}`);
74
+ lines.push(` Duration: ${duration}`);
75
+ if (ctx.phrenPath && ctx.sessionId) {
76
+ try {
77
+ const stateFile = path.join(ctx.phrenPath, ".runtime", "sessions", `session-${ctx.sessionId}.json`);
78
+ const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
79
+ lines.push(` Findings: ${state.findingsAdded ?? 0}`);
80
+ lines.push(` Tasks: ${state.tasksCompleted ?? 0}`);
81
+ }
82
+ catch { /* session file may not exist */ }
83
+ }
84
+ process.stderr.write(`${DIM}${lines.join("\n")}${RESET}\n`);
85
+ return true;
86
+ }
87
+ export function historyCommand(parts, ctx) {
88
+ const msgs = ctx.session.messages;
89
+ if (msgs.length === 0) {
90
+ process.stderr.write(`${DIM}No messages yet.${RESET}\n`);
91
+ return true;
92
+ }
93
+ const arg = parts[1];
94
+ const isFull = arg === "full";
95
+ const count = isFull ? msgs.length : Math.min(parseInt(arg, 10) || 10, msgs.length);
96
+ const slice = msgs.slice(-count);
97
+ const tokens = estimateMessageTokens(msgs);
98
+ const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
99
+ process.stderr.write(`${DIM}-- History (${slice.length}/${msgs.length} messages, ~${tokens} tokens, ${pct}% context) --${RESET}\n`);
100
+ for (const msg of slice) {
101
+ if (msg.role === "user") {
102
+ if (typeof msg.content === "string") {
103
+ const truncated = truncateText(msg.content, isFull ? Infinity : HISTORY_MAX_LINES);
104
+ process.stderr.write(`\n${CYAN}${BOLD}You:${RESET} ${truncated}\n`);
105
+ }
106
+ else {
107
+ for (const block of msg.content) {
108
+ if (block.type === "tool_result") {
109
+ const icon = block.is_error ? `${RED}\u2717${RESET}` : `${GREEN}\u2713${RESET}`;
110
+ const preview = (block.content ?? "").slice(0, 80).replace(/\n/g, " ");
111
+ process.stderr.write(`${DIM} ${icon} tool_result ${preview}${preview.length >= 80 ? "..." : ""}${RESET}\n`);
112
+ }
113
+ else if (block.type === "text") {
114
+ process.stderr.write(`${DIM} ${block.text.slice(0, 100)}${RESET}\n`);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ else if (msg.role === "assistant") {
120
+ if (typeof msg.content === "string") {
121
+ const rendered = isFull ? renderMarkdown(msg.content) : truncateText(msg.content, HISTORY_MAX_LINES);
122
+ process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
123
+ }
124
+ else {
125
+ for (const block of msg.content) {
126
+ if (block.type === "text") {
127
+ const text = block.text;
128
+ const rendered = isFull ? renderMarkdown(text) : truncateText(text, HISTORY_MAX_LINES);
129
+ process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
130
+ }
131
+ else if (block.type === "tool_use") {
132
+ const tb = block;
133
+ const inputPreview = JSON.stringify(tb.input).slice(0, 60);
134
+ process.stderr.write(`${YELLOW} \u26A1 ${tb.name}${RESET}${DIM}(${inputPreview})${RESET}\n`);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ process.stderr.write(`${DIM}-- end --${RESET}\n`);
141
+ return true;
142
+ }
143
+ export function compactCommand(_parts, ctx) {
144
+ const beforeCount = ctx.session.messages.length;
145
+ const beforeTokens = estimateMessageTokens(ctx.session.messages);
146
+ ctx.session.messages = pruneMessages(ctx.session.messages, { contextLimit: ctx.contextLimit, keepRecentTurns: 4 });
147
+ const afterCount = ctx.session.messages.length;
148
+ const afterTokens = estimateMessageTokens(ctx.session.messages);
149
+ const reduction = beforeTokens > 0 ? ((1 - afterTokens / beforeTokens) * 100).toFixed(0) : "0";
150
+ const fmtBefore = beforeTokens >= 1000 ? `${(beforeTokens / 1000).toFixed(1)}k` : String(beforeTokens);
151
+ const fmtAfter = afterTokens >= 1000 ? `${(afterTokens / 1000).toFixed(1)}k` : String(afterTokens);
152
+ process.stderr.write(`${DIM}Compacted: ${beforeCount} -> ${afterCount} messages (~${fmtBefore} -> ~${fmtAfter} tokens, ${reduction}% reduction)${RESET}\n`);
153
+ return true;
154
+ }
155
+ export function diffCommand(parts, _ctx) {
156
+ const staged = parts.includes("--staged") || parts.includes("--cached");
157
+ const cmd = staged ? "git diff --staged" : "git diff";
158
+ try {
159
+ const raw = execSync(cmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
160
+ if (!raw.trim()) {
161
+ process.stderr.write(`${DIM}No ${staged ? "staged " : ""}changes.${RESET}\n`);
162
+ }
163
+ else {
164
+ const colored = raw.split("\n").map((line) => {
165
+ if (line.startsWith("diff --git"))
166
+ return `${BOLD}${line}${RESET}`;
167
+ if (line.startsWith("@@"))
168
+ return `${CYAN}${line}${RESET}`;
169
+ if (line.startsWith("+"))
170
+ return `${GREEN}${line}${RESET}`;
171
+ if (line.startsWith("-"))
172
+ return `${RED}${line}${RESET}`;
173
+ return line;
174
+ }).join("\n");
175
+ process.stderr.write(colored + "\n");
176
+ }
177
+ }
178
+ catch (err) {
179
+ const e = err;
180
+ process.stderr.write(`${RED}${e.stderr || e.message || "git diff failed"}${RESET}\n`);
181
+ }
182
+ return true;
183
+ }
184
+ export function gitCommand(parts, _ctx) {
185
+ const sub = parts.slice(1).join(" ").trim();
186
+ if (!sub) {
187
+ process.stderr.write(`${DIM}Usage: /git <status|log|stash|stash pop>${RESET}\n`);
188
+ return true;
189
+ }
190
+ const allowed = {
191
+ "status": "git status",
192
+ "log": "git log --oneline -5",
193
+ "stash": "git stash",
194
+ "stash pop": "git stash pop",
195
+ };
196
+ const gitCmd = allowed[sub];
197
+ if (!gitCmd) {
198
+ process.stderr.write(`${DIM}Supported: /git status, /git log, /git stash, /git stash pop${RESET}\n`);
199
+ return true;
200
+ }
201
+ try {
202
+ const output = execSync(gitCmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
203
+ if (output.trim())
204
+ process.stderr.write(output.endsWith("\n") ? output : output + "\n");
205
+ else
206
+ process.stderr.write(`${DIM}(no output)${RESET}\n`);
207
+ }
208
+ catch (err) {
209
+ const e = err;
210
+ process.stderr.write(`${RED}${e.stderr || e.message || "git command failed"}${RESET}\n`);
211
+ }
212
+ return true;
213
+ }