@phren/agent 0.1.3 → 0.1.5

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 +68 -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 +26 -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 +287 -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
package/dist/commands.js CHANGED
@@ -1,27 +1,10 @@
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
- import { showModelPicker } from "./multi/model-picker.js";
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
- process.stderr.write(`${DIM}Commands:
61
- /help Show this help
62
- /model Interactive model + reasoning picker
63
- /model add <id> Add a custom model
64
- /model remove <id> Remove a custom model
65
- /provider Show configured providers + auth status
66
- /turns Show turn and tool call counts
67
- /clear Clear conversation history and terminal screen
68
- /cwd Show current working directory
69
- /files Quick file tree (max depth 2, first 30 files)
70
- /cost Show token usage and estimated cost
71
- /plan Show conversation plan (tool calls so far)
72
- /undo Undo last user message and response
73
- /history [n|full] Show last N messages (default 10) with rich formatting
74
- /compact Compact conversation to save context space
75
- /context Show context window usage and provider info
76
- /mode Toggle input mode (steering ↔ queue)
77
- /spawn <name> <task> Spawn a background agent
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":