@phren/agent 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent-loop.js +328 -0
  2. package/dist/bin.js +3 -0
  3. package/dist/checkpoint.js +103 -0
  4. package/dist/commands.js +292 -0
  5. package/dist/config.js +139 -0
  6. package/dist/context/pruner.js +62 -0
  7. package/dist/context/token-counter.js +28 -0
  8. package/dist/cost.js +71 -0
  9. package/dist/index.js +284 -0
  10. package/dist/mcp-client.js +168 -0
  11. package/dist/memory/anti-patterns.js +69 -0
  12. package/dist/memory/auto-capture.js +72 -0
  13. package/dist/memory/context-flush.js +24 -0
  14. package/dist/memory/context.js +170 -0
  15. package/dist/memory/error-recovery.js +58 -0
  16. package/dist/memory/project-context.js +77 -0
  17. package/dist/memory/session.js +100 -0
  18. package/dist/multi/agent-colors.js +41 -0
  19. package/dist/multi/child-entry.js +173 -0
  20. package/dist/multi/coordinator.js +263 -0
  21. package/dist/multi/diff-renderer.js +175 -0
  22. package/dist/multi/markdown.js +96 -0
  23. package/dist/multi/presets.js +107 -0
  24. package/dist/multi/progress.js +32 -0
  25. package/dist/multi/spawner.js +219 -0
  26. package/dist/multi/tui-multi.js +626 -0
  27. package/dist/multi/types.js +7 -0
  28. package/dist/permissions/allowlist.js +61 -0
  29. package/dist/permissions/checker.js +111 -0
  30. package/dist/permissions/prompt.js +190 -0
  31. package/dist/permissions/sandbox.js +95 -0
  32. package/dist/permissions/shell-safety.js +74 -0
  33. package/dist/permissions/types.js +2 -0
  34. package/dist/plan.js +38 -0
  35. package/dist/providers/anthropic.js +170 -0
  36. package/dist/providers/codex-auth.js +197 -0
  37. package/dist/providers/codex.js +265 -0
  38. package/dist/providers/ollama.js +142 -0
  39. package/dist/providers/openai-compat.js +163 -0
  40. package/dist/providers/openrouter.js +116 -0
  41. package/dist/providers/resolve.js +39 -0
  42. package/dist/providers/retry.js +55 -0
  43. package/dist/providers/types.js +2 -0
  44. package/dist/repl.js +180 -0
  45. package/dist/spinner.js +46 -0
  46. package/dist/system-prompt.js +31 -0
  47. package/dist/tools/edit-file.js +31 -0
  48. package/dist/tools/git.js +98 -0
  49. package/dist/tools/glob.js +65 -0
  50. package/dist/tools/grep.js +108 -0
  51. package/dist/tools/lint-test.js +76 -0
  52. package/dist/tools/phren-finding.js +35 -0
  53. package/dist/tools/phren-search.js +44 -0
  54. package/dist/tools/phren-tasks.js +71 -0
  55. package/dist/tools/read-file.js +44 -0
  56. package/dist/tools/registry.js +46 -0
  57. package/dist/tools/shell.js +48 -0
  58. package/dist/tools/types.js +2 -0
  59. package/dist/tools/write-file.js +27 -0
  60. package/dist/tui.js +451 -0
  61. package/package.json +39 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Task-based coordination for multi-agent teams.
3
+ *
4
+ * Manages a shared JSON task list at ~/.phren-agent/teams/<team>/tasks.json.
5
+ * Multiple child processes may claim tasks concurrently — all mutations go
6
+ * through file locking + atomic rename to prevent corruption.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import * as crypto from "node:crypto";
12
+ // ── File Locking ────────────────────────────────────────────────────────
13
+ const LOCK_MAX_WAIT_MS = 5_000;
14
+ const LOCK_POLL_MS = 50;
15
+ const LOCK_STALE_MS = 30_000;
16
+ function sleepSync(ms) {
17
+ const buf = new Int32Array(new SharedArrayBuffer(4));
18
+ Atomics.wait(buf, 0, 0, ms);
19
+ }
20
+ function acquireLock(lockPath) {
21
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
22
+ let waited = 0;
23
+ while (waited < LOCK_MAX_WAIT_MS) {
24
+ try {
25
+ fs.writeFileSync(lockPath, `${process.pid}\n${Date.now()}`, { flag: "wx" });
26
+ return; // acquired
27
+ }
28
+ catch {
29
+ // Lock exists — check if stale
30
+ try {
31
+ const stat = fs.statSync(lockPath);
32
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
33
+ // Check if owner process is alive
34
+ let alive = false;
35
+ try {
36
+ const content = fs.readFileSync(lockPath, "utf-8");
37
+ const pid = parseInt(content.split("\n")[0], 10);
38
+ if (pid > 0) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ alive = true;
42
+ }
43
+ catch { /* dead */ }
44
+ }
45
+ }
46
+ catch { /* file vanished */ }
47
+ if (!alive) {
48
+ try {
49
+ fs.unlinkSync(lockPath);
50
+ }
51
+ catch { /* gone */ }
52
+ continue;
53
+ }
54
+ }
55
+ }
56
+ catch { /* stat failed, retry */ }
57
+ sleepSync(LOCK_POLL_MS);
58
+ waited += LOCK_POLL_MS;
59
+ }
60
+ }
61
+ throw new Error(`TeamCoordinator: could not acquire lock within ${LOCK_MAX_WAIT_MS}ms`);
62
+ }
63
+ function releaseLock(lockPath) {
64
+ try {
65
+ fs.unlinkSync(lockPath);
66
+ }
67
+ catch { /* already gone */ }
68
+ }
69
+ function withLock(filePath, fn) {
70
+ const lockPath = filePath + ".lock";
71
+ acquireLock(lockPath);
72
+ try {
73
+ return fn();
74
+ }
75
+ finally {
76
+ releaseLock(lockPath);
77
+ }
78
+ }
79
+ // ── Atomic File Write ───────────────────────────────────────────────────
80
+ function atomicWriteJsonSync(filePath, data) {
81
+ const dir = path.dirname(filePath);
82
+ fs.mkdirSync(dir, { recursive: true });
83
+ const tmp = path.join(dir, `.tmp-${crypto.randomBytes(6).toString("hex")}`);
84
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
85
+ fs.renameSync(tmp, filePath);
86
+ }
87
+ // ── Coordinator ─────────────────────────────────────────────────────────
88
+ export class TeamCoordinator {
89
+ teamName;
90
+ filePath;
91
+ constructor(teamName) {
92
+ this.teamName = teamName;
93
+ this.filePath = path.join(os.homedir(), ".phren-agent", "teams", teamName, "tasks.json");
94
+ }
95
+ // ── Read / Write helpers (always called inside a lock) ──────────────
96
+ readFile() {
97
+ try {
98
+ const raw = fs.readFileSync(this.filePath, "utf-8");
99
+ return JSON.parse(raw);
100
+ }
101
+ catch {
102
+ const now = new Date().toISOString();
103
+ return { teamName: this.teamName, tasks: [], createdAt: now, updatedAt: now };
104
+ }
105
+ }
106
+ writeFile(data) {
107
+ data.updatedAt = new Date().toISOString();
108
+ atomicWriteJsonSync(this.filePath, data);
109
+ }
110
+ nextId(data) {
111
+ if (data.tasks.length === 0)
112
+ return "1";
113
+ const max = Math.max(...data.tasks.map((t) => parseInt(t.id, 10) || 0));
114
+ return String(max + 1);
115
+ }
116
+ // ── Public API ──────────────────────────────────────────────────────
117
+ /** Add a new task to the list. Returns the created task. */
118
+ createTask(subject, description, blockedBy) {
119
+ return withLock(this.filePath, () => {
120
+ const data = this.readFile();
121
+ const task = {
122
+ id: this.nextId(data),
123
+ subject,
124
+ description,
125
+ status: "pending",
126
+ owner: null,
127
+ createdAt: new Date().toISOString(),
128
+ claimedAt: null,
129
+ completedAt: null,
130
+ result: null,
131
+ error: null,
132
+ blockedBy: blockedBy ?? [],
133
+ };
134
+ data.tasks.push(task);
135
+ this.writeFile(data);
136
+ return task;
137
+ });
138
+ }
139
+ /**
140
+ * Atomically claim the next available task for an agent.
141
+ * Skips tasks that are blocked by incomplete dependencies.
142
+ * Returns null if nothing is available.
143
+ */
144
+ claimTask(agentName) {
145
+ return withLock(this.filePath, () => {
146
+ const data = this.readFile();
147
+ const completedIds = new Set(data.tasks.filter((t) => t.status === "completed").map((t) => t.id));
148
+ for (const task of data.tasks) {
149
+ if (task.status !== "pending")
150
+ continue;
151
+ // Check blockedBy — all listed tasks must be completed
152
+ const blocked = task.blockedBy.some((depId) => !completedIds.has(depId));
153
+ if (blocked)
154
+ continue;
155
+ task.status = "claimed";
156
+ task.owner = agentName;
157
+ task.claimedAt = new Date().toISOString();
158
+ this.writeFile(data);
159
+ return task;
160
+ }
161
+ return null;
162
+ });
163
+ }
164
+ /** Transition a claimed task to in_progress. */
165
+ startTask(taskId, agentName) {
166
+ withLock(this.filePath, () => {
167
+ const data = this.readFile();
168
+ const task = data.tasks.find((t) => t.id === taskId);
169
+ if (!task)
170
+ throw new Error(`Task ${taskId} not found`);
171
+ if (task.owner !== agentName) {
172
+ throw new Error(`Task ${taskId} is owned by "${task.owner}", not "${agentName}"`);
173
+ }
174
+ if (task.status !== "claimed") {
175
+ throw new Error(`Task ${taskId} is "${task.status}", expected "claimed"`);
176
+ }
177
+ task.status = "in_progress";
178
+ this.writeFile(data);
179
+ });
180
+ }
181
+ /** Mark a task as completed. */
182
+ completeTask(taskId, agentName, result) {
183
+ withLock(this.filePath, () => {
184
+ const data = this.readFile();
185
+ const task = data.tasks.find((t) => t.id === taskId);
186
+ if (!task)
187
+ throw new Error(`Task ${taskId} not found`);
188
+ if (task.owner !== agentName) {
189
+ throw new Error(`Task ${taskId} is owned by "${task.owner}", not "${agentName}"`);
190
+ }
191
+ task.status = "completed";
192
+ task.completedAt = new Date().toISOString();
193
+ task.result = result ?? null;
194
+ this.writeFile(data);
195
+ });
196
+ }
197
+ /** Mark a task as failed. */
198
+ failTask(taskId, agentName, error) {
199
+ withLock(this.filePath, () => {
200
+ const data = this.readFile();
201
+ const task = data.tasks.find((t) => t.id === taskId);
202
+ if (!task)
203
+ throw new Error(`Task ${taskId} not found`);
204
+ if (task.owner !== agentName) {
205
+ throw new Error(`Task ${taskId} is owned by "${task.owner}", not "${agentName}"`);
206
+ }
207
+ task.status = "failed";
208
+ task.completedAt = new Date().toISOString();
209
+ task.error = error;
210
+ this.writeFile(data);
211
+ });
212
+ }
213
+ /** Get the full task list (read-only snapshot). */
214
+ getTaskList() {
215
+ return withLock(this.filePath, () => {
216
+ return structuredClone(this.readFile().tasks);
217
+ });
218
+ }
219
+ /** Get the task currently assigned to an agent, or null. */
220
+ getAgentTask(agentName) {
221
+ return withLock(this.filePath, () => {
222
+ const data = this.readFile();
223
+ return (data.tasks.find((t) => t.owner === agentName &&
224
+ (t.status === "claimed" || t.status === "in_progress")) ?? null);
225
+ });
226
+ }
227
+ /** Get a single task by ID. */
228
+ getTask(taskId) {
229
+ return withLock(this.filePath, () => {
230
+ const data = this.readFile();
231
+ return data.tasks.find((t) => t.id === taskId) ?? null;
232
+ });
233
+ }
234
+ /** Return all tasks that are now unblocked (pending with all deps completed). */
235
+ getUnblockedTasks() {
236
+ return withLock(this.filePath, () => {
237
+ const data = this.readFile();
238
+ const completedIds = new Set(data.tasks.filter((t) => t.status === "completed").map((t) => t.id));
239
+ return data.tasks.filter((t) => t.status === "pending" &&
240
+ t.blockedBy.every((depId) => completedIds.has(depId)));
241
+ });
242
+ }
243
+ /** Summary string for display. */
244
+ formatStatus() {
245
+ const tasks = this.getTaskList();
246
+ if (tasks.length === 0)
247
+ return `Team "${this.teamName}": no tasks`;
248
+ const counts = {};
249
+ for (const t of tasks)
250
+ counts[t.status] = (counts[t.status] ?? 0) + 1;
251
+ const lines = [`Team "${this.teamName}" — ${tasks.length} tasks:`];
252
+ for (const t of tasks) {
253
+ const owner = t.owner ? ` [${t.owner}]` : "";
254
+ const blocked = t.blockedBy.length > 0 ? ` (blocked by: ${t.blockedBy.join(", ")})` : "";
255
+ lines.push(` #${t.id} [${t.status}]${owner} ${t.subject}${blocked}`);
256
+ }
257
+ const summary = Object.entries(counts)
258
+ .map(([s, c]) => `${s}: ${c}`)
259
+ .join(", ");
260
+ lines.push(` (${summary})`);
261
+ return lines.join("\n");
262
+ }
263
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Inline diff renderer — produces colored ANSI output for file edits.
3
+ *
4
+ * Uses a simple line-based diff (longest common subsequence) with no
5
+ * external dependencies. Output is capped at 30 lines.
6
+ */
7
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
8
+ const ESC = "\x1b[";
9
+ const red = (t) => `${ESC}31m${t}${ESC}0m`;
10
+ const green = (t) => `${ESC}32m${t}${ESC}0m`;
11
+ const dim = (t) => `${ESC}2m${t}${ESC}0m`;
12
+ const cyan = (t) => `${ESC}36m${t}${ESC}0m`;
13
+ /**
14
+ * Compute a line-level diff between two arrays of strings.
15
+ * Uses the LCS (Longest Common Subsequence) approach for correctness.
16
+ */
17
+ function computeLineDiff(oldLines, newLines) {
18
+ const m = oldLines.length;
19
+ const n = newLines.length;
20
+ // Build LCS table
21
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
22
+ for (let i = 1; i <= m; i++) {
23
+ for (let j = 1; j <= n; j++) {
24
+ if (oldLines[i - 1] === newLines[j - 1]) {
25
+ dp[i][j] = dp[i - 1][j - 1] + 1;
26
+ }
27
+ else {
28
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
29
+ }
30
+ }
31
+ }
32
+ // Backtrack to produce diff entries
33
+ const result = [];
34
+ let i = m;
35
+ let j = n;
36
+ while (i > 0 || j > 0) {
37
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
38
+ result.push({ op: "equal", line: oldLines[i - 1], oldLineNo: i, newLineNo: j });
39
+ i--;
40
+ j--;
41
+ }
42
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
43
+ result.push({ op: "insert", line: newLines[j - 1], newLineNo: j });
44
+ j--;
45
+ }
46
+ else {
47
+ result.push({ op: "delete", line: oldLines[i - 1], oldLineNo: i });
48
+ i--;
49
+ }
50
+ }
51
+ result.reverse();
52
+ return result;
53
+ }
54
+ // ── Renderer ─────────────────────────────────────────────────────────────────
55
+ const MAX_OUTPUT_LINES = 30;
56
+ const MAX_FIRST_CHUNK = 20;
57
+ const CONTEXT_LINES = 3;
58
+ /**
59
+ * Render a colored inline diff between old and new file content.
60
+ *
61
+ * Output format:
62
+ * ─── path/to/file ───
63
+ * (context lines in gray, removed in red, added in green)
64
+ * Collapsed unchanged sections shown as "... (N unchanged lines) ..."
65
+ *
66
+ * Capped at 30 output lines. If the diff is larger, shows the first 20
67
+ * lines plus a "... (N more changes)" trailer.
68
+ */
69
+ export function renderInlineDiff(oldContent, newContent, filePath) {
70
+ const oldLines = oldContent.split("\n");
71
+ const newLines = newContent.split("\n");
72
+ const diff = computeLineDiff(oldLines, newLines);
73
+ // Identify which diff entries have changes nearby (within CONTEXT_LINES)
74
+ const hasChange = diff.map((d) => d.op !== "equal");
75
+ const visible = new Array(diff.length).fill(false);
76
+ for (let i = 0; i < diff.length; i++) {
77
+ if (hasChange[i]) {
78
+ for (let c = Math.max(0, i - CONTEXT_LINES); c <= Math.min(diff.length - 1, i + CONTEXT_LINES); c++) {
79
+ visible[c] = true;
80
+ }
81
+ }
82
+ }
83
+ // Build output lines
84
+ const outputLines = [];
85
+ let inCollapsed = false;
86
+ let collapsedCount = 0;
87
+ for (let i = 0; i < diff.length; i++) {
88
+ if (!visible[i]) {
89
+ if (!inCollapsed) {
90
+ inCollapsed = true;
91
+ collapsedCount = 0;
92
+ }
93
+ collapsedCount++;
94
+ continue;
95
+ }
96
+ // Emit collapsed marker if we just exited a collapsed section
97
+ if (inCollapsed) {
98
+ outputLines.push(dim(` ... (${collapsedCount} unchanged lines) ...`));
99
+ inCollapsed = false;
100
+ collapsedCount = 0;
101
+ }
102
+ const d = diff[i];
103
+ const lineNo = d.oldLineNo ?? d.newLineNo ?? 0;
104
+ const lineNoStr = dim(String(lineNo).padStart(4) + " ");
105
+ switch (d.op) {
106
+ case "equal":
107
+ outputLines.push(lineNoStr + dim(d.line));
108
+ break;
109
+ case "delete":
110
+ outputLines.push(lineNoStr + red(`- ${d.line}`));
111
+ break;
112
+ case "insert":
113
+ outputLines.push(lineNoStr + green(`+ ${d.line}`));
114
+ break;
115
+ }
116
+ }
117
+ // Trailing collapsed section
118
+ if (inCollapsed) {
119
+ outputLines.push(dim(` ... (${collapsedCount} unchanged lines) ...`));
120
+ }
121
+ // If no changes found
122
+ if (outputLines.length === 0) {
123
+ return "";
124
+ }
125
+ // Cap output
126
+ let body;
127
+ if (outputLines.length > MAX_OUTPUT_LINES) {
128
+ const truncated = outputLines.slice(0, MAX_FIRST_CHUNK);
129
+ const remaining = outputLines.length - MAX_FIRST_CHUNK;
130
+ truncated.push(dim(` ... (${remaining} more lines) ...`));
131
+ body = truncated.join("\n");
132
+ }
133
+ else {
134
+ body = outputLines.join("\n");
135
+ }
136
+ const header = cyan(`─── ${filePath} ───`);
137
+ return `${header}\n${body}`;
138
+ }
139
+ /** Diff marker used in tool output to separate normal output from diff data. */
140
+ export const DIFF_MARKER = "\n---DIFF---\n";
141
+ /**
142
+ * Encode old + new content after the diff marker for downstream rendering.
143
+ * Format: `---DIFF---\nFILE:path\nOLD_LEN:N\n<old content>\nNEW_LEN:N\n<new content>`
144
+ */
145
+ export function encodeDiffPayload(filePath, oldContent, newContent) {
146
+ return `${DIFF_MARKER}FILE:${filePath}\nOLD_LEN:${oldContent.length}\n${oldContent}\nNEW_LEN:${newContent.length}\n${newContent}`;
147
+ }
148
+ /**
149
+ * Detect and decode a diff payload from tool output.
150
+ * Returns null if no diff marker is found.
151
+ */
152
+ export function decodeDiffPayload(output) {
153
+ const idx = output.indexOf(DIFF_MARKER);
154
+ if (idx === -1)
155
+ return null;
156
+ const payload = output.slice(idx + DIFF_MARKER.length);
157
+ const fileMatch = payload.match(/^FILE:(.+)\n/);
158
+ if (!fileMatch)
159
+ return null;
160
+ const filePath = fileMatch[1];
161
+ const rest = payload.slice(fileMatch[0].length);
162
+ const oldLenMatch = rest.match(/^OLD_LEN:(\d+)\n/);
163
+ if (!oldLenMatch)
164
+ return null;
165
+ const oldLen = parseInt(oldLenMatch[1], 10);
166
+ const afterOldHeader = rest.slice(oldLenMatch[0].length);
167
+ const oldContent = afterOldHeader.slice(0, oldLen);
168
+ const afterOld = afterOldHeader.slice(oldLen);
169
+ const newLenMatch = afterOld.match(/^\nNEW_LEN:(\d+)\n/);
170
+ if (!newLenMatch)
171
+ return null;
172
+ const newLen = parseInt(newLenMatch[1], 10);
173
+ const newContent = afterOld.slice(newLenMatch[0].length, newLenMatch[0].length + newLen);
174
+ return { filePath, oldContent, newContent };
175
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Simple markdown-to-ANSI renderer for terminal output.
3
+ * Regex-based, no AST parser. Handles the common subset of markdown
4
+ * that LLMs produce: headers, bold, inline code, code blocks, bullet lists.
5
+ */
6
+ const ESC = "\x1b[";
7
+ const RESET = `${ESC}0m`;
8
+ const BOLD = `${ESC}1m`;
9
+ const DIM = `${ESC}2m`;
10
+ const ITALIC = `${ESC}3m`;
11
+ const CYAN = `${ESC}36m`;
12
+ const YELLOW = `${ESC}33m`;
13
+ const GREEN = `${ESC}32m`;
14
+ const MAGENTA = `${ESC}35m`;
15
+ const INVERSE = `${ESC}7m`;
16
+ const MAX_WIDTH = 80;
17
+ /** Render basic markdown to ANSI-colored terminal text. */
18
+ export function renderMarkdown(text) {
19
+ const lines = text.split("\n");
20
+ const out = [];
21
+ let inCodeBlock = false;
22
+ let codeLang = "";
23
+ for (const line of lines) {
24
+ // Code block fences
25
+ if (line.trimStart().startsWith("```")) {
26
+ if (!inCodeBlock) {
27
+ inCodeBlock = true;
28
+ codeLang = line.trimStart().slice(3).trim();
29
+ const label = codeLang ? ` ${codeLang}` : "";
30
+ out.push(`${DIM} ┌──${label}${"─".repeat(Math.max(0, MAX_WIDTH - 6 - label.length))}${RESET}`);
31
+ }
32
+ else {
33
+ inCodeBlock = false;
34
+ codeLang = "";
35
+ out.push(`${DIM} └${"─".repeat(MAX_WIDTH - 3)}${RESET}`);
36
+ }
37
+ continue;
38
+ }
39
+ if (inCodeBlock) {
40
+ out.push(`${DIM} │ ${line.slice(0, MAX_WIDTH - 4)}${RESET}`);
41
+ continue;
42
+ }
43
+ // Headers
44
+ const h3 = line.match(/^###\s+(.+)/);
45
+ if (h3) {
46
+ out.push(`${MAGENTA}${BOLD} ${h3[1]}${RESET}`);
47
+ continue;
48
+ }
49
+ const h2 = line.match(/^##\s+(.+)/);
50
+ if (h2) {
51
+ out.push(`${GREEN}${BOLD} ${h2[1]}${RESET}`);
52
+ continue;
53
+ }
54
+ const h1 = line.match(/^#\s+(.+)/);
55
+ if (h1) {
56
+ out.push(`${YELLOW}${BOLD}${h1[1]}${RESET}`);
57
+ continue;
58
+ }
59
+ // Bullet lists (-, *, +)
60
+ const bullet = line.match(/^(\s*)[*+-]\s+(.+)/);
61
+ if (bullet) {
62
+ const indent = bullet[1];
63
+ const content = renderInline(bullet[2]);
64
+ out.push(`${indent} ${DIM}${BOLD}·${RESET} ${content}`);
65
+ continue;
66
+ }
67
+ // Numbered lists
68
+ const numbered = line.match(/^(\s*)\d+[.)]\s+(.+)/);
69
+ if (numbered) {
70
+ const indent = numbered[1];
71
+ const num = line.match(/^(\s*)(\d+[.)])/);
72
+ const content = renderInline(numbered[2]);
73
+ out.push(`${indent} ${DIM}${num[2]}${RESET} ${content}`);
74
+ continue;
75
+ }
76
+ // Regular line — apply inline formatting
77
+ out.push(renderInline(line));
78
+ }
79
+ return out.join("\n");
80
+ }
81
+ /** Apply inline formatting: bold, italic, inline code. */
82
+ function renderInline(text) {
83
+ let result = text;
84
+ // Inline code (must go first to avoid bold/italic inside code)
85
+ result = result.replace(/`([^`]+)`/g, `${CYAN}${INVERSE} $1 ${RESET}`);
86
+ // Bold+italic (***text*** or ___text___)
87
+ result = result.replace(/\*\*\*(.+?)\*\*\*/g, `${BOLD}${ITALIC}$1${RESET}`);
88
+ result = result.replace(/___(.+?)___/g, `${BOLD}${ITALIC}$1${RESET}`);
89
+ // Bold (**text** or __text__)
90
+ result = result.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`);
91
+ result = result.replace(/__(.+?)__/g, `${BOLD}$1${RESET}`);
92
+ // Italic (*text* or _text_ — careful not to match mid-word underscores)
93
+ result = result.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, `${ITALIC}$1${RESET}`);
94
+ result = result.replace(/(?<!\w)_([^_]+)_(?!\w)/g, `${ITALIC}$1${RESET}`);
95
+ return result;
96
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Agent config presets — save/load provider+model+permissions combos.
3
+ *
4
+ * User presets stored at ~/.phren-agent/presets.json.
5
+ * Built-in presets are always available and cannot be overwritten.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ // ── Built-in presets ────────────────────────────────────────────────────
11
+ const BUILTIN_PRESETS = {
12
+ fast: {
13
+ provider: "ollama",
14
+ permissions: "auto-confirm",
15
+ maxTurns: 20,
16
+ },
17
+ careful: {
18
+ provider: "anthropic",
19
+ permissions: "suggest",
20
+ plan: true,
21
+ },
22
+ yolo: {
23
+ provider: "openrouter",
24
+ permissions: "full-auto",
25
+ maxTurns: 100,
26
+ budget: null,
27
+ },
28
+ };
29
+ const BUILTIN_NAMES = new Set(Object.keys(BUILTIN_PRESETS));
30
+ // ── File path ───────────────────────────────────────────────────────────
31
+ const PRESETS_PATH = path.join(os.homedir(), ".phren-agent", "presets.json");
32
+ // ── File I/O ────────────────────────────────────────────────────────────
33
+ function readFile() {
34
+ try {
35
+ const raw = fs.readFileSync(PRESETS_PATH, "utf-8");
36
+ return JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return { presets: {} };
40
+ }
41
+ }
42
+ function writeFile(data) {
43
+ const dir = path.dirname(PRESETS_PATH);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(PRESETS_PATH, JSON.stringify(data, null, 2) + "\n");
46
+ }
47
+ // ── Public API ──────────────────────────────────────────────────────────
48
+ /** Save a user preset. Cannot overwrite built-in names. */
49
+ export function savePreset(name, config) {
50
+ if (BUILTIN_NAMES.has(name)) {
51
+ throw new Error(`Cannot overwrite built-in preset "${name}".`);
52
+ }
53
+ const data = readFile();
54
+ data.presets[name] = config;
55
+ writeFile(data);
56
+ }
57
+ /** Load a preset by name. Checks built-ins first, then user presets. */
58
+ export function loadPreset(name) {
59
+ if (BUILTIN_PRESETS[name]) {
60
+ return { ...BUILTIN_PRESETS[name] };
61
+ }
62
+ const data = readFile();
63
+ const preset = data.presets[name];
64
+ return preset ? { ...preset } : null;
65
+ }
66
+ /** Delete a user preset. Cannot delete built-in presets. */
67
+ export function deletePreset(name) {
68
+ if (BUILTIN_NAMES.has(name)) {
69
+ throw new Error(`Cannot delete built-in preset "${name}".`);
70
+ }
71
+ const data = readFile();
72
+ if (!(name in data.presets))
73
+ return false;
74
+ delete data.presets[name];
75
+ writeFile(data);
76
+ return true;
77
+ }
78
+ /** List all presets: built-in + user. */
79
+ export function listPresets() {
80
+ const results = [];
81
+ for (const [name, preset] of Object.entries(BUILTIN_PRESETS)) {
82
+ results.push({ name, preset, builtin: true });
83
+ }
84
+ const data = readFile();
85
+ for (const [name, preset] of Object.entries(data.presets)) {
86
+ results.push({ name, preset, builtin: false });
87
+ }
88
+ return results;
89
+ }
90
+ /** Format a preset for display. */
91
+ export function formatPreset(name, preset, builtin) {
92
+ const parts = [];
93
+ if (preset.provider)
94
+ parts.push(`provider=${preset.provider}`);
95
+ if (preset.model)
96
+ parts.push(`model=${preset.model}`);
97
+ if (preset.permissions)
98
+ parts.push(`perms=${preset.permissions}`);
99
+ if (preset.maxTurns !== undefined)
100
+ parts.push(`turns=${preset.maxTurns}`);
101
+ if (preset.budget !== undefined)
102
+ parts.push(preset.budget === null ? "no-budget" : `budget=$${preset.budget}`);
103
+ if (preset.plan)
104
+ parts.push("plan");
105
+ const tag = builtin ? " (built-in)" : "";
106
+ return `${name}${tag}: ${parts.join(", ")}`;
107
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Progress indicators for terminal output.
3
+ */
4
+ const FILLED = "█";
5
+ const EMPTY = "░";
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ /** Render a progress bar: `[████░░░░] 50%` */
8
+ export function renderProgressBar(current, total, width = 20) {
9
+ if (total <= 0)
10
+ return `[${"░".repeat(width)}] 0%`;
11
+ const pct = Math.min(1, Math.max(0, current / total));
12
+ const filled = Math.round(pct * width);
13
+ const empty = width - filled;
14
+ const percent = Math.round(pct * 100);
15
+ return `[${FILLED.repeat(filled)}${EMPTY.repeat(empty)}] ${percent}%`;
16
+ }
17
+ /** Get a braille spinner frame by index (wraps automatically). */
18
+ export function renderSpinnerFrame(frame) {
19
+ return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
20
+ }
21
+ /** Format elapsed time from a start timestamp: `2.1s` or `1m 30s`. */
22
+ export function renderElapsed(startMs) {
23
+ const elapsed = Date.now() - startMs;
24
+ if (elapsed < 1000)
25
+ return `${elapsed}ms`;
26
+ const secs = elapsed / 1000;
27
+ if (secs < 60)
28
+ return `${secs.toFixed(1)}s`;
29
+ const mins = Math.floor(secs / 60);
30
+ const remSecs = Math.round(secs % 60);
31
+ return `${mins}m ${remSecs}s`;
32
+ }