@phren/agent 0.1.0 → 0.1.2

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.
@@ -9,7 +9,6 @@ import { injectPlanPrompt, requestPlanApproval } from "./plan.js";
9
9
  import { detectLintCommand, detectTestCommand, runPostEditCheck } from "./tools/lint-test.js";
10
10
  import { createCheckpoint } from "./checkpoint.js";
11
11
  const MAX_TOOL_CONCURRENCY = 5;
12
- const MAX_LINT_TEST_RETRIES = 3;
13
12
  export function createSession(contextLimit) {
14
13
  return {
15
14
  messages: [],
@@ -76,7 +75,7 @@ async function consumeStream(stream, costTracker, onTextDelta) {
76
75
  try {
77
76
  input = JSON.parse(jsonStr);
78
77
  }
79
- catch (e) {
78
+ catch {
80
79
  process.stderr.write(`\x1b[33m[warning] Malformed tool_use JSON for ${tool.name} (${tool.id}), skipping block\x1b[0m\n`);
81
80
  continue;
82
81
  }
@@ -105,7 +104,6 @@ export async function runTurn(userInput, session, config, hooks) {
105
104
  const toolDefs = registry.getDefinitions();
106
105
  const spinner = createSpinner();
107
106
  const useStream = typeof provider.chatStream === "function";
108
- const write = hooks?.onTextDelta ?? process.stdout.write.bind(process.stdout);
109
107
  const status = hooks?.onStatus ?? ((msg) => process.stderr.write(msg));
110
108
  // Plan mode: modify system prompt for first turn
111
109
  let planPending = config.plan && session.turns === 0;
@@ -67,37 +67,3 @@ export function createCheckpoint(cwd, label) {
67
67
  return null;
68
68
  }
69
69
  }
70
- /** Rollback to a checkpoint by discarding current changes and applying the stash. */
71
- export function rollbackToCheckpoint(cwd, ref) {
72
- if (!isGitRepo(cwd))
73
- return false;
74
- try {
75
- // Discard current working tree changes
76
- execFileSync("git", ["checkout", "."], {
77
- cwd,
78
- encoding: "utf-8",
79
- stdio: ["ignore", "pipe", "pipe"],
80
- });
81
- // Apply the stash ref
82
- execFileSync("git", ["stash", "apply", ref], {
83
- cwd,
84
- encoding: "utf-8",
85
- stdio: ["ignore", "pipe", "pipe"],
86
- });
87
- return true;
88
- }
89
- catch {
90
- return false;
91
- }
92
- }
93
- /** List stored checkpoints. */
94
- export function listCheckpoints(cwd) {
95
- return loadStore(cwd).checkpoints;
96
- }
97
- /** Get the latest checkpoint ref. */
98
- export function getLatestCheckpoint(cwd) {
99
- const store = loadStore(cwd);
100
- return store.checkpoints.length > 0
101
- ? store.checkpoints[store.checkpoints.length - 1].ref
102
- : null;
103
- }
package/dist/index.js CHANGED
@@ -8,6 +8,9 @@ import { editFileTool } from "./tools/edit-file.js";
8
8
  import { shellTool } from "./tools/shell.js";
9
9
  import { globTool } from "./tools/glob.js";
10
10
  import { grepTool } from "./tools/grep.js";
11
+ import { createWebFetchTool } from "./tools/web-fetch.js";
12
+ import { createWebSearchTool } from "./tools/web-search.js";
13
+ import { createPhrenAddTaskTool } from "./tools/phren-add-task.js";
11
14
  import { createPhrenSearchTool } from "./tools/phren-search.js";
12
15
  import { createPhrenFindingTool } from "./tools/phren-finding.js";
13
16
  import { createPhrenGetTasksTool, createPhrenCompleteTaskTool } from "./tools/phren-tasks.js";
@@ -84,7 +87,10 @@ export async function runAgentCli(raw) {
84
87
  contextSnippet += `\n\n## Agent context (${phrenCtx.project})\n\n${projectCtx}`;
85
88
  }
86
89
  }
87
- const systemPrompt = buildSystemPrompt(contextSnippet, priorSummary);
90
+ const systemPrompt = buildSystemPrompt(contextSnippet, priorSummary, {
91
+ name: provider.name,
92
+ model: provider.model,
93
+ });
88
94
  // Dry run: print system prompt and exit
89
95
  if (args.dryRun) {
90
96
  console.log("=== System Prompt ===");
@@ -111,8 +117,11 @@ export async function runAgentCli(raw) {
111
117
  registry.register(createPhrenFindingTool(phrenCtx, sessionId));
112
118
  registry.register(createPhrenGetTasksTool(phrenCtx));
113
119
  registry.register(createPhrenCompleteTaskTool(phrenCtx, sessionId));
120
+ registry.register(createPhrenAddTaskTool(phrenCtx, sessionId));
114
121
  }
115
- // Git tools
122
+ // Web tools
123
+ registry.register(createWebFetchTool());
124
+ registry.register(createWebSearchTool());
116
125
  registry.register(gitStatusTool);
117
126
  registry.register(gitDiffTool);
118
127
  registry.register(gitCommitTool);
@@ -85,7 +85,6 @@ export function showModelPicker(provider, currentModel, w) {
85
85
  const m = models[i];
86
86
  const selected = i === cursor;
87
87
  const arrow = selected ? s.cyan("▸") : " ";
88
- const label = selected ? s.bold(m.label) : s.dim(m.label);
89
88
  const padded = m.label + " ".repeat(maxLabel - m.label.length);
90
89
  const labelStr = selected ? s.bold(padded) : s.dim(padded);
91
90
  const meter = renderReasoningMeter(reasoningState[i], m.reasoningRange);
@@ -97,7 +96,6 @@ export function showModelPicker(provider, currentModel, w) {
97
96
  // Initial draw
98
97
  drawPicker();
99
98
  return new Promise((resolve) => {
100
- const wasRaw = process.stdin.isRaw;
101
99
  function onKey(_ch, key) {
102
100
  if (!key)
103
101
  return;
@@ -92,34 +92,11 @@ export function removeCustomModel(id) {
92
92
  export function getCustomModels() {
93
93
  return loadConfig().customModels;
94
94
  }
95
- /** Get all models for a provider (built-in + custom). */
96
- export async function getAllModelsForProvider(provider, currentModel) {
97
- // Import dynamically to avoid circular dep
98
- const { getAvailableModels } = await import("./model-picker.js");
99
- const builtIn = getAvailableModels(provider, currentModel);
100
- // Add custom models for this provider
101
- const custom = getCustomModels().filter((m) => m.provider === provider);
102
- for (const c of custom) {
103
- if (!builtIn.some((b) => b.id === c.id)) {
104
- builtIn.push({
105
- id: c.id,
106
- provider: provider,
107
- label: c.label + " ★",
108
- reasoning: c.reasoning,
109
- reasoningRange: c.reasoningRange,
110
- contextWindow: c.contextWindow,
111
- });
112
- }
113
- }
114
- return builtIn;
115
- }
116
95
  // ── Format helpers for CLI display ──────────────────────────────────────────
117
96
  const DIM = "\x1b[2m";
118
97
  const BOLD = "\x1b[1m";
119
98
  const GREEN = "\x1b[32m";
120
99
  const RED = "\x1b[31m";
121
- const CYAN = "\x1b[36m";
122
- const YELLOW = "\x1b[33m";
123
100
  const RESET = "\x1b[0m";
124
101
  export function formatProviderList() {
125
102
  const statuses = getProviderStatuses();
@@ -6,7 +6,6 @@
6
6
  const ESC = "\x1b[";
7
7
  const RESET = `${ESC}0m`;
8
8
  const BOLD = `${ESC}1m`;
9
- const DIM = `${ESC}2m`;
10
9
  const MAGENTA = `${ESC}35m`;
11
10
  const GREEN = `${ESC}32m`;
12
11
  const YELLOW = `${ESC}33m`;
@@ -93,11 +93,11 @@ function formatToolEnd(toolName, input, output, isError, durationMs) {
93
93
  const icon = isError ? s.red("x") : s.green("ok");
94
94
  const preview = JSON.stringify(input).slice(0, 50);
95
95
  const header = s.dim(` ${toolName}(${preview})`) + ` ${icon} ${s.dim(dur)}`;
96
- const outputLines = output.split("\n").slice(0, 4);
96
+ const allLines = output.split("\n");
97
97
  const w = cols();
98
- const body = outputLines.map((l) => s.dim(` | ${l.slice(0, w - 6)}`)).join("\n");
99
- const more = output.split("\n").length > 4 ? s.dim(` | ... (${output.split("\n").length} lines)`) : "";
100
- return `${header}\n${body}${more ? "\n" + more : ""}`;
98
+ const body = allLines.slice(0, 4).map((l) => s.dim(` | ${l.slice(0, w - 6)}`)).join("\n");
99
+ const more = allLines.length > 4 ? `\n${s.dim(` | ... (${allLines.length} lines)`)}` : "";
100
+ return `${header}\n${body}${more}`;
101
101
  }
102
102
  // ── Main TUI ─────────────────────────────────────────────────────────────────
103
103
  export async function startMultiTui(spawner, config) {
@@ -603,8 +603,6 @@ export async function startMultiTui(spawner, config) {
603
603
  });
604
604
  // Handle terminal resize
605
605
  process.stdout.on("resize", () => render());
606
- // Initial render
607
- render();
608
606
  // Register panes for any agents that already exist
609
607
  for (const agent of spawner.listAgents()) {
610
608
  getOrCreatePane(agent.id);
@@ -58,7 +58,3 @@ export function addAllow(toolName, input, scope) {
58
58
  export function clearAllowlist() {
59
59
  sessionAllowlist.length = 0;
60
60
  }
61
- /** Get a snapshot of the current allowlist (for display). */
62
- export function getAllowlist() {
63
- return sessionAllowlist;
64
- }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Privacy safeguards — scrub sensitive data from tool outputs, findings, and LLM context.
3
+ *
4
+ * Prevents accidental leakage of:
5
+ * - API keys and tokens in tool output (e.g., from reading .env files)
6
+ * - Passwords and connection strings
7
+ * - PII patterns (emails, IPs shown in logs)
8
+ * - Private keys and certificates
9
+ *
10
+ * Applied at three layers:
11
+ * 1. Tool output → before sending to LLM (scrubToolOutput)
12
+ * 2. Findings → before saving to phren (scrubFinding)
13
+ * 3. Session summaries → before persisting (scrubSummary)
14
+ */
15
+ // ── Secret patterns ──────────────────────────────────────────────────────
16
+ /** Patterns that match common API key/token formats. */
17
+ const SECRET_PATTERNS = [
18
+ // Generic API keys (long hex/base64 strings prefixed by common env var names)
19
+ { pattern: /(?:api[_-]?key|api[_-]?secret|api[_-]?token)\s*[:=]\s*["']?([A-Za-z0-9_\-/.+=]{20,})["']?/gi, label: "API_KEY" },
20
+ // AWS keys
21
+ { pattern: /AKIA[0-9A-Z]{16}/g, label: "AWS_ACCESS_KEY" },
22
+ { pattern: /(?:aws[_-]?secret[_-]?access[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9/+=]{30,})["']?/gi, label: "AWS_SECRET" },
23
+ // Bearer tokens
24
+ { pattern: /Bearer\s+[A-Za-z0-9_\-/.+=]{20,}/g, label: "BEARER_TOKEN" },
25
+ // GitHub tokens
26
+ { pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/g, label: "GITHUB_TOKEN" },
27
+ // Anthropic keys
28
+ { pattern: /sk-ant-[A-Za-z0-9_\-]{20,}/g, label: "ANTHROPIC_KEY" },
29
+ // OpenAI keys
30
+ { pattern: /sk-[A-Za-z0-9]{20,}/g, label: "OPENAI_KEY" },
31
+ // Generic password assignments
32
+ { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s"']{8,})["']?/gi, label: "PASSWORD" },
33
+ // Connection strings with passwords
34
+ { pattern: /:\/\/[^:]+:([^@\s]{8,})@/g, label: "CONNECTION_PASSWORD" },
35
+ // Private key blocks
36
+ { pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g, label: "PRIVATE_KEY" },
37
+ // JWT tokens
38
+ { pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, label: "JWT" },
39
+ // Slack tokens
40
+ { pattern: /xox[bpras]-[0-9]{10,}-[A-Za-z0-9-]+/g, label: "SLACK_TOKEN" },
41
+ // Env variable assignments with secret-ish names
42
+ { pattern: /(?:SECRET|TOKEN|PASSWORD|PRIVATE_KEY|AUTH|CREDENTIAL)[A-Z_]*\s*=\s*["']?([^\s"']{8,})["']?/gi, label: "SECRET_VAR" },
43
+ ];
44
+ /** Patterns for PII that shouldn't be stored in findings. */
45
+ const PII_PATTERNS = [
46
+ // Email addresses (only redact in contexts where they're likely PII, not code)
47
+ { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, label: "EMAIL" },
48
+ // IP addresses (v4) — only in log-like contexts
49
+ { pattern: /\b(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}\b/g, label: "IP_ADDRESS" },
50
+ ];
51
+ // ── Scrubbing functions ──────────────────────────────────────────────────
52
+ /**
53
+ * Scrub sensitive data from tool output before it's sent to the LLM.
54
+ * This is the primary privacy gate — catches secrets in file reads, command output, etc.
55
+ */
56
+ export function scrubToolOutput(toolName, output) {
57
+ // Don't scrub short outputs (unlikely to contain full secrets)
58
+ if (output.length < 20)
59
+ return output;
60
+ let scrubbed = output;
61
+ for (const { pattern, label } of SECRET_PATTERNS) {
62
+ // Reset regex state for global patterns
63
+ pattern.lastIndex = 0;
64
+ scrubbed = scrubbed.replace(pattern, `[REDACTED:${label}]`);
65
+ }
66
+ return scrubbed;
67
+ }
68
+ /**
69
+ * Check if a string contains likely secrets. Returns true if secrets detected.
70
+ * Use this as a gate before saving to persistent storage.
71
+ */
72
+ export function containsSecrets(text) {
73
+ for (const { pattern } of SECRET_PATTERNS) {
74
+ pattern.lastIndex = 0;
75
+ if (pattern.test(text))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Scrub sensitive data from a finding before saving to phren.
82
+ * More aggressive than tool output scrubbing — also catches PII.
83
+ */
84
+ export function scrubFinding(finding) {
85
+ let scrubbed = finding;
86
+ // Secret patterns
87
+ for (const { pattern, label } of SECRET_PATTERNS) {
88
+ pattern.lastIndex = 0;
89
+ scrubbed = scrubbed.replace(pattern, `[REDACTED:${label}]`);
90
+ }
91
+ // PII patterns (only in findings, not tool outputs where they may be needed)
92
+ for (const { pattern, label } of PII_PATTERNS) {
93
+ pattern.lastIndex = 0;
94
+ scrubbed = scrubbed.replace(pattern, `[REDACTED:${label}]`);
95
+ }
96
+ return scrubbed;
97
+ }
98
+ /**
99
+ * Scrub a session summary before persisting.
100
+ */
101
+ export function scrubSummary(summary) {
102
+ return scrubFinding(summary);
103
+ }
104
+ /**
105
+ * Check if tool output looks like it came from reading a sensitive file
106
+ * (e.g., .env, credentials). Returns true if the content appears to be
107
+ * mostly key-value secrets.
108
+ */
109
+ export function looksLikeSecretsFile(output) {
110
+ const lines = output.split("\n").filter(l => l.trim() && !l.startsWith("#"));
111
+ if (lines.length < 2)
112
+ return false;
113
+ let secretLines = 0;
114
+ for (const line of lines) {
115
+ // Count lines that look like KEY=secret_value
116
+ if (/^[A-Z_]{2,}=\S+/.test(line)) {
117
+ for (const { pattern } of SECRET_PATTERNS) {
118
+ pattern.lastIndex = 0;
119
+ if (pattern.test(line)) {
120
+ secretLines++;
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // If >50% of non-comment lines are secrets, it's probably a secrets file
127
+ return secretLines / lines.length > 0.5;
128
+ }
129
+ /**
130
+ * Validate that a finding doesn't contain obvious secrets before saving.
131
+ * Returns an error message if the finding should be rejected, null if OK.
132
+ */
133
+ export function validateFindingSafety(finding) {
134
+ if (containsSecrets(finding)) {
135
+ return "Finding contains detected secrets (API keys, tokens, passwords). Secrets should never be stored in findings. The sensitive values have been redacted.";
136
+ }
137
+ return null;
138
+ }
139
+ /**
140
+ * Patterns that indicate prompt injection — text trying to override AI instructions.
141
+ * Each entry: [regex, flag label].
142
+ */
143
+ const PROMPT_INJECTION_PATTERNS = [
144
+ // Direct instruction override attempts
145
+ [/\b(?:ignore|disregard|forget|override)\s+(?:all\s+)?(?:previous|prior|above|earlier|your|the|safety|system)\b/i, "prompt_injection:instruction_override"],
146
+ [/\byou\s+(?:must|should|shall|will|are\s+(?:now|required\s+to))\b/i, "prompt_injection:directive"],
147
+ [/\byour\s+new\s+(?:instructions?|role|purpose|directive)\b/i, "prompt_injection:role_reassignment"],
148
+ [/\bas\s+an?\s+(?:AI|language\s+model|assistant|LLM)\b/i, "prompt_injection:identity_framing"],
149
+ [/\bforget\s+everything\b/i, "prompt_injection:memory_wipe"],
150
+ // System prompt markers (ChatML, Llama, etc.)
151
+ [/\[INST\]|\[\/INST\]/i, "prompt_injection:chatml_inst"],
152
+ [/<<SYS>>|<<\/SYS>>/i, "prompt_injection:llama_sys"],
153
+ [/<\|im_start\|>|<\|im_end\|>/i, "prompt_injection:chatml_marker"],
154
+ [/^system\s*:/im, "prompt_injection:system_prefix"],
155
+ [/SYSTEM\s*:\s*you\s+are/i, "prompt_injection:system_role"],
156
+ // Jailbreak-style keywords
157
+ [/\b(?:DAN|do\s+anything\s+now|jailbreak)\b/i, "prompt_injection:jailbreak_keyword"],
158
+ ];
159
+ /**
160
+ * Patterns for dangerous executable instructions embedded in findings.
161
+ */
162
+ const DANGEROUS_COMMAND_PATTERNS = [
163
+ // Pipe-to-shell patterns
164
+ [/\bcurl\s+[^\s|]*\s*\|\s*(?:sh|bash|zsh|eval)\b/i, "dangerous_command:curl_pipe_shell"],
165
+ [/\bwget\s+[^\s|]*\s*\|\s*(?:sh|bash|zsh|eval)\b/i, "dangerous_command:wget_pipe_shell"],
166
+ // Dangerous eval/exec
167
+ [/\beval\s*\(\s*['"`].*['"`]\s*\)/i, "dangerous_command:eval_literal"],
168
+ [/\bexec\s*\(\s*['"`].*['"`]\s*\)/i, "dangerous_command:exec_literal"],
169
+ // Destructive commands
170
+ [/\brm\s+-rf\s+[/~]/i, "dangerous_command:rm_rf_root"],
171
+ [/\bdd\s+.*\bof=\/dev\//i, "dangerous_command:dd_device"],
172
+ [/\bmkfs\b/i, "dangerous_command:mkfs"],
173
+ // Instructions to always run dangerous commands
174
+ [/\b(?:always|first)\s+run\s+[`"'].*(?:curl|wget|rm\s+-rf|chmod\s+777|sudo)/i, "dangerous_command:instruction_to_run"],
175
+ ];
176
+ /**
177
+ * Patterns for authority/permission escalation claims.
178
+ */
179
+ const AUTHORITY_ESCALATION_PATTERNS = [
180
+ [/\b(?:user|admin|owner)\s+has\s+(?:authorized|approved|granted|confirmed)\b/i, "authority_escalation:false_authorization"],
181
+ [/\bfull\s+(?:access|permission|control)\s+(?:granted|given|enabled)\b/i, "authority_escalation:false_access"],
182
+ [/\bskip\s+(?:validation|verification|safety|security|auth)\b/i, "authority_escalation:skip_safety"],
183
+ [/\bno\s+need\s+to\s+(?:ask|confirm|verify|validate|check)\b/i, "authority_escalation:skip_confirmation"],
184
+ [/\bauto[-\s]?(?:approve|accept|confirm)\s+(?:all|every)\b/i, "authority_escalation:auto_approve"],
185
+ [/\bdisable\s+(?:safety|security|protection|guard|check|filter)\b/i, "authority_escalation:disable_safety"],
186
+ ];
187
+ /**
188
+ * Patterns for self-replication — findings that instruct saving more findings.
189
+ */
190
+ const SELF_REPLICATION_PATTERNS = [
191
+ [/\b(?:save|add|create|write|store|append)\s+(?:this\s+)?(?:finding|memory|memories|findings)\b/i, "self_replication:save_finding"],
192
+ [/\badd_finding\b/i, "self_replication:tool_invocation"],
193
+ [/\bremember\s+to\s+always\b/i, "self_replication:persistent_instruction"],
194
+ [/\b(?:when|if)\s+you\s+see\s+this\b/i, "self_replication:conditional_trigger"],
195
+ [/\bspread\s+(?:this|the)\s+(?:message|finding|memory)\b/i, "self_replication:spread_instruction"],
196
+ ];
197
+ /**
198
+ * Check a finding for integrity issues — prompt injection, dangerous commands,
199
+ * authority escalation, and self-replication attempts.
200
+ *
201
+ * Returns a structured result with risk level and triggered flags.
202
+ * Risk levels:
203
+ * - "none": no issues detected
204
+ * - "low": one minor flag, likely benign but noted
205
+ * - "medium": multiple flags or a single concerning pattern
206
+ * - "high": strong prompt injection or dangerous command pattern
207
+ */
208
+ export function checkFindingIntegrity(finding) {
209
+ const flags = [];
210
+ // Check all pattern categories
211
+ for (const [pattern, flag] of PROMPT_INJECTION_PATTERNS) {
212
+ pattern.lastIndex = 0;
213
+ if (pattern.test(finding))
214
+ flags.push(flag);
215
+ }
216
+ for (const [pattern, flag] of DANGEROUS_COMMAND_PATTERNS) {
217
+ pattern.lastIndex = 0;
218
+ if (pattern.test(finding))
219
+ flags.push(flag);
220
+ }
221
+ for (const [pattern, flag] of AUTHORITY_ESCALATION_PATTERNS) {
222
+ pattern.lastIndex = 0;
223
+ if (pattern.test(finding))
224
+ flags.push(flag);
225
+ }
226
+ for (const [pattern, flag] of SELF_REPLICATION_PATTERNS) {
227
+ pattern.lastIndex = 0;
228
+ if (pattern.test(finding))
229
+ flags.push(flag);
230
+ }
231
+ if (flags.length === 0) {
232
+ return { safe: true, risk: "none", flags: [] };
233
+ }
234
+ // Determine risk level based on count and severity
235
+ const hasHighSeverity = flags.some(f => f.startsWith("prompt_injection:") || f.startsWith("dangerous_command:") || f.startsWith("authority_escalation:disable_safety"));
236
+ const hasMediumSeverity = flags.some(f => f.startsWith("authority_escalation:") || f.startsWith("self_replication:"));
237
+ let risk;
238
+ if (hasHighSeverity || flags.length >= 3) {
239
+ risk = "high";
240
+ }
241
+ else if (hasMediumSeverity || flags.length >= 2) {
242
+ risk = "medium";
243
+ }
244
+ else {
245
+ risk = "low";
246
+ }
247
+ return { safe: risk !== "high", risk, flags };
248
+ }
@@ -1,6 +1,7 @@
1
- export function buildSystemPrompt(phrenContext, priorSummary) {
1
+ export function buildSystemPrompt(phrenContext, priorSummary, providerInfo) {
2
+ const modelNote = providerInfo ? ` You are running on ${providerInfo.name}${providerInfo.model ? ` (model: ${providerInfo.model})` : ""}.` : "";
2
3
  const parts = [
3
- `You are phren-agent, a coding assistant with persistent memory powered by phren. You retain knowledge across sessions — past decisions, discovered patterns, and project context are all searchable. Use this memory to avoid repeating mistakes and to build on prior work.`,
4
+ `You are phren-agent, a coding assistant with persistent memory powered by phren.${modelNote} You retain knowledge across sessions — past decisions, discovered patterns, and project context are all searchable. Use this memory to avoid repeating mistakes and to build on prior work.`,
4
5
  "",
5
6
  "## Workflow",
6
7
  "1. **Orient** — Before starting, search phren for relevant findings (`phren_search`) and check active tasks (`phren_get_tasks`). Past sessions may have context that saves time.",
@@ -0,0 +1,49 @@
1
+ import { addTasks } from "@phren/cli/data/tasks";
2
+ export function createPhrenAddTaskTool(ctx, sessionId) {
3
+ return {
4
+ name: "phren_add_task",
5
+ description: "Add a new task to the phren task list for a project. " +
6
+ "Use this to track work items discovered during execution that should be addressed later. " +
7
+ "Good tasks: TODOs found in code, follow-up work, bugs to fix, tech debt. " +
8
+ "Bad tasks: obvious next steps, tasks you'll complete in this session.",
9
+ input_schema: {
10
+ type: "object",
11
+ properties: {
12
+ item: {
13
+ type: "string",
14
+ description: "Task description. Be specific — include file paths, function names, or error context.",
15
+ },
16
+ project: {
17
+ type: "string",
18
+ description: "Project name. Omit to use current project.",
19
+ },
20
+ priority: {
21
+ type: "string",
22
+ enum: ["high", "medium", "low"],
23
+ description: "Task priority. Default: medium.",
24
+ },
25
+ },
26
+ required: ["item"],
27
+ },
28
+ async execute(input) {
29
+ const item = input.item;
30
+ const project = input.project || ctx.project;
31
+ const priority = input.priority || "medium";
32
+ if (!project)
33
+ return { output: "No project context. Specify a project name.", is_error: true };
34
+ try {
35
+ // Format with priority prefix if not medium
36
+ const taskText = priority !== "medium" ? `[${priority}] ${item}` : item;
37
+ const result = addTasks(ctx.phrenPath, project, [taskText]);
38
+ if (result.ok) {
39
+ return { output: `Task added to ${project}: ${item}` };
40
+ }
41
+ return { output: result.error ?? "Failed to add task.", is_error: true };
42
+ }
43
+ catch (err) {
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ return { output: `Failed: ${msg}`, is_error: true };
46
+ }
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,40 @@
1
+ export function createWebFetchTool() {
2
+ return {
3
+ name: "web_fetch",
4
+ description: "Fetch a URL and return its text content. Use for reading documentation, API references, or web pages. Returns plain text (HTML tags stripped). Max 50KB response.",
5
+ input_schema: {
6
+ type: "object",
7
+ properties: {
8
+ url: { type: "string", description: "The URL to fetch." },
9
+ max_length: { type: "number", description: "Max response length in characters. Default: 50000." },
10
+ },
11
+ required: ["url"],
12
+ },
13
+ async execute(input) {
14
+ const url = input.url;
15
+ const maxLen = input.max_length || 50_000;
16
+ try {
17
+ const res = await fetch(url, {
18
+ headers: { "User-Agent": "phren-agent/0.1" },
19
+ signal: AbortSignal.timeout(15_000),
20
+ });
21
+ if (!res.ok)
22
+ return { output: `HTTP ${res.status}: ${res.statusText}`, is_error: true };
23
+ let text = await res.text();
24
+ // Strip HTML tags for readability
25
+ text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
26
+ text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
27
+ text = text.replace(/<[^>]+>/g, " ");
28
+ text = text.replace(/\s{2,}/g, " ").trim();
29
+ if (text.length > maxLen) {
30
+ text = text.slice(0, maxLen) + `\n\n[truncated at ${maxLen} chars]`;
31
+ }
32
+ return { output: text };
33
+ }
34
+ catch (err) {
35
+ const msg = err instanceof Error ? err.message : String(err);
36
+ return { output: `Fetch failed: ${msg}`, is_error: true };
37
+ }
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,93 @@
1
+ export function createWebSearchTool() {
2
+ return {
3
+ name: "web_search",
4
+ description: "Search the web for documentation, error messages, library APIs, or any technical information. " +
5
+ "Returns a list of search results with titles, URLs, and snippets. " +
6
+ "Use this when you need external information not available in the codebase or phren knowledge base.",
7
+ input_schema: {
8
+ type: "object",
9
+ properties: {
10
+ query: {
11
+ type: "string",
12
+ description: "Search query. Be specific — include error messages, library names, or version numbers.",
13
+ },
14
+ limit: {
15
+ type: "number",
16
+ description: "Max results to return. Default: 5.",
17
+ },
18
+ },
19
+ required: ["query"],
20
+ },
21
+ async execute(input) {
22
+ const query = input.query;
23
+ const limit = Math.min(input.limit || 5, 10);
24
+ try {
25
+ const results = await searchDuckDuckGo(query, limit);
26
+ if (results.length === 0) {
27
+ return { output: "No search results found." };
28
+ }
29
+ const formatted = results.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`).join("\n\n");
30
+ return { output: formatted };
31
+ }
32
+ catch (err) {
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ return { output: `Search failed: ${msg}`, is_error: true };
35
+ }
36
+ },
37
+ };
38
+ }
39
+ async function searchDuckDuckGo(query, limit) {
40
+ const encoded = encodeURIComponent(query);
41
+ const url = `https://html.duckduckgo.com/html/?q=${encoded}`;
42
+ const res = await fetch(url, {
43
+ headers: {
44
+ "User-Agent": "phren-agent/0.1 (search tool)",
45
+ "Accept": "text/html",
46
+ },
47
+ signal: AbortSignal.timeout(10_000),
48
+ });
49
+ if (!res.ok) {
50
+ throw new Error(`Search returned HTTP ${res.status}`);
51
+ }
52
+ const html = await res.text();
53
+ return parseSearchResults(html, limit);
54
+ }
55
+ function parseSearchResults(html, limit) {
56
+ const results = [];
57
+ // DuckDuckGo HTML results are in <div class="result"> blocks
58
+ // Extract links and snippets using regex (no DOM parser dependency)
59
+ const resultBlocks = html.match(/<div class="links_main[\s\S]*?<\/div>\s*<\/div>/gi) || [];
60
+ for (const block of resultBlocks) {
61
+ if (results.length >= limit)
62
+ break;
63
+ // Extract URL from the result link
64
+ const urlMatch = block.match(/<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>/i);
65
+ const titleMatch = block.match(/<a[^>]*class="result__a"[^>]*>([\s\S]*?)<\/a>/i);
66
+ const snippetMatch = block.match(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i);
67
+ if (!urlMatch || !titleMatch)
68
+ continue;
69
+ let href = urlMatch[1];
70
+ // DuckDuckGo wraps URLs through their redirect — extract the actual URL
71
+ const uddgMatch = href.match(/uddg=([^&]+)/);
72
+ if (uddgMatch) {
73
+ href = decodeURIComponent(uddgMatch[1]);
74
+ }
75
+ const title = stripHtml(titleMatch[1]).trim();
76
+ const snippet = snippetMatch ? stripHtml(snippetMatch[1]).trim() : "";
77
+ if (title && href) {
78
+ results.push({ title, url: href, snippet });
79
+ }
80
+ }
81
+ return results;
82
+ }
83
+ function stripHtml(html) {
84
+ return html
85
+ .replace(/<[^>]+>/g, "")
86
+ .replace(/&amp;/g, "&")
87
+ .replace(/&lt;/g, "<")
88
+ .replace(/&gt;/g, ">")
89
+ .replace(/&quot;/g, '"')
90
+ .replace(/&#x27;/g, "'")
91
+ .replace(/&nbsp;/g, " ")
92
+ .replace(/\s{2,}/g, " ");
93
+ }
package/dist/tui.js CHANGED
@@ -46,6 +46,19 @@ const PERMISSION_LABELS = {
46
46
  "auto-confirm": "auto",
47
47
  "full-auto": "full-auto",
48
48
  };
49
+ const PERMISSION_ICONS = {
50
+ "suggest": "○",
51
+ "auto-confirm": "◐",
52
+ "full-auto": "●",
53
+ };
54
+ const PERMISSION_COLORS = {
55
+ "suggest": s.cyan,
56
+ "auto-confirm": s.green,
57
+ "full-auto": s.yellow,
58
+ };
59
+ function permTag(mode) {
60
+ return PERMISSION_COLORS[mode](`${PERMISSION_ICONS[mode]} ${mode}`);
61
+ }
49
62
  // ── Status bar ───────────────────────────────────────────────────────────────
50
63
  function renderStatusBar(provider, project, turns, cost, permMode, agentCount) {
51
64
  const modeLabel = permMode ? PERMISSION_LABELS[permMode] : "";
@@ -84,10 +97,9 @@ function formatDuration(ms) {
84
97
  return `${mins}m ${secs}s`;
85
98
  }
86
99
  function formatToolInput(name, input) {
87
- // Show the most relevant input field for each tool type
88
100
  switch (name) {
89
- case "read_file": return input.file_path ?? "";
90
- case "write_file": return input.file_path ?? "";
101
+ case "read_file":
102
+ case "write_file":
91
103
  case "edit_file": return input.file_path ?? "";
92
104
  case "shell": return (input.command ?? "").slice(0, 60);
93
105
  case "glob": return input.pattern ?? "";
@@ -149,6 +161,10 @@ export async function startTui(config, spawner) {
149
161
  let menuFilterActive = false;
150
162
  let menuFilterBuf = "";
151
163
  let ctrlCCount = 0;
164
+ // Input history
165
+ const inputHistory = [];
166
+ let historyIndex = -1;
167
+ let savedInput = "";
152
168
  // ── Menu rendering ─────────────────────────────────────────────────────
153
169
  async function renderMenu() {
154
170
  const mod = await loadMenuModule();
@@ -190,25 +206,17 @@ export async function startTui(config, spawner) {
190
206
  if (!isTTY)
191
207
  return;
192
208
  const mode = config.registry.permissionConfig.mode;
193
- const modeIcon = mode === "full-auto" ? "●" : mode === "auto-confirm" ? "◐" : "○";
194
- const modeColor = mode === "full-auto" ? s.yellow : mode === "auto-confirm" ? s.green : s.cyan;
209
+ const color = PERMISSION_COLORS[mode];
210
+ const icon = PERMISSION_ICONS[mode];
195
211
  const rows = process.stdout.rows || 24;
196
212
  const c = cols();
197
213
  if (!skipNewline)
198
214
  w.write("\n");
199
- // Separator line + prompt on last 2 rows
200
- const permLabel = PERMISSION_LABELS[mode];
201
- const separator = s.dim("─".repeat(c));
202
- const rightHints = s.dim(`${bashMode ? "! bash" : permLabel} · shift+tab to cycle · esc to interrupt`);
203
- const rightLen = stripAnsi(rightHints).length;
204
- const sepLine = s.dim("─".repeat(Math.max(1, c - rightLen - 1))) + " " + rightHints;
205
- w.write(`${ESC}${rows - 1};1H${ESC}2K${sepLine}`);
206
- if (bashMode) {
207
- w.write(`${ESC}${rows};1H${ESC}2K${s.yellow("!")} `);
208
- }
209
- else {
210
- w.write(`${ESC}${rows};1H${ESC}2K${modeColor(modeIcon)} ${s.dim("▸")} `);
211
- }
215
+ const sepLine = s.dim("─".repeat(c));
216
+ const permLine = ` ${color(`${icon} ${PERMISSION_LABELS[mode]} permissions`)} ${s.dim("(shift+tab to cycle)")}`;
217
+ w.write(`${ESC}${rows - 2};1H${ESC}2K${sepLine}`);
218
+ w.write(`${ESC}${rows - 1};1H${ESC}2K${permLine}`);
219
+ w.write(`${ESC}${rows};1H${ESC}2K${bashMode ? `${s.yellow("!")} ` : `${color(icon)} ${s.dim("▸")} `}`);
212
220
  }
213
221
  // Terminal cleanup: restore state on exit
214
222
  function cleanupTerminal() {
@@ -230,26 +238,23 @@ export async function startTui(config, spawner) {
230
238
  const project = config.phrenCtx?.project;
231
239
  const cwd = process.cwd().replace(os.homedir(), "~");
232
240
  const permMode = config.registry.permissionConfig.mode;
233
- const modeColor = permMode === "full-auto" ? s.yellow : permMode === "auto-confirm" ? s.green : s.cyan;
234
- // Try to show the phren character art alongside info
235
241
  let artLines = [];
236
242
  try {
237
243
  const { PHREN_ART } = await import("@phren/cli/phren-art");
238
244
  artLines = PHREN_ART.filter((l) => l.trim());
239
245
  }
240
246
  catch { /* art not available */ }
247
+ const info = [
248
+ `${s.brand("◆ phren agent")} ${s.dim(`v${AGENT_VERSION}`)}`,
249
+ `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
250
+ `${s.dim(cwd)}`,
251
+ ``,
252
+ `${permTag(permMode)} ${s.dim("permissions (shift+tab to cycle)")}`,
253
+ ``,
254
+ `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
255
+ ];
241
256
  if (artLines.length > 0) {
242
- // Art on left, info on right
243
- const info = [
244
- `${s.brand("◆ phren agent")} ${s.dim("v${AGENT_VERSION}")}`,
245
- `${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""}`,
246
- `${s.dim(cwd)}`,
247
- ``,
248
- `${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}`,
249
- ``,
250
- `${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit`,
251
- ];
252
- const maxArtWidth = 26; // phren art is ~24 chars wide
257
+ const maxArtWidth = 26;
253
258
  for (let i = 0; i < Math.max(artLines.length, info.length); i++) {
254
259
  const artPart = i < artLines.length ? artLines[i] : "";
255
260
  const infoPart = i < info.length ? info[i] : "";
@@ -258,11 +263,7 @@ export async function startTui(config, spawner) {
258
263
  }
259
264
  }
260
265
  else {
261
- // Fallback: text-only banner
262
- w.write(`\n ${s.brand("◆ phren agent")} ${s.dim("v${AGENT_VERSION}")}\n`);
263
- w.write(` ${s.dim(config.provider.name)}${project ? s.dim(` · ${project}`) : ""} ${s.dim(cwd)}\n`);
264
- w.write(` ${modeColor(`${permMode === "full-auto" ? "●" : permMode === "auto-confirm" ? "◐" : "○"} ${permMode}`)} ${s.dim("permissions (shift+tab to cycle)")}\n\n`);
265
- w.write(` ${s.dim("Tab")} memory ${s.dim("Shift+Tab")} perms ${s.dim("/help")} cmds ${s.dim("Ctrl+D")} exit\n\n`);
266
+ w.write(`\n ${info[0]}\n ${info[1]} ${info[2]}\n ${info[4]}\n\n ${info[6]}\n\n`);
266
267
  }
267
268
  w.write("\n");
268
269
  }
@@ -339,16 +340,11 @@ export async function startTui(config, spawner) {
339
340
  }
340
341
  // Shift+Tab — cycle permission mode (works in chat mode, not during filter)
341
342
  if (key.shift && key.name === "tab" && !menuFilterActive && tuiMode === "chat") {
342
- const current = config.registry.permissionConfig.mode;
343
- const next = nextPermissionMode(current);
343
+ const next = nextPermissionMode(config.registry.permissionConfig.mode);
344
344
  config.registry.setPermissions({ ...config.registry.permissionConfig, mode: next });
345
345
  savePermissionMode(next);
346
- const modeColor = next === "full-auto" ? s.yellow : next === "auto-confirm" ? s.green : s.cyan;
347
- const modeIcon = next === "full-auto" ? "●" : next === "auto-confirm" ? "◐" : "○";
348
- w.write(` ${modeColor(`${modeIcon} ${next}`)}\n`);
349
- statusBar();
350
- if (!running)
351
- prompt();
346
+ // Just update bottom bar in-place no scrollback output
347
+ prompt(true);
352
348
  return;
353
349
  }
354
350
  // Tab — toggle mode (not during agent run or filter)
@@ -430,20 +426,40 @@ export async function startTui(config, spawner) {
430
426
  prompt();
431
427
  return;
432
428
  }
429
+ // Push to history
430
+ if (inputHistory[inputHistory.length - 1] !== line) {
431
+ inputHistory.push(line);
432
+ }
433
+ historyIndex = -1;
433
434
  // Bash mode: ! prefix runs shell directly
434
435
  if (line.startsWith("!") || bashMode) {
435
436
  const cmd = bashMode ? line : line.slice(1).trim();
436
437
  bashMode = false;
437
438
  if (cmd) {
438
- try {
439
- const output = execSync(cmd, { encoding: "utf-8", timeout: 30_000, cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
440
- w.write(output);
441
- if (!output.endsWith("\n"))
442
- w.write("\n");
439
+ // Handle cd specially — change process cwd
440
+ const cdMatch = cmd.match(/^cd\s+(.*)/);
441
+ if (cdMatch) {
442
+ try {
443
+ const target = cdMatch[1].trim().replace(/^~/, os.homedir());
444
+ const resolved = require("path").resolve(process.cwd(), target);
445
+ process.chdir(resolved);
446
+ w.write(s.dim(process.cwd()) + "\n");
447
+ }
448
+ catch (err) {
449
+ w.write(s.red(err.message) + "\n");
450
+ }
443
451
  }
444
- catch (err) {
445
- const e = err;
446
- w.write(s.red(e.stderr || e.message || "Command failed") + "\n");
452
+ else {
453
+ try {
454
+ const output = execSync(cmd, { encoding: "utf-8", timeout: 30_000, cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
455
+ w.write(output);
456
+ if (!output.endsWith("\n"))
457
+ w.write("\n");
458
+ }
459
+ catch (err) {
460
+ const e = err;
461
+ w.write(s.red(e.stderr || e.message || "Command failed") + "\n");
462
+ }
447
463
  }
448
464
  }
449
465
  prompt();
@@ -464,6 +480,20 @@ export async function startTui(config, spawner) {
464
480
  providerName: config.provider.name,
465
481
  currentModel: config.provider.model,
466
482
  spawner,
483
+ onModelChange: (result) => {
484
+ // Live model switch — re-resolve provider with new model
485
+ try {
486
+ const { resolveProvider } = require("./providers/resolve.js");
487
+ const newProvider = resolveProvider(config.provider.name, result.model);
488
+ config.provider = newProvider;
489
+ // Rebuild system prompt with new model info
490
+ const { buildSystemPrompt } = require("./system-prompt.js");
491
+ config.systemPrompt = buildSystemPrompt(config.systemPrompt.split("\n## Last session")[0], // preserve context, strip old summary
492
+ null, { name: newProvider.name, model: result.model });
493
+ statusBar();
494
+ }
495
+ catch { /* keep current provider on error */ }
496
+ },
467
497
  })) {
468
498
  prompt();
469
499
  return;
@@ -479,6 +509,40 @@ export async function startTui(config, spawner) {
479
509
  runAgentTurn(line);
480
510
  return;
481
511
  }
512
+ // Up arrow — previous history
513
+ if (key.name === "up" && !running && tuiMode === "chat") {
514
+ if (inputHistory.length === 0)
515
+ return;
516
+ if (historyIndex === -1) {
517
+ savedInput = inputLine;
518
+ historyIndex = inputHistory.length - 1;
519
+ }
520
+ else if (historyIndex > 0) {
521
+ historyIndex--;
522
+ }
523
+ inputLine = inputHistory[historyIndex];
524
+ w.write(`${ESC}2K\r`);
525
+ prompt(true);
526
+ w.write(inputLine);
527
+ return;
528
+ }
529
+ // Down arrow — next history or restore saved
530
+ if (key.name === "down" && !running && tuiMode === "chat") {
531
+ if (historyIndex === -1)
532
+ return;
533
+ if (historyIndex < inputHistory.length - 1) {
534
+ historyIndex++;
535
+ inputLine = inputHistory[historyIndex];
536
+ }
537
+ else {
538
+ historyIndex = -1;
539
+ inputLine = savedInput;
540
+ }
541
+ w.write(`${ESC}2K\r`);
542
+ prompt(true);
543
+ w.write(inputLine);
544
+ return;
545
+ }
482
546
  // Backspace
483
547
  if (key.name === "backspace") {
484
548
  if (inputLine.length > 0) {
@@ -501,6 +565,7 @@ export async function startTui(config, spawner) {
501
565
  });
502
566
  // TUI hooks — render streaming text with markdown, compact tool output
503
567
  let textBuffer = "";
568
+ let firstDelta = true;
504
569
  function flushTextBuffer() {
505
570
  if (!textBuffer)
506
571
  return;
@@ -509,6 +574,10 @@ export async function startTui(config, spawner) {
509
574
  }
510
575
  const tuiHooks = {
511
576
  onTextDelta: (text) => {
577
+ if (firstDelta) {
578
+ w.write(`${ESC}2K\r`); // clear thinking timer line
579
+ firstDelta = false;
580
+ }
512
581
  textBuffer += text;
513
582
  // Flush on paragraph boundaries (double newline) or single newline for streaming feel
514
583
  if (textBuffer.includes("\n\n") || textBuffer.endsWith("\n")) {
@@ -551,13 +620,21 @@ export async function startTui(config, spawner) {
551
620
  };
552
621
  async function runAgentTurn(userInput) {
553
622
  running = true;
554
- w.write(`${ESC}2K ${s.dim("◌ thinking...")}\r`);
623
+ firstDelta = true;
624
+ const thinkStart = Date.now();
625
+ const thinkTimer = setInterval(() => {
626
+ const elapsed = ((Date.now() - thinkStart) / 1000).toFixed(1);
627
+ w.write(`${ESC}2K ${s.dim(`◌ thinking... ${elapsed}s`)}\r`);
628
+ }, 100);
555
629
  try {
556
630
  await runTurn(userInput, session, config, tuiHooks);
631
+ clearInterval(thinkTimer);
557
632
  statusBar();
558
633
  }
559
634
  catch (err) {
635
+ clearInterval(thinkTimer);
560
636
  const msg = err instanceof Error ? err.message : String(err);
637
+ w.write(`${ESC}2K\r`);
561
638
  w.write(s.red(` Error: ${msg}\n`));
562
639
  }
563
640
  running = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Coding agent with persistent memory — powered by phren",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "dist"
14
14
  ],
15
15
  "dependencies": {
16
- "@phren/cli": "0.1.0"
16
+ "@phren/cli": "0.1.2"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=20.0.0"