@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
package/dist/tui.js ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Terminal UI for phren-agent — streaming chat with inline tool calls.
3
+ * Dual-mode: Chat (LLM conversation) and Menu (navigable memory browser).
4
+ * Tab toggles between modes. Raw stdin for steering support.
5
+ */
6
+ import * as readline from "node:readline";
7
+ import { createSession, runTurn } from "./agent-loop.js";
8
+ import { handleCommand } from "./commands.js";
9
+ import { renderMarkdown } from "./multi/markdown.js";
10
+ import { decodeDiffPayload, renderInlineDiff, DIFF_MARKER } from "./multi/diff-renderer.js";
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import * as os from "os";
14
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
15
+ const ESC = "\x1b[";
16
+ const s = {
17
+ reset: `${ESC}0m`,
18
+ bold: (t) => `${ESC}1m${t}${ESC}0m`,
19
+ dim: (t) => `${ESC}2m${t}${ESC}0m`,
20
+ cyan: (t) => `${ESC}36m${t}${ESC}0m`,
21
+ green: (t) => `${ESC}32m${t}${ESC}0m`,
22
+ yellow: (t) => `${ESC}33m${t}${ESC}0m`,
23
+ red: (t) => `${ESC}31m${t}${ESC}0m`,
24
+ gray: (t) => `${ESC}90m${t}${ESC}0m`,
25
+ invert: (t) => `${ESC}7m${t}${ESC}0m`,
26
+ };
27
+ function cols() {
28
+ return process.stdout.columns || 80;
29
+ }
30
+ // ── Permission mode helpers ─────────────────────────────────────────────────
31
+ const PERMISSION_MODES = ["suggest", "auto-confirm", "full-auto"];
32
+ function nextPermissionMode(current) {
33
+ const idx = PERMISSION_MODES.indexOf(current);
34
+ return PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length];
35
+ }
36
+ const PERMISSION_LABELS = {
37
+ "suggest": "suggest",
38
+ "auto-confirm": "auto",
39
+ "full-auto": "full-auto",
40
+ };
41
+ function formatPermissionMode(mode) {
42
+ const label = PERMISSION_LABELS[mode];
43
+ switch (mode) {
44
+ case "suggest": return s.cyan(`[${label}]`);
45
+ case "auto-confirm": return s.green(`[${label}]`);
46
+ case "full-auto": return s.yellow(`[${label}]`);
47
+ }
48
+ }
49
+ // ── Status bar ───────────────────────────────────────────────────────────────
50
+ function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
51
+ const modeStr = permMode ? ` ${PERMISSION_LABELS[permMode]}` : "";
52
+ const agentTag = agentCount && agentCount > 0 ? ` ${s.dim(`A${agentCount}`)}` : "";
53
+ const left = ` ${s.bold("phren-agent")} ${s.dim("·")} ${provider}${project ? ` ${s.dim("·")} ${project}` : ""}`;
54
+ const right = `${modeStr}${agentTag} ${cost ? cost + " " : ""}${s.dim(`T${turns}`)} `;
55
+ const w = cols();
56
+ const pad = Math.max(0, w - stripAnsi(left).length - stripAnsi(right).length);
57
+ return s.invert(stripAnsi(left) + " ".repeat(pad) + stripAnsi(right));
58
+ }
59
+ function stripAnsi(t) {
60
+ return t.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
61
+ }
62
+ // ── Tool call rendering ──────────────────────────────────────────────────────
63
+ const COMPACT_LINES = 3;
64
+ function formatDuration(ms) {
65
+ if (ms < 1000)
66
+ return `${ms}ms`;
67
+ if (ms < 60_000)
68
+ return `${(ms / 1000).toFixed(1)}s`;
69
+ const mins = Math.floor(ms / 60_000);
70
+ const secs = Math.round((ms % 60_000) / 1000);
71
+ return `${mins}m ${secs}s`;
72
+ }
73
+ function renderToolCall(name, input, output, isError, durationMs) {
74
+ const inputPreview = JSON.stringify(input).slice(0, 80);
75
+ const dur = formatDuration(durationMs);
76
+ const icon = isError ? s.red("✗") : s.green("✓");
77
+ const header = s.dim(` ${name}(${inputPreview})`) + ` ${icon} ${s.dim(dur)}`;
78
+ // Compact: show first 3 lines only, with overflow count
79
+ const allLines = output.split("\n").filter(Boolean);
80
+ const shown = allLines.slice(0, COMPACT_LINES);
81
+ const body = shown.map((l) => s.dim(` │ ${l.slice(0, cols() - 6)}`)).join("\n");
82
+ const overflow = allLines.length - COMPACT_LINES;
83
+ const more = overflow > 0 ? `\n${s.dim(` │ [+${overflow} lines]`)}` : "";
84
+ return `${header}\n${body}${more}`;
85
+ }
86
+ // ── Menu mode helpers ────────────────────────────────────────────────────────
87
+ let menuMod = null;
88
+ async function loadMenuModule() {
89
+ if (!menuMod) {
90
+ try {
91
+ menuMod = await import("@phren/cli/shell/render-api");
92
+ }
93
+ catch {
94
+ menuMod = null;
95
+ }
96
+ }
97
+ return menuMod;
98
+ }
99
+ // ── Main TUI ─────────────────────────────────────────────────────────────────
100
+ export async function startTui(config, spawner) {
101
+ const contextLimit = config.provider.contextWindow ?? 200_000;
102
+ const session = createSession(contextLimit);
103
+ const w = process.stdout;
104
+ const isTTY = process.stdout.isTTY;
105
+ let inputMode = loadInputMode();
106
+ let pendingInput = null;
107
+ let running = false;
108
+ let inputLine = "";
109
+ let costStr = "";
110
+ // ── Dual-mode state ─────────────────────────────────────────────────────
111
+ let tuiMode = "chat";
112
+ let menuState = {
113
+ view: "Projects",
114
+ project: config.phrenCtx?.project ?? undefined,
115
+ cursor: 0,
116
+ scroll: 0,
117
+ };
118
+ let menuListCount = 0;
119
+ let menuFilterActive = false;
120
+ let menuFilterBuf = "";
121
+ // ── Menu rendering ─────────────────────────────────────────────────────
122
+ async function renderMenu() {
123
+ const mod = await loadMenuModule();
124
+ if (!mod || !config.phrenCtx)
125
+ return;
126
+ const result = await mod.renderMenuFrame(config.phrenCtx.phrenPath, config.phrenCtx.profile, menuState);
127
+ menuListCount = result.listCount;
128
+ // Full-screen write: single write to avoid flicker
129
+ w.write(`${ESC}?25l${ESC}H${ESC}2J${result.output}${ESC}?25h`);
130
+ }
131
+ function enterMenuMode() {
132
+ if (!config.phrenCtx) {
133
+ w.write(s.yellow(" phren not configured — menu unavailable\n"));
134
+ return;
135
+ }
136
+ tuiMode = "menu";
137
+ menuState.project = config.phrenCtx.project ?? menuState.project;
138
+ w.write("\x1b[?1049h"); // enter alternate screen
139
+ renderMenu();
140
+ }
141
+ function exitMenuMode() {
142
+ tuiMode = "chat";
143
+ menuFilterActive = false;
144
+ menuFilterBuf = "";
145
+ w.write("\x1b[?1049l"); // leave alternate screen (restores chat)
146
+ statusBar();
147
+ prompt();
148
+ }
149
+ // Print status bar
150
+ function statusBar() {
151
+ if (!isTTY)
152
+ return;
153
+ const bar = renderStatusBar(config.provider.name, config.phrenCtx?.project ?? null, session.turns, costStr, config.registry.permissionConfig.mode, spawner?.listAgents().length);
154
+ w.write(`${ESC}s${ESC}H${bar}${ESC}u`); // save cursor, move to top, print, restore
155
+ }
156
+ // Print prompt
157
+ function prompt() {
158
+ const modeTag = inputMode === "steering" ? s.dim("[steer]") : s.dim("[queue]");
159
+ w.write(`\n${s.cyan("phren>")} ${modeTag} `);
160
+ }
161
+ // Terminal cleanup: restore state on exit
162
+ function cleanupTerminal() {
163
+ w.write("\x1b[?1049l"); // leave alt screen if active
164
+ if (process.stdin.isTTY) {
165
+ try {
166
+ process.stdin.setRawMode(false);
167
+ }
168
+ catch { }
169
+ }
170
+ }
171
+ process.on("exit", cleanupTerminal);
172
+ // Setup: alternate screen not needed — just reserve top line for status
173
+ if (isTTY) {
174
+ w.write("\n"); // make room for status bar
175
+ w.write(`${ESC}1;1H`); // move to top
176
+ statusBar();
177
+ w.write(`${ESC}2;1H`); // move below status bar
178
+ w.write(s.dim("phren-agent TUI. Tab: memory browser Shift+Tab: permissions /help: commands Ctrl+D: exit\n"));
179
+ }
180
+ // Raw stdin for steering
181
+ if (process.stdin.isTTY) {
182
+ readline.emitKeypressEvents(process.stdin);
183
+ process.stdin.setRawMode(true);
184
+ }
185
+ let resolve = null;
186
+ const done = new Promise((r) => { resolve = r; });
187
+ // ── Menu keypress handler ───────────────────────────────────────────────
188
+ async function handleMenuKeypress(key) {
189
+ // Filter input mode: capture text for / search
190
+ if (menuFilterActive) {
191
+ if (key.name === "escape") {
192
+ menuFilterActive = false;
193
+ menuFilterBuf = "";
194
+ menuState = { ...menuState, filter: undefined, cursor: 0, scroll: 0 };
195
+ renderMenu();
196
+ return;
197
+ }
198
+ if (key.name === "return") {
199
+ menuFilterActive = false;
200
+ menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0, scroll: 0 };
201
+ menuFilterBuf = "";
202
+ renderMenu();
203
+ return;
204
+ }
205
+ if (key.name === "backspace") {
206
+ menuFilterBuf = menuFilterBuf.slice(0, -1);
207
+ menuState = { ...menuState, filter: menuFilterBuf || undefined, cursor: 0 };
208
+ renderMenu();
209
+ return;
210
+ }
211
+ if (key.sequence && !key.ctrl && !key.meta) {
212
+ menuFilterBuf += key.sequence;
213
+ menuState = { ...menuState, filter: menuFilterBuf, cursor: 0 };
214
+ renderMenu();
215
+ }
216
+ return;
217
+ }
218
+ // "/" starts filter input
219
+ if (key.sequence === "/") {
220
+ menuFilterActive = true;
221
+ menuFilterBuf = "";
222
+ return;
223
+ }
224
+ const mod = await loadMenuModule();
225
+ if (!mod) {
226
+ exitMenuMode();
227
+ return;
228
+ }
229
+ const newState = mod.handleMenuKey(menuState, key.name ?? "", menuListCount, config.phrenCtx?.phrenPath, config.phrenCtx?.profile);
230
+ if (newState === null) {
231
+ exitMenuMode();
232
+ }
233
+ else {
234
+ menuState = newState;
235
+ renderMenu();
236
+ }
237
+ }
238
+ // ── Keypress router ────────────────────────────────────────────────────
239
+ process.stdin.on("keypress", (_ch, key) => {
240
+ if (!key)
241
+ return;
242
+ // Ctrl+D — always exit
243
+ if (key.ctrl && key.name === "d") {
244
+ if (tuiMode === "menu")
245
+ w.write("\x1b[?1049l"); // leave alt screen
246
+ if (process.stdin.isTTY)
247
+ process.stdin.setRawMode(false);
248
+ w.write(s.dim("\nSession ended.\n"));
249
+ resolve(session);
250
+ return;
251
+ }
252
+ // Shift+Tab — cycle permission mode (works in chat mode, not during filter)
253
+ if (key.shift && key.name === "tab" && !menuFilterActive && tuiMode === "chat") {
254
+ const current = config.registry.permissionConfig.mode;
255
+ const next = nextPermissionMode(current);
256
+ config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
257
+ savePermissionMode(next);
258
+ w.write(s.yellow(` [mode: ${next}]\n`));
259
+ statusBar();
260
+ if (!running)
261
+ prompt();
262
+ return;
263
+ }
264
+ // Tab — toggle mode (not during agent run or filter)
265
+ if (key.name === "tab" && !menuFilterActive) {
266
+ if (tuiMode === "chat" && !running) {
267
+ enterMenuMode();
268
+ }
269
+ else if (tuiMode === "menu") {
270
+ exitMenuMode();
271
+ }
272
+ return;
273
+ }
274
+ // Route to mode-specific handler
275
+ if (tuiMode === "menu") {
276
+ handleMenuKeypress(key);
277
+ return;
278
+ }
279
+ // ── Chat mode keys ──────────────────────────────────────────────────
280
+ // Ctrl+C — cancel current or clear line
281
+ if (key.ctrl && key.name === "c") {
282
+ if (running) {
283
+ pendingInput = null;
284
+ w.write(s.yellow("\n [interrupted]\n"));
285
+ }
286
+ else {
287
+ inputLine = "";
288
+ w.write("\n");
289
+ prompt();
290
+ }
291
+ return;
292
+ }
293
+ // Enter — submit
294
+ if (key.name === "return") {
295
+ const line = inputLine.trim();
296
+ inputLine = "";
297
+ w.write("\n");
298
+ if (!line) {
299
+ prompt();
300
+ return;
301
+ }
302
+ // Slash commands
303
+ if (line === "/mode") {
304
+ inputMode = inputMode === "steering" ? "queue" : "steering";
305
+ saveInputMode(inputMode);
306
+ w.write(s.yellow(` Input mode: ${inputMode}\n`));
307
+ prompt();
308
+ return;
309
+ }
310
+ if (handleCommand(line, { session, contextLimit, undoStack: [] })) {
311
+ prompt();
312
+ return;
313
+ }
314
+ // If agent is running, buffer input
315
+ if (running) {
316
+ pendingInput = line;
317
+ const label = inputMode === "steering" ? "steering" : "queued";
318
+ w.write(s.dim(` ↳ ${label}: "${line.slice(0, 60)}"\n`));
319
+ return;
320
+ }
321
+ // Run agent turn
322
+ runAgentTurn(line);
323
+ return;
324
+ }
325
+ // Backspace
326
+ if (key.name === "backspace") {
327
+ if (inputLine.length > 0) {
328
+ inputLine = inputLine.slice(0, -1);
329
+ w.write("\b \b");
330
+ }
331
+ return;
332
+ }
333
+ // Regular character
334
+ if (key.sequence && !key.ctrl && !key.meta) {
335
+ inputLine += key.sequence;
336
+ w.write(key.sequence);
337
+ }
338
+ });
339
+ // TUI hooks — render streaming text with markdown, compact tool output
340
+ let textBuffer = "";
341
+ function flushTextBuffer() {
342
+ if (!textBuffer)
343
+ return;
344
+ w.write(renderMarkdown(textBuffer));
345
+ textBuffer = "";
346
+ }
347
+ const tuiHooks = {
348
+ onTextDelta: (text) => {
349
+ textBuffer += text;
350
+ // Flush on paragraph boundaries (double newline) or single newline for streaming feel
351
+ if (textBuffer.includes("\n\n") || textBuffer.endsWith("\n")) {
352
+ flushTextBuffer();
353
+ }
354
+ },
355
+ onTextDone: () => {
356
+ flushTextBuffer();
357
+ },
358
+ onTextBlock: (text) => {
359
+ w.write(renderMarkdown(text));
360
+ if (!text.endsWith("\n"))
361
+ w.write("\n");
362
+ },
363
+ onToolStart: (name, _input, _count) => {
364
+ flushTextBuffer();
365
+ w.write(s.dim(` ⠋ ${name}...\r`));
366
+ },
367
+ onToolEnd: (name, input, output, isError, dur) => {
368
+ w.write(`${ESC}2K\r`);
369
+ const diffData = (name === "edit_file" || name === "write_file") ? decodeDiffPayload(output) : null;
370
+ const cleanOutput = diffData ? output.slice(0, output.indexOf(DIFF_MARKER)) : output;
371
+ w.write(renderToolCall(name, input, cleanOutput, isError, dur) + "\n");
372
+ if (diffData) {
373
+ w.write(renderInlineDiff(diffData.oldContent, diffData.newContent, diffData.filePath) + "\n");
374
+ }
375
+ },
376
+ onStatus: (msg) => w.write(s.dim(msg)),
377
+ getSteeringInput: () => {
378
+ if (pendingInput && inputMode === "steering") {
379
+ const steer = pendingInput;
380
+ pendingInput = null;
381
+ w.write(s.yellow(` ↳ steering: ${steer}\n`));
382
+ return steer;
383
+ }
384
+ return null;
385
+ },
386
+ };
387
+ async function runAgentTurn(userInput) {
388
+ running = true;
389
+ w.write(s.dim(" ⠋ Thinking...\r"));
390
+ try {
391
+ await runTurn(userInput, session, config, tuiHooks);
392
+ statusBar();
393
+ }
394
+ catch (err) {
395
+ const msg = err instanceof Error ? err.message : String(err);
396
+ w.write(s.red(` Error: ${msg}\n`));
397
+ }
398
+ running = false;
399
+ // Process queued input
400
+ if (pendingInput) {
401
+ const queued = pendingInput;
402
+ pendingInput = null;
403
+ runAgentTurn(queued);
404
+ }
405
+ else {
406
+ prompt();
407
+ }
408
+ }
409
+ // Initial prompt
410
+ prompt();
411
+ return done;
412
+ }
413
+ // ── Settings persistence ─────────────────────────────────────────────────────
414
+ const SETTINGS_FILE = path.join(os.homedir(), ".phren-agent", "settings.json");
415
+ function loadInputMode() {
416
+ try {
417
+ const data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
418
+ if (data.inputMode === "queue")
419
+ return "queue";
420
+ }
421
+ catch { }
422
+ return "steering";
423
+ }
424
+ function saveInputMode(mode) {
425
+ try {
426
+ const dir = path.dirname(SETTINGS_FILE);
427
+ fs.mkdirSync(dir, { recursive: true });
428
+ let data = {};
429
+ try {
430
+ data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
431
+ }
432
+ catch { }
433
+ data.inputMode = mode;
434
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
435
+ }
436
+ catch { }
437
+ }
438
+ function savePermissionMode(mode) {
439
+ try {
440
+ const dir = path.dirname(SETTINGS_FILE);
441
+ fs.mkdirSync(dir, { recursive: true });
442
+ let data = {};
443
+ try {
444
+ data = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
445
+ }
446
+ catch { }
447
+ data.permissionMode = mode;
448
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2) + "\n");
449
+ }
450
+ catch { }
451
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@phren/agent",
3
+ "version": "0.0.1",
4
+ "description": "Coding agent with persistent memory — powered by phren",
5
+ "type": "module",
6
+ "bin": {
7
+ "phren-agent": "dist/bin.js"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "test": "echo 'Run tests from repo root: pnpm -w test'"
18
+ },
19
+ "dependencies": {
20
+ "@phren/cli": "workspace:*"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "keywords": [
26
+ "agent",
27
+ "coding-agent",
28
+ "tui",
29
+ "phren",
30
+ "memory"
31
+ ],
32
+ "author": "Ala Arab",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/alaarab/phren.git",
37
+ "directory": "packages/agent"
38
+ }
39
+ }