@phren/agent 0.1.1 → 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.
- package/dist/agent-loop.js +1 -3
- package/dist/checkpoint.js +0 -34
- package/dist/index.js +11 -2
- package/dist/multi/model-picker.js +0 -2
- package/dist/multi/provider-manager.js +0 -23
- package/dist/multi/syntax-highlight.js +0 -1
- package/dist/multi/tui-multi.js +4 -6
- package/dist/permissions/allowlist.js +0 -4
- package/dist/permissions/privacy.js +248 -0
- package/dist/system-prompt.js +3 -2
- package/dist/tools/phren-add-task.js +49 -0
- package/dist/tools/web-fetch.js +40 -0
- package/dist/tools/web-search.js +93 -0
- package/dist/tui.js +104 -42
- package/package.json +2 -2
package/dist/agent-loop.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/checkpoint.js
CHANGED
|
@@ -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
|
-
//
|
|
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();
|
package/dist/multi/tui-multi.js
CHANGED
|
@@ -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
|
|
96
|
+
const allLines = output.split("\n");
|
|
97
97
|
const w = cols();
|
|
98
|
-
const body =
|
|
99
|
-
const more =
|
|
100
|
-
return `${header}\n${body}${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);
|
|
@@ -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
|
+
}
|
package/dist/system-prompt.js
CHANGED
|
@@ -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
|
|
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(/&/g, "&")
|
|
87
|
+
.replace(/</g, "<")
|
|
88
|
+
.replace(/>/g, ">")
|
|
89
|
+
.replace(/"/g, '"')
|
|
90
|
+
.replace(/'/g, "'")
|
|
91
|
+
.replace(/ /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":
|
|
90
|
-
case "write_file":
|
|
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
|
|
194
|
-
const
|
|
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
|
-
// 3 bottom rows: separator, permission line, input
|
|
202
215
|
const sepLine = s.dim("─".repeat(c));
|
|
203
|
-
const permLine = ` ${
|
|
216
|
+
const permLine = ` ${color(`${icon} ${PERMISSION_LABELS[mode]} permissions`)} ${s.dim("(shift+tab to cycle)")}`;
|
|
204
217
|
w.write(`${ESC}${rows - 2};1H${ESC}2K${sepLine}`);
|
|
205
218
|
w.write(`${ESC}${rows - 1};1H${ESC}2K${permLine}`);
|
|
206
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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,6 +426,11 @@ 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();
|
|
@@ -479,6 +480,20 @@ export async function startTui(config, spawner) {
|
|
|
479
480
|
providerName: config.provider.name,
|
|
480
481
|
currentModel: config.provider.model,
|
|
481
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
|
+
},
|
|
482
497
|
})) {
|
|
483
498
|
prompt();
|
|
484
499
|
return;
|
|
@@ -494,6 +509,40 @@ export async function startTui(config, spawner) {
|
|
|
494
509
|
runAgentTurn(line);
|
|
495
510
|
return;
|
|
496
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
|
+
}
|
|
497
546
|
// Backspace
|
|
498
547
|
if (key.name === "backspace") {
|
|
499
548
|
if (inputLine.length > 0) {
|
|
@@ -516,6 +565,7 @@ export async function startTui(config, spawner) {
|
|
|
516
565
|
});
|
|
517
566
|
// TUI hooks — render streaming text with markdown, compact tool output
|
|
518
567
|
let textBuffer = "";
|
|
568
|
+
let firstDelta = true;
|
|
519
569
|
function flushTextBuffer() {
|
|
520
570
|
if (!textBuffer)
|
|
521
571
|
return;
|
|
@@ -524,6 +574,10 @@ export async function startTui(config, spawner) {
|
|
|
524
574
|
}
|
|
525
575
|
const tuiHooks = {
|
|
526
576
|
onTextDelta: (text) => {
|
|
577
|
+
if (firstDelta) {
|
|
578
|
+
w.write(`${ESC}2K\r`); // clear thinking timer line
|
|
579
|
+
firstDelta = false;
|
|
580
|
+
}
|
|
527
581
|
textBuffer += text;
|
|
528
582
|
// Flush on paragraph boundaries (double newline) or single newline for streaming feel
|
|
529
583
|
if (textBuffer.includes("\n\n") || textBuffer.endsWith("\n")) {
|
|
@@ -566,13 +620,21 @@ export async function startTui(config, spawner) {
|
|
|
566
620
|
};
|
|
567
621
|
async function runAgentTurn(userInput) {
|
|
568
622
|
running = true;
|
|
569
|
-
|
|
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);
|
|
570
629
|
try {
|
|
571
630
|
await runTurn(userInput, session, config, tuiHooks);
|
|
631
|
+
clearInterval(thinkTimer);
|
|
572
632
|
statusBar();
|
|
573
633
|
}
|
|
574
634
|
catch (err) {
|
|
635
|
+
clearInterval(thinkTimer);
|
|
575
636
|
const msg = err instanceof Error ? err.message : String(err);
|
|
637
|
+
w.write(`${ESC}2K\r`);
|
|
576
638
|
w.write(s.red(` Error: ${msg}\n`));
|
|
577
639
|
}
|
|
578
640
|
running = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phren/agent",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
16
|
+
"@phren/cli": "0.1.2"
|
|
17
17
|
},
|
|
18
18
|
"engines": {
|
|
19
19
|
"node": ">=20.0.0"
|