@phren/agent 0.1.1 → 0.1.3
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 +10 -5
- package/dist/checkpoint.js +0 -34
- package/dist/commands.js +351 -4
- package/dist/config.js +6 -2
- package/dist/index.js +12 -2
- package/dist/multi/model-picker.js +0 -2
- package/dist/multi/provider-manager.js +0 -23
- package/dist/multi/spawner.js +3 -2
- 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/permissions/shell-safety.js +8 -0
- package/dist/providers/anthropic.js +68 -31
- package/dist/providers/codex.js +112 -56
- package/dist/repl.js +2 -2
- package/dist/system-prompt.js +26 -27
- package/dist/tools/phren-add-task.js +49 -0
- package/dist/tools/shell.js +5 -2
- package/dist/tools/web-fetch.js +40 -0
- package/dist/tools/web-search.js +93 -0
- package/dist/tui.js +381 -62
- package/package.json +2 -2
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
|
+
}
|
|
@@ -11,6 +11,14 @@ const DANGEROUS_PATTERNS = [
|
|
|
11
11
|
{ pattern: /\bnohup\b/i, reason: "Detached process may outlive session", severity: "block" },
|
|
12
12
|
{ pattern: /\bdisown\b/i, reason: "Detached process may outlive session", severity: "block" },
|
|
13
13
|
{ pattern: /\bsetsid\b/i, reason: "Detached process may outlive session", severity: "block" },
|
|
14
|
+
// Block: Windows-specific destructive commands
|
|
15
|
+
{ pattern: /\bformat\s+[a-z]:/i, reason: "Disk format command", severity: "block" },
|
|
16
|
+
{ pattern: /\bdel\s+\/[sq]/i, reason: "Recursive or quiet delete", severity: "block" },
|
|
17
|
+
{ pattern: /\brd\s+\/s/i, reason: "Recursive directory removal", severity: "block" },
|
|
18
|
+
{ pattern: /\brmdir\s+\/s/i, reason: "Recursive directory removal", severity: "block" },
|
|
19
|
+
{ pattern: /\breg\s+delete\b/i, reason: "Registry deletion", severity: "block" },
|
|
20
|
+
{ pattern: /\bpowershell\b.*\b-enc\b/i, reason: "Encoded PowerShell command (obfuscation)", severity: "block" },
|
|
21
|
+
{ pattern: /\bcmd\b.*\/c.*\bdel\s+\/[sq]/i, reason: "Recursive or quiet delete via cmd", severity: "block" },
|
|
14
22
|
// Warn: potentially dangerous
|
|
15
23
|
{ pattern: /\beval\b/i, reason: "Dynamic code execution via eval", severity: "warn" },
|
|
16
24
|
{ pattern: /\$\(.*\)/, reason: "Command substitution", severity: "warn" },
|
|
@@ -4,28 +4,15 @@ export class AnthropicProvider {
|
|
|
4
4
|
maxOutputTokens;
|
|
5
5
|
apiKey;
|
|
6
6
|
model;
|
|
7
|
-
|
|
7
|
+
cacheEnabled;
|
|
8
|
+
constructor(apiKey, model, maxOutputTokens, cacheEnabled = true) {
|
|
8
9
|
this.apiKey = apiKey;
|
|
9
10
|
this.model = model ?? "claude-sonnet-4-20250514";
|
|
10
11
|
this.maxOutputTokens = maxOutputTokens ?? 8192;
|
|
12
|
+
this.cacheEnabled = cacheEnabled;
|
|
11
13
|
}
|
|
12
14
|
async chat(system, messages, tools) {
|
|
13
|
-
const body =
|
|
14
|
-
model: this.model,
|
|
15
|
-
system,
|
|
16
|
-
messages: messages.map((m) => ({
|
|
17
|
-
role: m.role,
|
|
18
|
-
content: m.content,
|
|
19
|
-
})),
|
|
20
|
-
max_tokens: this.maxOutputTokens,
|
|
21
|
-
};
|
|
22
|
-
if (tools.length > 0) {
|
|
23
|
-
body.tools = tools.map((t) => ({
|
|
24
|
-
name: t.name,
|
|
25
|
-
description: t.description,
|
|
26
|
-
input_schema: t.input_schema,
|
|
27
|
-
}));
|
|
28
|
-
}
|
|
15
|
+
const body = this.buildRequestBody(system, messages, tools);
|
|
29
16
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
30
17
|
method: "POST",
|
|
31
18
|
headers: {
|
|
@@ -45,6 +32,7 @@ export class AnthropicProvider {
|
|
|
45
32
|
: data.stop_reason === "max_tokens" ? "max_tokens"
|
|
46
33
|
: "end_turn";
|
|
47
34
|
const usage = data.usage;
|
|
35
|
+
logCacheUsage(usage);
|
|
48
36
|
return {
|
|
49
37
|
content,
|
|
50
38
|
stop_reason: stop_reason,
|
|
@@ -52,20 +40,8 @@ export class AnthropicProvider {
|
|
|
52
40
|
};
|
|
53
41
|
}
|
|
54
42
|
async *chatStream(system, messages, tools) {
|
|
55
|
-
const body =
|
|
56
|
-
|
|
57
|
-
system,
|
|
58
|
-
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
59
|
-
max_tokens: this.maxOutputTokens,
|
|
60
|
-
stream: true,
|
|
61
|
-
};
|
|
62
|
-
if (tools.length > 0) {
|
|
63
|
-
body.tools = tools.map((t) => ({
|
|
64
|
-
name: t.name,
|
|
65
|
-
description: t.description,
|
|
66
|
-
input_schema: t.input_schema,
|
|
67
|
-
}));
|
|
68
|
-
}
|
|
43
|
+
const body = this.buildRequestBody(system, messages, tools);
|
|
44
|
+
body.stream = true;
|
|
69
45
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
70
46
|
method: "POST",
|
|
71
47
|
headers: {
|
|
@@ -129,6 +105,7 @@ export class AnthropicProvider {
|
|
|
129
105
|
else if (type === "message_start") {
|
|
130
106
|
const u = data.message?.usage;
|
|
131
107
|
if (u) {
|
|
108
|
+
logCacheUsage(u);
|
|
132
109
|
usage = {
|
|
133
110
|
input_tokens: u.input_tokens ?? 0,
|
|
134
111
|
output_tokens: u.output_tokens ?? 0,
|
|
@@ -138,6 +115,66 @@ export class AnthropicProvider {
|
|
|
138
115
|
}
|
|
139
116
|
yield { type: "done", stop_reason: stopReason, usage };
|
|
140
117
|
}
|
|
118
|
+
/** Build the request body with optional prompt caching breakpoints. */
|
|
119
|
+
buildRequestBody(system, messages, tools) {
|
|
120
|
+
const cache = { cache_control: { type: "ephemeral" } };
|
|
121
|
+
// System prompt: use content array format with cache_control on the text block
|
|
122
|
+
const systemValue = this.cacheEnabled
|
|
123
|
+
? [{ type: "text", text: system, ...cache }]
|
|
124
|
+
: system;
|
|
125
|
+
const mappedMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
126
|
+
// Mark the last 2 user messages with cache_control for recent-context caching
|
|
127
|
+
if (this.cacheEnabled) {
|
|
128
|
+
let marked = 0;
|
|
129
|
+
for (let i = mappedMessages.length - 1; i >= 0 && marked < 2; i--) {
|
|
130
|
+
if (mappedMessages[i].role !== "user")
|
|
131
|
+
continue;
|
|
132
|
+
const c = mappedMessages[i].content;
|
|
133
|
+
if (typeof c === "string") {
|
|
134
|
+
mappedMessages[i] = {
|
|
135
|
+
role: "user",
|
|
136
|
+
content: [{ type: "text", text: c, ...cache }],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
else if (Array.isArray(c) && c.length > 0) {
|
|
140
|
+
// Add cache_control to the last block of the content array
|
|
141
|
+
const blocks = [...c];
|
|
142
|
+
blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], ...cache };
|
|
143
|
+
mappedMessages[i] = { role: "user", content: blocks };
|
|
144
|
+
}
|
|
145
|
+
marked++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const body = {
|
|
149
|
+
model: this.model,
|
|
150
|
+
system: systemValue,
|
|
151
|
+
messages: mappedMessages,
|
|
152
|
+
max_tokens: this.maxOutputTokens,
|
|
153
|
+
};
|
|
154
|
+
if (tools.length > 0) {
|
|
155
|
+
const mappedTools = tools.map((t) => ({
|
|
156
|
+
name: t.name,
|
|
157
|
+
description: t.description,
|
|
158
|
+
input_schema: t.input_schema,
|
|
159
|
+
}));
|
|
160
|
+
// Cache the last tool definition — Anthropic uses it as the breakpoint for the entire tools block
|
|
161
|
+
if (this.cacheEnabled) {
|
|
162
|
+
mappedTools[mappedTools.length - 1] = { ...mappedTools[mappedTools.length - 1], ...cache };
|
|
163
|
+
}
|
|
164
|
+
body.tools = mappedTools;
|
|
165
|
+
}
|
|
166
|
+
return body;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Log cache hit/creation stats to stderr (visible in verbose mode). */
|
|
170
|
+
function logCacheUsage(usage) {
|
|
171
|
+
if (!usage)
|
|
172
|
+
return;
|
|
173
|
+
const created = usage.cache_creation_input_tokens;
|
|
174
|
+
const read = usage.cache_read_input_tokens;
|
|
175
|
+
if (created || read) {
|
|
176
|
+
process.stderr.write(`[cache] created=${created ?? 0} read=${read ?? 0} input=${usage.input_tokens ?? 0}\n`);
|
|
177
|
+
}
|
|
141
178
|
}
|
|
142
179
|
/** Parse SSE stream from a fetch Response. */
|
|
143
180
|
async function* parseSSE(res) {
|
package/dist/providers/codex.js
CHANGED
|
@@ -194,73 +194,129 @@ export class CodexProvider {
|
|
|
194
194
|
body.tools = toResponsesTools(tools);
|
|
195
195
|
body.tool_choice = "auto";
|
|
196
196
|
}
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
// Use WebSocket for true token-by-token streaming (matches Codex CLI behavior).
|
|
198
|
+
// The HTTP SSE endpoint batches the entire response before flushing.
|
|
199
|
+
yield* this.chatStreamWs(accessToken, body);
|
|
200
|
+
}
|
|
201
|
+
/** WebSocket streaming — sends request, yields deltas as they arrive. */
|
|
202
|
+
async *chatStreamWs(accessToken, body) {
|
|
203
|
+
const wsUrl = CODEX_API.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
|
|
204
|
+
// Queue for events received from the WebSocket before the consumer pulls them
|
|
205
|
+
const queue = [];
|
|
206
|
+
let resolve = null;
|
|
207
|
+
let done = false;
|
|
208
|
+
const push = (item) => {
|
|
209
|
+
queue.push(item);
|
|
210
|
+
if (resolve) {
|
|
211
|
+
resolve();
|
|
212
|
+
resolve = null;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
// Node.js (undici) WebSocket accepts headers in the second argument object,
|
|
216
|
+
// but the DOM typings only allow string | string[]. Cast to bypass.
|
|
217
|
+
const ws = new WebSocket(wsUrl, {
|
|
199
218
|
headers: {
|
|
200
|
-
"Content-Type": "application/json",
|
|
201
219
|
Authorization: `Bearer ${accessToken}`,
|
|
202
220
|
},
|
|
203
|
-
body: JSON.stringify(body),
|
|
204
221
|
});
|
|
205
|
-
if (!res.ok) {
|
|
206
|
-
const text = await res.text();
|
|
207
|
-
throw new Error(`Codex API error ${res.status}: ${text}`);
|
|
208
|
-
}
|
|
209
|
-
// Parse SSE stream
|
|
210
|
-
if (!res.body)
|
|
211
|
-
throw new Error("Provider returned empty response body");
|
|
212
|
-
const reader = res.body.getReader();
|
|
213
|
-
const decoder = new TextDecoder();
|
|
214
|
-
let buffer = "";
|
|
215
222
|
let activeToolCallId = "";
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
223
|
+
ws.addEventListener("open", () => {
|
|
224
|
+
// Wrap the request body in a response.create envelope (Codex WS protocol)
|
|
225
|
+
const wsRequest = { type: "response.create", ...body };
|
|
226
|
+
ws.send(JSON.stringify(wsRequest));
|
|
227
|
+
});
|
|
228
|
+
ws.addEventListener("message", (evt) => {
|
|
229
|
+
const data = typeof evt.data === "string" ? evt.data : String(evt.data);
|
|
230
|
+
let event;
|
|
231
|
+
try {
|
|
232
|
+
event = JSON.parse(data);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const type = event.type;
|
|
238
|
+
// Handle server-side errors
|
|
239
|
+
if (type === "error") {
|
|
240
|
+
const err = event.error;
|
|
241
|
+
const msg = err?.message ?? "Codex WebSocket error";
|
|
242
|
+
const status = event.status;
|
|
243
|
+
push(new Error(`Codex WS error${status ? ` ${status}` : ""}: ${msg}`));
|
|
244
|
+
done = true;
|
|
230
245
|
try {
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
continue;
|
|
246
|
+
ws.close();
|
|
235
247
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
catch { /* ignore */ }
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (type === "response.output_text.delta") {
|
|
252
|
+
const delta = event.delta;
|
|
253
|
+
if (delta)
|
|
254
|
+
push({ type: "text_delta", text: delta });
|
|
255
|
+
}
|
|
256
|
+
else if (type === "response.output_item.added") {
|
|
257
|
+
if (event.item?.type === "function_call") {
|
|
258
|
+
const item = event.item;
|
|
259
|
+
activeToolCallId = item.call_id;
|
|
260
|
+
push({ type: "tool_use_start", id: activeToolCallId, name: item.name });
|
|
246
261
|
}
|
|
247
|
-
|
|
248
|
-
|
|
262
|
+
}
|
|
263
|
+
else if (type === "response.function_call_arguments.delta") {
|
|
264
|
+
push({ type: "tool_use_delta", id: activeToolCallId, json: event.delta });
|
|
265
|
+
}
|
|
266
|
+
else if (type === "response.function_call_arguments.done") {
|
|
267
|
+
push({ type: "tool_use_end", id: activeToolCallId });
|
|
268
|
+
}
|
|
269
|
+
else if (type === "response.completed") {
|
|
270
|
+
const response = event.response;
|
|
271
|
+
const usage = response?.usage;
|
|
272
|
+
const output = response?.output;
|
|
273
|
+
const hasToolCalls = output?.some((o) => o.type === "function_call");
|
|
274
|
+
push({
|
|
275
|
+
type: "done",
|
|
276
|
+
stop_reason: hasToolCalls ? "tool_use" : "end_turn",
|
|
277
|
+
usage: usage ? { input_tokens: usage.input_tokens ?? 0, output_tokens: usage.output_tokens ?? 0 } : undefined,
|
|
278
|
+
});
|
|
279
|
+
done = true;
|
|
280
|
+
try {
|
|
281
|
+
ws.close();
|
|
249
282
|
}
|
|
250
|
-
|
|
251
|
-
|
|
283
|
+
catch { /* ignore */ }
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
ws.addEventListener("error", () => {
|
|
287
|
+
if (!done) {
|
|
288
|
+
push(new Error("Codex WebSocket connection error"));
|
|
289
|
+
done = true;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
ws.addEventListener("close", () => {
|
|
293
|
+
if (!done) {
|
|
294
|
+
push(new Error("Codex WebSocket closed before response.completed"));
|
|
295
|
+
done = true;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
// Async iteration: drain the queue, wait for new events
|
|
299
|
+
try {
|
|
300
|
+
while (true) {
|
|
301
|
+
while (queue.length > 0) {
|
|
302
|
+
const item = queue.shift();
|
|
303
|
+
if (item instanceof Error)
|
|
304
|
+
throw item;
|
|
305
|
+
yield item;
|
|
306
|
+
if (item.type === "done")
|
|
307
|
+
return;
|
|
252
308
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
};
|
|
309
|
+
if (done)
|
|
310
|
+
return;
|
|
311
|
+
await new Promise((r) => { resolve = r; });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
316
|
+
try {
|
|
317
|
+
ws.close();
|
|
263
318
|
}
|
|
319
|
+
catch { /* ignore */ }
|
|
264
320
|
}
|
|
265
321
|
}
|
|
266
322
|
}
|
package/dist/repl.js
CHANGED
|
@@ -82,7 +82,7 @@ export async function startRepl(config) {
|
|
|
82
82
|
rl.prompt();
|
|
83
83
|
continue;
|
|
84
84
|
}
|
|
85
|
-
if (handleCommand(trimmed, { session, contextLimit, undoStack: [] })) {
|
|
85
|
+
if (handleCommand(trimmed, { session, contextLimit, undoStack: [], phrenCtx: config.phrenCtx })) {
|
|
86
86
|
rl.prompt();
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
@@ -118,7 +118,7 @@ export async function startRepl(config) {
|
|
|
118
118
|
process.stderr.write(`${YELLOW}Input mode: ${inputMode}${RESET}\n`);
|
|
119
119
|
}
|
|
120
120
|
else {
|
|
121
|
-
handleCommand(queued, { session, contextLimit, undoStack: [] });
|
|
121
|
+
handleCommand(queued, { session, contextLimit, undoStack: [], phrenCtx: config.phrenCtx });
|
|
122
122
|
}
|
|
123
123
|
break;
|
|
124
124
|
}
|