@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.
- package/dist/agent-loop/index.js +214 -0
- package/dist/agent-loop/stream.js +124 -0
- package/dist/agent-loop/types.js +13 -0
- package/dist/agent-loop.js +7 -333
- package/dist/commands/info.js +146 -0
- package/dist/commands/memory.js +165 -0
- package/dist/commands/model.js +138 -0
- package/dist/commands/session.js +213 -0
- package/dist/commands.js +24 -643
- package/dist/index.js +9 -4
- package/dist/mcp-client.js +11 -7
- package/dist/multi/multi-commands.js +170 -0
- package/dist/multi/multi-events.js +81 -0
- package/dist/multi/multi-render.js +146 -0
- package/dist/multi/pane.js +28 -0
- package/dist/multi/tui-multi.js +39 -454
- package/dist/permissions/allowlist.js +2 -2
- package/dist/providers/anthropic.js +4 -2
- package/dist/providers/codex.js +9 -4
- package/dist/providers/openai-compat.js +6 -1
- package/dist/tools/glob.js +30 -6
- package/dist/tui/ansi.js +48 -0
- package/dist/tui/components/AgentMessage.js +5 -0
- package/dist/tui/components/App.js +70 -0
- package/dist/tui/components/Banner.js +44 -0
- package/dist/tui/components/ChatMessage.js +23 -0
- package/dist/tui/components/InputArea.js +23 -0
- package/dist/tui/components/Separator.js +7 -0
- package/dist/tui/components/StatusBar.js +25 -0
- package/dist/tui/components/SteerQueue.js +7 -0
- package/dist/tui/components/StreamingText.js +5 -0
- package/dist/tui/components/ThinkingIndicator.js +20 -0
- package/dist/tui/components/ToolCall.js +11 -0
- package/dist/tui/components/UserMessage.js +5 -0
- package/dist/tui/hooks/useKeyboardShortcuts.js +89 -0
- package/dist/tui/hooks/useSlashCommands.js +52 -0
- package/dist/tui/index.js +5 -0
- package/dist/tui/ink-entry.js +271 -0
- package/dist/tui/menu-mode.js +86 -0
- package/dist/tui/tool-render.js +43 -0
- package/dist/tui.js +149 -280
- package/package.json +9 -2
package/dist/commands.js
CHANGED
|
@@ -1,27 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { formatProviderList, formatModelAddHelp, addCustomModel, removeCustomModel } from "./multi/provider-manager.js";
|
|
7
|
-
import { execSync } from "node:child_process";
|
|
8
|
-
import * as fs from "node:fs";
|
|
9
|
-
import * as path from "node:path";
|
|
10
|
-
import * as os from "node:os";
|
|
11
|
-
import { saveSessionMessages } from "./memory/session.js";
|
|
12
|
-
import { buildIndex } from "@phren/cli/shared";
|
|
13
|
-
import { searchKnowledgeRows, rankResults } from "@phren/cli/shared/retrieval";
|
|
14
|
-
import { readFindings } from "@phren/cli/data/access";
|
|
15
|
-
import { readTasks } from "@phren/cli/data/tasks";
|
|
16
|
-
import { addFinding } from "@phren/cli/core/finding";
|
|
1
|
+
// Sub-module handlers
|
|
2
|
+
import { helpCommand, turnsCommand, clearCommand, cwdCommand, filesCommand, costCommand, planCommand, undoCommand, contextCommand } from "./commands/info.js";
|
|
3
|
+
import { sessionCommand, historyCommand, compactCommand, diffCommand, gitCommand } from "./commands/session.js";
|
|
4
|
+
import { memCommand, askCommand } from "./commands/memory.js";
|
|
5
|
+
import { modelCommand, providerCommand, presetCommand } from "./commands/model.js";
|
|
17
6
|
const DIM = "\x1b[2m";
|
|
18
|
-
const BOLD = "\x1b[1m";
|
|
19
|
-
const CYAN = "\x1b[36m";
|
|
20
|
-
const GREEN = "\x1b[32m";
|
|
21
|
-
const RED = "\x1b[31m";
|
|
22
|
-
const YELLOW = "\x1b[33m";
|
|
23
7
|
const RESET = "\x1b[0m";
|
|
24
|
-
const HISTORY_MAX_LINES = 5;
|
|
25
8
|
export function createCommandContext(session, contextLimit) {
|
|
26
9
|
return {
|
|
27
10
|
session,
|
|
@@ -29,25 +12,6 @@ export function createCommandContext(session, contextLimit) {
|
|
|
29
12
|
undoStack: [],
|
|
30
13
|
};
|
|
31
14
|
}
|
|
32
|
-
/** Format elapsed milliseconds as human-readable duration. */
|
|
33
|
-
function formatElapsed(ms) {
|
|
34
|
-
const secs = Math.floor(ms / 1000);
|
|
35
|
-
if (secs < 60)
|
|
36
|
-
return `${secs}s`;
|
|
37
|
-
const mins = Math.floor(secs / 60);
|
|
38
|
-
if (mins < 60)
|
|
39
|
-
return `${mins}m ${secs % 60}s`;
|
|
40
|
-
const hrs = Math.floor(mins / 60);
|
|
41
|
-
return `${hrs}h ${mins % 60}m`;
|
|
42
|
-
}
|
|
43
|
-
/** Truncate text to N lines, appending [+M lines] if overflow. */
|
|
44
|
-
function truncateText(text, maxLines) {
|
|
45
|
-
const lines = text.split("\n");
|
|
46
|
-
if (lines.length <= maxLines)
|
|
47
|
-
return text;
|
|
48
|
-
const overflow = lines.length - maxLines;
|
|
49
|
-
return lines.slice(0, maxLines).join("\n") + `\n${DIM}[+${overflow} lines]${RESET}`;
|
|
50
|
-
}
|
|
51
15
|
/**
|
|
52
16
|
* Try to handle a slash command. Returns true if the input was a command.
|
|
53
17
|
* Returns a Promise<boolean> for async commands like /ask.
|
|
@@ -56,273 +20,25 @@ export function handleCommand(input, ctx) {
|
|
|
56
20
|
const parts = input.trim().split(/\s+/);
|
|
57
21
|
const name = parts[0];
|
|
58
22
|
switch (name) {
|
|
59
|
-
case "/help":
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/agents List running agents
|
|
79
|
-
/session Show session info (id, duration, stats)
|
|
80
|
-
/session save Save conversation checkpoint
|
|
81
|
-
/session export Export conversation as JSON
|
|
82
|
-
/diff [--staged] Show git diff with syntax highlighting
|
|
83
|
-
/git <cmd> Run common git commands (status, log, stash, stash pop)
|
|
84
|
-
/ask <question> Quick LLM query (no tools, not added to session)
|
|
85
|
-
/mem search <query> Search phren memory directly
|
|
86
|
-
/mem findings [project] Show recent findings
|
|
87
|
-
/mem tasks [project] Show tasks
|
|
88
|
-
/mem add <finding> Quick-add a finding
|
|
89
|
-
/preset [name|save|delete|list] Config presets
|
|
90
|
-
/exit Exit the REPL${RESET}\n`);
|
|
91
|
-
return true;
|
|
92
|
-
case "/model": {
|
|
93
|
-
const sub = parts[1]?.toLowerCase();
|
|
94
|
-
// /model add <id> [provider=X] [context=N] [reasoning=X]
|
|
95
|
-
if (sub === "add") {
|
|
96
|
-
const modelId = parts[2];
|
|
97
|
-
if (!modelId) {
|
|
98
|
-
process.stderr.write(formatModelAddHelp() + "\n");
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
let provider = ctx.providerName ?? "openrouter";
|
|
102
|
-
let contextWindow = 128_000;
|
|
103
|
-
let reasoning = null;
|
|
104
|
-
const reasoningRange = [];
|
|
105
|
-
for (const arg of parts.slice(3)) {
|
|
106
|
-
const [k, v] = arg.split("=", 2);
|
|
107
|
-
if (k === "provider")
|
|
108
|
-
provider = v;
|
|
109
|
-
else if (k === "context")
|
|
110
|
-
contextWindow = parseInt(v, 10) || 128_000;
|
|
111
|
-
else if (k === "reasoning") {
|
|
112
|
-
reasoning = v;
|
|
113
|
-
reasoningRange.push("low", "medium", "high");
|
|
114
|
-
if (v === "max")
|
|
115
|
-
reasoningRange.push("max");
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
addCustomModel(modelId, provider, { contextWindow, reasoning, reasoningRange });
|
|
119
|
-
process.stderr.write(`${GREEN}→ Added ${modelId} to ${provider}${RESET}\n`);
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
// /model remove <id>
|
|
123
|
-
if (sub === "remove" || sub === "rm") {
|
|
124
|
-
const modelId = parts[2];
|
|
125
|
-
if (!modelId) {
|
|
126
|
-
process.stderr.write(`${DIM}Usage: /model remove <model-id>${RESET}\n`);
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
const ok = removeCustomModel(modelId);
|
|
130
|
-
process.stderr.write(ok ? `${GREEN}→ Removed ${modelId}${RESET}\n` : `${DIM}Model "${modelId}" not found in custom models.${RESET}\n`);
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
// /model (no sub) — interactive picker
|
|
134
|
-
if (!ctx.providerName) {
|
|
135
|
-
process.stderr.write(`${DIM}Provider not configured. Start with --provider to set one.${RESET}\n`);
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
showModelPicker(ctx.providerName, ctx.currentModel, process.stdout).then((result) => {
|
|
139
|
-
if (result && ctx.onModelChange) {
|
|
140
|
-
ctx.onModelChange(result);
|
|
141
|
-
const reasoningLabel = result.reasoning ? ` (reasoning: ${result.reasoning})` : "";
|
|
142
|
-
process.stderr.write(`${GREEN}→ ${result.model}${reasoningLabel}${RESET}\n`);
|
|
143
|
-
}
|
|
144
|
-
else if (result) {
|
|
145
|
-
process.stderr.write(`${DIM}Model selected: ${result.model} — restart to apply.${RESET}\n`);
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
case "/provider": {
|
|
151
|
-
process.stderr.write(formatProviderList());
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
case "/turns": {
|
|
155
|
-
const tokens = estimateMessageTokens(ctx.session.messages);
|
|
156
|
-
const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
|
|
157
|
-
const costLine = ctx.costTracker ? ` Cost: $${ctx.costTracker.totalCost.toFixed(4)}` : "";
|
|
158
|
-
process.stderr.write(`${DIM}Turns: ${ctx.session.turns} Tool calls: ${ctx.session.toolCalls} ` +
|
|
159
|
-
`Messages: ${ctx.session.messages.length} Tokens: ~${tokens} (${pct}%)${costLine}${RESET}\n`);
|
|
160
|
-
return true;
|
|
161
|
-
}
|
|
162
|
-
case "/clear":
|
|
163
|
-
ctx.session.messages.length = 0;
|
|
164
|
-
ctx.session.turns = 0;
|
|
165
|
-
ctx.session.toolCalls = 0;
|
|
166
|
-
ctx.undoStack.length = 0;
|
|
167
|
-
process.stdout.write("\x1b[2J\x1b[H"); // clear terminal screen
|
|
168
|
-
process.stderr.write(`${DIM}Conversation cleared.${RESET}\n`);
|
|
169
|
-
return true;
|
|
170
|
-
case "/cwd":
|
|
171
|
-
process.stderr.write(`${DIM}${process.cwd()}${RESET}\n`);
|
|
172
|
-
return true;
|
|
173
|
-
case "/files": {
|
|
174
|
-
try {
|
|
175
|
-
const countRaw = execSync("find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | wc -l", { encoding: "utf-8", timeout: 5_000, cwd: process.cwd() }).trim();
|
|
176
|
-
const total = parseInt(countRaw, 10) || 0;
|
|
177
|
-
const listRaw = execSync("find . -maxdepth 2 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | sort | head -30", { encoding: "utf-8", timeout: 5_000, cwd: process.cwd() }).trim();
|
|
178
|
-
if (!listRaw) {
|
|
179
|
-
process.stderr.write(`${DIM}No files found.${RESET}\n`);
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
const lines = listRaw.split("\n");
|
|
183
|
-
const label = total > lines.length ? `${total} files (showing first ${lines.length})` : `${total} files`;
|
|
184
|
-
process.stderr.write(`${DIM}${label}\n${listRaw}${RESET}\n`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
catch (err) {
|
|
188
|
-
const e = err;
|
|
189
|
-
process.stderr.write(`${RED}${e.stderr || e.message || "find failed"}${RESET}\n`);
|
|
190
|
-
}
|
|
191
|
-
return true;
|
|
192
|
-
}
|
|
193
|
-
case "/cost": {
|
|
194
|
-
const ct = ctx.costTracker;
|
|
195
|
-
if (ct) {
|
|
196
|
-
process.stderr.write(`${DIM}Tokens — input: ${ct.inputTokens} output: ${ct.outputTokens} est. cost: $${ct.totalCost.toFixed(4)}${RESET}\n`);
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
process.stderr.write(`${DIM}Cost tracking not available.${RESET}\n`);
|
|
200
|
-
}
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
case "/plan": {
|
|
204
|
-
const tools = [];
|
|
205
|
-
for (const msg of ctx.session.messages) {
|
|
206
|
-
if (typeof msg.content !== "string") {
|
|
207
|
-
for (const block of msg.content) {
|
|
208
|
-
if (block.type === "tool_use") {
|
|
209
|
-
tools.push(block.name);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (tools.length === 0) {
|
|
215
|
-
process.stderr.write(`${DIM}No tool calls yet.${RESET}\n`);
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
process.stderr.write(`${DIM}Tool calls (${tools.length}): ${tools.join(" → ")}${RESET}\n`);
|
|
219
|
-
}
|
|
220
|
-
return true;
|
|
221
|
-
}
|
|
222
|
-
case "/undo": {
|
|
223
|
-
if (ctx.session.messages.length < 2) {
|
|
224
|
-
process.stderr.write(`${DIM}Nothing to undo.${RESET}\n`);
|
|
225
|
-
return true;
|
|
226
|
-
}
|
|
227
|
-
// Remove messages back to the previous user message
|
|
228
|
-
let removed = 0;
|
|
229
|
-
while (ctx.session.messages.length > 0) {
|
|
230
|
-
const last = ctx.session.messages.pop();
|
|
231
|
-
removed++;
|
|
232
|
-
if (last?.role === "user" && typeof last.content === "string")
|
|
233
|
-
break;
|
|
234
|
-
}
|
|
235
|
-
process.stderr.write(`${DIM}Undid ${removed} messages.${RESET}\n`);
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
case "/history": {
|
|
239
|
-
const msgs = ctx.session.messages;
|
|
240
|
-
if (msgs.length === 0) {
|
|
241
|
-
process.stderr.write(`${DIM}No messages yet.${RESET}\n`);
|
|
242
|
-
return true;
|
|
243
|
-
}
|
|
244
|
-
const arg = parts[1];
|
|
245
|
-
const isFull = arg === "full";
|
|
246
|
-
const count = isFull ? msgs.length : Math.min(parseInt(arg, 10) || 10, msgs.length);
|
|
247
|
-
const slice = msgs.slice(-count);
|
|
248
|
-
const tokens = estimateMessageTokens(msgs);
|
|
249
|
-
const pct = ctx.contextLimit > 0 ? ((tokens / ctx.contextLimit) * 100).toFixed(1) : "?";
|
|
250
|
-
process.stderr.write(`${DIM}── History (${slice.length}/${msgs.length} messages, ~${tokens} tokens, ${pct}% context) ──${RESET}\n`);
|
|
251
|
-
for (const msg of slice) {
|
|
252
|
-
if (msg.role === "user") {
|
|
253
|
-
if (typeof msg.content === "string") {
|
|
254
|
-
const truncated = truncateText(msg.content, isFull ? Infinity : HISTORY_MAX_LINES);
|
|
255
|
-
process.stderr.write(`\n${CYAN}${BOLD}You:${RESET} ${truncated}\n`);
|
|
256
|
-
}
|
|
257
|
-
else {
|
|
258
|
-
// Tool results
|
|
259
|
-
for (const block of msg.content) {
|
|
260
|
-
if (block.type === "tool_result") {
|
|
261
|
-
const icon = block.is_error ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`;
|
|
262
|
-
const preview = (block.content ?? "").slice(0, 80).replace(/\n/g, " ");
|
|
263
|
-
process.stderr.write(`${DIM} ${icon} tool_result ${preview}${preview.length >= 80 ? "..." : ""}${RESET}\n`);
|
|
264
|
-
}
|
|
265
|
-
else if (block.type === "text") {
|
|
266
|
-
process.stderr.write(`${DIM} ${block.text.slice(0, 100)}${RESET}\n`);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
else if (msg.role === "assistant") {
|
|
272
|
-
if (typeof msg.content === "string") {
|
|
273
|
-
const rendered = isFull ? renderMarkdown(msg.content) : truncateText(msg.content, HISTORY_MAX_LINES);
|
|
274
|
-
process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
277
|
-
for (const block of msg.content) {
|
|
278
|
-
if (block.type === "text") {
|
|
279
|
-
const text = block.text;
|
|
280
|
-
const rendered = isFull ? renderMarkdown(text) : truncateText(text, HISTORY_MAX_LINES);
|
|
281
|
-
process.stderr.write(`\n${GREEN}${BOLD}Agent:${RESET}\n${rendered}\n`);
|
|
282
|
-
}
|
|
283
|
-
else if (block.type === "tool_use") {
|
|
284
|
-
const tb = block;
|
|
285
|
-
const inputPreview = JSON.stringify(tb.input).slice(0, 60);
|
|
286
|
-
process.stderr.write(`${YELLOW} ⚡ ${tb.name}${RESET}${DIM}(${inputPreview})${RESET}\n`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
process.stderr.write(`${DIM}── end ──${RESET}\n`);
|
|
293
|
-
return true;
|
|
294
|
-
}
|
|
295
|
-
case "/compact": {
|
|
296
|
-
const beforeCount = ctx.session.messages.length;
|
|
297
|
-
const beforeTokens = estimateMessageTokens(ctx.session.messages);
|
|
298
|
-
ctx.session.messages = pruneMessages(ctx.session.messages, { contextLimit: ctx.contextLimit, keepRecentTurns: 4 });
|
|
299
|
-
const afterCount = ctx.session.messages.length;
|
|
300
|
-
const afterTokens = estimateMessageTokens(ctx.session.messages);
|
|
301
|
-
const reduction = beforeTokens > 0 ? ((1 - afterTokens / beforeTokens) * 100).toFixed(0) : "0";
|
|
302
|
-
const fmtBefore = beforeTokens >= 1000 ? `${(beforeTokens / 1000).toFixed(1)}k` : String(beforeTokens);
|
|
303
|
-
const fmtAfter = afterTokens >= 1000 ? `${(afterTokens / 1000).toFixed(1)}k` : String(afterTokens);
|
|
304
|
-
process.stderr.write(`${DIM}Compacted: ${beforeCount} → ${afterCount} messages (~${fmtBefore} → ~${fmtAfter} tokens, ${reduction}% reduction)${RESET}\n`);
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
case "/context": {
|
|
308
|
-
const ctxTokens = estimateMessageTokens(ctx.session.messages);
|
|
309
|
-
const ctxPct = ctx.contextLimit > 0 ? (ctxTokens / ctx.contextLimit) * 100 : 0;
|
|
310
|
-
const ctxPctStr = ctxPct.toFixed(1);
|
|
311
|
-
const ctxWindowK = ctx.contextLimit >= 1000 ? `${(ctx.contextLimit / 1000).toFixed(0)}k` : String(ctx.contextLimit);
|
|
312
|
-
const ctxTokensStr = ctxTokens >= 1000 ? `~${(ctxTokens / 1000).toFixed(1)}k` : `~${ctxTokens}`;
|
|
313
|
-
// Progress bar: 10 chars wide
|
|
314
|
-
const filled = Math.round(ctxPct / 10);
|
|
315
|
-
const bar = "█".repeat(Math.min(filled, 10)) + "░".repeat(Math.max(10 - filled, 0));
|
|
316
|
-
const barColor = ctxPct > 80 ? RED : ctxPct > 50 ? YELLOW : GREEN;
|
|
317
|
-
const providerLabel = ctx.providerName ?? "unknown";
|
|
318
|
-
const modelLabel = ctx.currentModel ?? "default";
|
|
319
|
-
process.stderr.write(`${DIM} Messages: ${ctx.session.messages.length}\n` +
|
|
320
|
-
` Tokens: ${ctxTokensStr} / ${ctxWindowK} (${ctxPctStr}%)\n` +
|
|
321
|
-
` Provider: ${providerLabel} (${modelLabel})\n` +
|
|
322
|
-
` Context window: ${ctxWindowK}\n` +
|
|
323
|
-
` ${barColor}[${bar}]${RESET}${DIM} ${ctxPctStr}%${RESET}\n`);
|
|
324
|
-
return true;
|
|
325
|
-
}
|
|
23
|
+
case "/help": return helpCommand(parts, ctx);
|
|
24
|
+
case "/turns": return turnsCommand(parts, ctx);
|
|
25
|
+
case "/clear": return clearCommand(parts, ctx);
|
|
26
|
+
case "/cwd": return cwdCommand(parts, ctx);
|
|
27
|
+
case "/files": return filesCommand(parts, ctx);
|
|
28
|
+
case "/cost": return costCommand(parts, ctx);
|
|
29
|
+
case "/plan": return planCommand(parts, ctx);
|
|
30
|
+
case "/undo": return undoCommand(parts, ctx);
|
|
31
|
+
case "/context": return contextCommand(parts, ctx);
|
|
32
|
+
case "/model": return modelCommand(parts, ctx);
|
|
33
|
+
case "/provider": return providerCommand(parts, ctx);
|
|
34
|
+
case "/preset": return presetCommand(parts, ctx);
|
|
35
|
+
case "/session": return sessionCommand(parts, ctx);
|
|
36
|
+
case "/history": return historyCommand(parts, ctx);
|
|
37
|
+
case "/compact": return compactCommand(parts, ctx);
|
|
38
|
+
case "/diff": return diffCommand(parts, ctx);
|
|
39
|
+
case "/git": return gitCommand(parts, ctx);
|
|
40
|
+
case "/mem": return memCommand(parts, ctx);
|
|
41
|
+
case "/ask": return askCommand(parts, ctx);
|
|
326
42
|
case "/spawn": {
|
|
327
43
|
if (!ctx.spawner) {
|
|
328
44
|
process.stderr.write(`${DIM}Spawner not available. Start with --multi or --team to enable.${RESET}\n`);
|
|
@@ -358,341 +74,6 @@ export function handleCommand(input, ctx) {
|
|
|
358
74
|
}
|
|
359
75
|
return true;
|
|
360
76
|
}
|
|
361
|
-
case "/preset": {
|
|
362
|
-
const sub = parts[1]?.toLowerCase();
|
|
363
|
-
if (!sub || sub === "list") {
|
|
364
|
-
const all = listPresets();
|
|
365
|
-
if (all.length === 0) {
|
|
366
|
-
process.stderr.write(`${DIM}No presets.${RESET}\n`);
|
|
367
|
-
}
|
|
368
|
-
else {
|
|
369
|
-
const lines = all.map((p) => ` ${formatPreset(p.name, p.preset, p.builtin)}`);
|
|
370
|
-
process.stderr.write(`${DIM}Presets:\n${lines.join("\n")}${RESET}\n`);
|
|
371
|
-
}
|
|
372
|
-
return true;
|
|
373
|
-
}
|
|
374
|
-
if (sub === "save") {
|
|
375
|
-
// /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]
|
|
376
|
-
const presetName = parts[2];
|
|
377
|
-
if (!presetName) {
|
|
378
|
-
process.stderr.write(`${DIM}Usage: /preset save <name> [provider=X] [model=X] [permissions=X] [max-turns=N] [budget=N] [plan]${RESET}\n`);
|
|
379
|
-
return true;
|
|
380
|
-
}
|
|
381
|
-
const preset = {};
|
|
382
|
-
for (const arg of parts.slice(3)) {
|
|
383
|
-
const [k, v] = arg.split("=", 2);
|
|
384
|
-
if (k === "provider")
|
|
385
|
-
preset.provider = v;
|
|
386
|
-
else if (k === "model")
|
|
387
|
-
preset.model = v;
|
|
388
|
-
else if (k === "permissions")
|
|
389
|
-
preset.permissions = v;
|
|
390
|
-
else if (k === "max-turns")
|
|
391
|
-
preset.maxTurns = parseInt(v, 10) || undefined;
|
|
392
|
-
else if (k === "budget")
|
|
393
|
-
preset.budget = v === "none" ? null : parseFloat(v) || undefined;
|
|
394
|
-
else if (k === "plan")
|
|
395
|
-
preset.plan = true;
|
|
396
|
-
}
|
|
397
|
-
try {
|
|
398
|
-
savePreset(presetName, preset);
|
|
399
|
-
process.stderr.write(`${DIM}Saved preset "${presetName}".${RESET}\n`);
|
|
400
|
-
}
|
|
401
|
-
catch (err) {
|
|
402
|
-
process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
403
|
-
}
|
|
404
|
-
return true;
|
|
405
|
-
}
|
|
406
|
-
if (sub === "delete") {
|
|
407
|
-
const presetName = parts[2];
|
|
408
|
-
if (!presetName) {
|
|
409
|
-
process.stderr.write(`${DIM}Usage: /preset delete <name>${RESET}\n`);
|
|
410
|
-
return true;
|
|
411
|
-
}
|
|
412
|
-
try {
|
|
413
|
-
const ok = deletePreset(presetName);
|
|
414
|
-
process.stderr.write(`${DIM}${ok ? `Deleted "${presetName}".` : `Preset "${presetName}" not found.`}${RESET}\n`);
|
|
415
|
-
}
|
|
416
|
-
catch (err) {
|
|
417
|
-
process.stderr.write(`${DIM}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
418
|
-
}
|
|
419
|
-
return true;
|
|
420
|
-
}
|
|
421
|
-
// /preset <name> — show preset details (use --preset <name> on CLI to apply at startup)
|
|
422
|
-
const preset = loadPreset(sub);
|
|
423
|
-
if (!preset) {
|
|
424
|
-
process.stderr.write(`${DIM}Preset "${sub}" not found. Use /preset list to see available presets.${RESET}\n`);
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
const isBuiltin = ["fast", "careful", "yolo"].includes(sub);
|
|
428
|
-
process.stderr.write(`${DIM}${formatPreset(sub, preset, isBuiltin)}\nUse: phren-agent --preset ${sub} <task>${RESET}\n`);
|
|
429
|
-
}
|
|
430
|
-
return true;
|
|
431
|
-
}
|
|
432
|
-
case "/session": {
|
|
433
|
-
const sub = parts[1]?.toLowerCase();
|
|
434
|
-
if (sub === "save") {
|
|
435
|
-
if (!ctx.phrenPath || !ctx.sessionId) {
|
|
436
|
-
process.stderr.write(`${DIM}No active phren session to save.${RESET}\n`);
|
|
437
|
-
return true;
|
|
438
|
-
}
|
|
439
|
-
try {
|
|
440
|
-
saveSessionMessages(ctx.phrenPath, ctx.sessionId, ctx.session.messages);
|
|
441
|
-
process.stderr.write(`${GREEN}→ Checkpoint saved (${ctx.session.messages.length} messages)${RESET}\n`);
|
|
442
|
-
}
|
|
443
|
-
catch (err) {
|
|
444
|
-
process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
445
|
-
}
|
|
446
|
-
return true;
|
|
447
|
-
}
|
|
448
|
-
if (sub === "export") {
|
|
449
|
-
const exportDir = path.join(os.homedir(), ".phren-agent", "exports");
|
|
450
|
-
fs.mkdirSync(exportDir, { recursive: true });
|
|
451
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
452
|
-
const exportFile = path.join(exportDir, `session-${ts}.json`);
|
|
453
|
-
try {
|
|
454
|
-
fs.writeFileSync(exportFile, JSON.stringify(ctx.session.messages, null, 2) + "\n");
|
|
455
|
-
process.stderr.write(`${GREEN}→ Exported to ${exportFile}${RESET}\n`);
|
|
456
|
-
}
|
|
457
|
-
catch (err) {
|
|
458
|
-
process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
459
|
-
}
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
// Default: show session info
|
|
463
|
-
const duration = ctx.startTime ? formatElapsed(Date.now() - ctx.startTime) : "unknown";
|
|
464
|
-
const lines = [];
|
|
465
|
-
if (ctx.sessionId)
|
|
466
|
-
lines.push(` Session: ${ctx.sessionId}`);
|
|
467
|
-
lines.push(` Turns: ${ctx.session.turns}`);
|
|
468
|
-
lines.push(` Tools: ${ctx.session.toolCalls}`);
|
|
469
|
-
lines.push(` Messages: ${ctx.session.messages.length}`);
|
|
470
|
-
lines.push(` Duration: ${duration}`);
|
|
471
|
-
// Read session state file for findings/tasks counters
|
|
472
|
-
if (ctx.phrenPath && ctx.sessionId) {
|
|
473
|
-
try {
|
|
474
|
-
const stateFile = path.join(ctx.phrenPath, ".runtime", "sessions", `session-${ctx.sessionId}.json`);
|
|
475
|
-
const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
|
|
476
|
-
lines.push(` Findings: ${state.findingsAdded ?? 0}`);
|
|
477
|
-
lines.push(` Tasks: ${state.tasksCompleted ?? 0}`);
|
|
478
|
-
}
|
|
479
|
-
catch { /* session file may not exist */ }
|
|
480
|
-
}
|
|
481
|
-
process.stderr.write(`${DIM}${lines.join("\n")}${RESET}\n`);
|
|
482
|
-
return true;
|
|
483
|
-
}
|
|
484
|
-
case "/diff": {
|
|
485
|
-
const staged = parts.includes("--staged") || parts.includes("--cached");
|
|
486
|
-
const cmd = staged ? "git diff --staged" : "git diff";
|
|
487
|
-
try {
|
|
488
|
-
const raw = execSync(cmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
|
|
489
|
-
if (!raw.trim()) {
|
|
490
|
-
process.stderr.write(`${DIM}No ${staged ? "staged " : ""}changes.${RESET}\n`);
|
|
491
|
-
}
|
|
492
|
-
else {
|
|
493
|
-
const colored = raw.split("\n").map((line) => {
|
|
494
|
-
if (line.startsWith("diff --git"))
|
|
495
|
-
return `${BOLD}${line}${RESET}`;
|
|
496
|
-
if (line.startsWith("@@"))
|
|
497
|
-
return `${CYAN}${line}${RESET}`;
|
|
498
|
-
if (line.startsWith("+"))
|
|
499
|
-
return `${GREEN}${line}${RESET}`;
|
|
500
|
-
if (line.startsWith("-"))
|
|
501
|
-
return `${RED}${line}${RESET}`;
|
|
502
|
-
return line;
|
|
503
|
-
}).join("\n");
|
|
504
|
-
process.stderr.write(colored + "\n");
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
catch (err) {
|
|
508
|
-
const e = err;
|
|
509
|
-
process.stderr.write(`${RED}${e.stderr || e.message || "git diff failed"}${RESET}\n`);
|
|
510
|
-
}
|
|
511
|
-
return true;
|
|
512
|
-
}
|
|
513
|
-
case "/git": {
|
|
514
|
-
const sub = parts.slice(1).join(" ").trim();
|
|
515
|
-
if (!sub) {
|
|
516
|
-
process.stderr.write(`${DIM}Usage: /git <status|log|stash|stash pop>${RESET}\n`);
|
|
517
|
-
return true;
|
|
518
|
-
}
|
|
519
|
-
const allowed = {
|
|
520
|
-
"status": "git status",
|
|
521
|
-
"log": "git log --oneline -5",
|
|
522
|
-
"stash": "git stash",
|
|
523
|
-
"stash pop": "git stash pop",
|
|
524
|
-
};
|
|
525
|
-
const gitCmd = allowed[sub];
|
|
526
|
-
if (!gitCmd) {
|
|
527
|
-
process.stderr.write(`${DIM}Supported: /git status, /git log, /git stash, /git stash pop${RESET}\n`);
|
|
528
|
-
return true;
|
|
529
|
-
}
|
|
530
|
-
try {
|
|
531
|
-
const output = execSync(gitCmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
|
|
532
|
-
if (output.trim())
|
|
533
|
-
process.stderr.write(output.endsWith("\n") ? output : output + "\n");
|
|
534
|
-
else
|
|
535
|
-
process.stderr.write(`${DIM}(no output)${RESET}\n`);
|
|
536
|
-
}
|
|
537
|
-
catch (err) {
|
|
538
|
-
const e = err;
|
|
539
|
-
process.stderr.write(`${RED}${e.stderr || e.message || "git command failed"}${RESET}\n`);
|
|
540
|
-
}
|
|
541
|
-
return true;
|
|
542
|
-
}
|
|
543
|
-
case "/mem": {
|
|
544
|
-
const sub = parts[1]?.toLowerCase();
|
|
545
|
-
if (!ctx.phrenCtx) {
|
|
546
|
-
process.stderr.write(`${DIM}No phren context available.${RESET}\n`);
|
|
547
|
-
return true;
|
|
548
|
-
}
|
|
549
|
-
const pCtx = ctx.phrenCtx;
|
|
550
|
-
if (!sub || sub === "help") {
|
|
551
|
-
process.stderr.write(`${DIM}Usage:
|
|
552
|
-
/mem search <query> Search phren memory
|
|
553
|
-
/mem findings [project] Show recent findings
|
|
554
|
-
/mem tasks [project] Show tasks
|
|
555
|
-
/mem add <finding> Quick-add a finding${RESET}\n`);
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
if (sub === "search") {
|
|
559
|
-
const query = parts.slice(2).join(" ").trim();
|
|
560
|
-
if (!query) {
|
|
561
|
-
process.stderr.write(`${DIM}Usage: /mem search <query>${RESET}\n`);
|
|
562
|
-
return true;
|
|
563
|
-
}
|
|
564
|
-
return (async () => {
|
|
565
|
-
try {
|
|
566
|
-
const db = await buildIndex(pCtx.phrenPath, pCtx.profile);
|
|
567
|
-
const result = await searchKnowledgeRows(db, {
|
|
568
|
-
query,
|
|
569
|
-
maxResults: 10,
|
|
570
|
-
filterProject: pCtx.project || null,
|
|
571
|
-
filterType: null,
|
|
572
|
-
phrenPath: pCtx.phrenPath,
|
|
573
|
-
});
|
|
574
|
-
const ranked = rankResults(result.rows ?? [], query, null, pCtx.project || null, pCtx.phrenPath, db);
|
|
575
|
-
if (ranked.length === 0) {
|
|
576
|
-
process.stderr.write(`${DIM}No results found.${RESET}\n`);
|
|
577
|
-
}
|
|
578
|
-
else {
|
|
579
|
-
const lines = ranked.slice(0, 10).map((r, i) => {
|
|
580
|
-
const snippet = r.content?.slice(0, 200) ?? "";
|
|
581
|
-
return ` ${CYAN}${i + 1}.${RESET} ${DIM}[${r.project}/${r.filename}]${RESET} ${snippet}`;
|
|
582
|
-
});
|
|
583
|
-
process.stderr.write(lines.join("\n") + "\n");
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
catch (err) {
|
|
587
|
-
process.stderr.write(`${RED}Search failed: ${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
588
|
-
}
|
|
589
|
-
return true;
|
|
590
|
-
})();
|
|
591
|
-
}
|
|
592
|
-
if (sub === "findings") {
|
|
593
|
-
const project = parts[2] || pCtx.project;
|
|
594
|
-
if (!project) {
|
|
595
|
-
process.stderr.write(`${DIM}Usage: /mem findings <project>${RESET}\n`);
|
|
596
|
-
return true;
|
|
597
|
-
}
|
|
598
|
-
const result = readFindings(pCtx.phrenPath, project);
|
|
599
|
-
if (!result.ok) {
|
|
600
|
-
process.stderr.write(`${RED}${result.error}${RESET}\n`);
|
|
601
|
-
return true;
|
|
602
|
-
}
|
|
603
|
-
const items = result.data ?? [];
|
|
604
|
-
if (items.length === 0) {
|
|
605
|
-
process.stderr.write(`${DIM}No findings for ${project}.${RESET}\n`);
|
|
606
|
-
return true;
|
|
607
|
-
}
|
|
608
|
-
const recent = items.slice(-15);
|
|
609
|
-
const lines = recent.map((f) => ` ${DIM}${f.date}${RESET} ${f.text.slice(0, 120)}${f.text.length > 120 ? "..." : ""}`);
|
|
610
|
-
process.stderr.write(`${DIM}── Findings (${items.length} total, showing last ${recent.length}) ──${RESET}\n`);
|
|
611
|
-
process.stderr.write(lines.join("\n") + "\n");
|
|
612
|
-
return true;
|
|
613
|
-
}
|
|
614
|
-
if (sub === "tasks") {
|
|
615
|
-
const project = parts[2] || pCtx.project;
|
|
616
|
-
if (!project) {
|
|
617
|
-
process.stderr.write(`${DIM}Usage: /mem tasks <project>${RESET}\n`);
|
|
618
|
-
return true;
|
|
619
|
-
}
|
|
620
|
-
const result = readTasks(pCtx.phrenPath, project);
|
|
621
|
-
if (!result.ok) {
|
|
622
|
-
process.stderr.write(`${RED}${result.error}${RESET}\n`);
|
|
623
|
-
return true;
|
|
624
|
-
}
|
|
625
|
-
const sections = [];
|
|
626
|
-
for (const [section, items] of Object.entries(result.data.items)) {
|
|
627
|
-
if (section === "Done")
|
|
628
|
-
continue;
|
|
629
|
-
if (items.length === 0)
|
|
630
|
-
continue;
|
|
631
|
-
const lines = items.map((t) => {
|
|
632
|
-
const icon = t.checked ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
|
|
633
|
-
return ` ${icon} ${t.line}`;
|
|
634
|
-
});
|
|
635
|
-
sections.push(`${BOLD}${section}${RESET}\n${lines.join("\n")}`);
|
|
636
|
-
}
|
|
637
|
-
if (sections.length === 0) {
|
|
638
|
-
process.stderr.write(`${DIM}No active tasks for ${project}.${RESET}\n`);
|
|
639
|
-
}
|
|
640
|
-
else {
|
|
641
|
-
process.stderr.write(sections.join("\n") + "\n");
|
|
642
|
-
}
|
|
643
|
-
return true;
|
|
644
|
-
}
|
|
645
|
-
if (sub === "add") {
|
|
646
|
-
const finding = parts.slice(2).join(" ").trim();
|
|
647
|
-
if (!finding) {
|
|
648
|
-
process.stderr.write(`${DIM}Usage: /mem add <finding text>${RESET}\n`);
|
|
649
|
-
return true;
|
|
650
|
-
}
|
|
651
|
-
const project = pCtx.project;
|
|
652
|
-
if (!project) {
|
|
653
|
-
process.stderr.write(`${DIM}No project context. Cannot add finding without a project.${RESET}\n`);
|
|
654
|
-
return true;
|
|
655
|
-
}
|
|
656
|
-
const result = addFinding(pCtx.phrenPath, project, finding);
|
|
657
|
-
if (result.ok) {
|
|
658
|
-
process.stderr.write(`${GREEN}→ Finding saved to ${project}.${RESET}\n`);
|
|
659
|
-
}
|
|
660
|
-
else {
|
|
661
|
-
process.stderr.write(`${RED}${result.message ?? "Failed to save finding."}${RESET}\n`);
|
|
662
|
-
}
|
|
663
|
-
return true;
|
|
664
|
-
}
|
|
665
|
-
process.stderr.write(`${DIM}Unknown /mem subcommand: ${sub}. Try /mem help${RESET}\n`);
|
|
666
|
-
return true;
|
|
667
|
-
}
|
|
668
|
-
case "/ask": {
|
|
669
|
-
const question = parts.slice(1).join(" ").trim();
|
|
670
|
-
if (!question) {
|
|
671
|
-
process.stderr.write(`${DIM}Usage: /ask <question>${RESET}\n`);
|
|
672
|
-
return true;
|
|
673
|
-
}
|
|
674
|
-
if (!ctx.provider) {
|
|
675
|
-
process.stderr.write(`${DIM}Provider not available for /ask.${RESET}\n`);
|
|
676
|
-
return true;
|
|
677
|
-
}
|
|
678
|
-
const provider = ctx.provider;
|
|
679
|
-
const sysPrompt = ctx.systemPrompt ?? "You are a helpful assistant.";
|
|
680
|
-
return (async () => {
|
|
681
|
-
process.stderr.write(`${DIM}◆ quick answer (no tools):${RESET}\n`);
|
|
682
|
-
try {
|
|
683
|
-
const response = await provider.chat(sysPrompt, [{ role: "user", content: question }], []);
|
|
684
|
-
for (const block of response.content) {
|
|
685
|
-
if (block.type === "text") {
|
|
686
|
-
process.stderr.write(renderMarkdown(block.text) + "\n");
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
catch (err) {
|
|
691
|
-
process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
692
|
-
}
|
|
693
|
-
return true;
|
|
694
|
-
})();
|
|
695
|
-
}
|
|
696
77
|
case "/exit":
|
|
697
78
|
case "/quit":
|
|
698
79
|
case "/q":
|