@phren/agent 0.0.1 → 0.1.0
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/LICENSE +21 -0
- package/dist/agent-loop.js +1 -1
- package/dist/commands.js +68 -0
- package/dist/config.js +4 -0
- package/dist/context/pruner.js +97 -8
- package/dist/cost.js +35 -0
- package/dist/index.js +1 -1
- package/dist/multi/agent-colors.js +0 -5
- package/dist/multi/child-entry.js +1 -2
- package/dist/multi/markdown.js +11 -1
- package/dist/multi/model-picker.js +154 -0
- package/dist/multi/provider-manager.js +151 -0
- package/dist/multi/syntax-highlight.js +188 -0
- package/dist/multi/tui-multi.js +0 -9
- package/dist/permissions/allowlist.js +4 -1
- package/dist/permissions/prompt.js +36 -22
- package/dist/permissions/shell-safety.js +2 -0
- package/dist/providers/anthropic.js +5 -3
- package/dist/providers/codex-auth.js +1 -1
- package/dist/providers/codex.js +4 -2
- package/dist/providers/ollama.js +5 -1
- package/dist/providers/openrouter.js +10 -6
- package/dist/providers/resolve.js +16 -7
- package/dist/repl.js +1 -36
- package/dist/settings.js +42 -0
- package/dist/system-prompt.js +11 -0
- package/dist/tools/edit-file.js +13 -0
- package/dist/tools/git.js +13 -0
- package/dist/tools/write-file.js +12 -0
- package/dist/tui.js +209 -83
- package/package.json +7 -7
- package/dist/multi/progress.js +0 -32
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider management: auth, model registry, live switching.
|
|
3
|
+
*
|
|
4
|
+
* /provider — show configured providers + auth status
|
|
5
|
+
* /provider add — interactive provider setup (enter key, auth login, etc.)
|
|
6
|
+
* /provider switch — change active provider mid-session
|
|
7
|
+
* /model add <id> — add a custom model to the catalog
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
import { hasCodexToken } from "../providers/codex-auth.js";
|
|
13
|
+
const CONFIG_DIR = path.join(os.homedir(), ".phren-agent");
|
|
14
|
+
const PROVIDERS_FILE = path.join(CONFIG_DIR, "providers.json");
|
|
15
|
+
export function getProviderStatuses() {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
name: "openrouter",
|
|
19
|
+
configured: !!process.env.OPENROUTER_API_KEY,
|
|
20
|
+
authMethod: "api-key",
|
|
21
|
+
keyEnvVar: "OPENROUTER_API_KEY",
|
|
22
|
+
models: ["anthropic/claude-sonnet-4", "anthropic/claude-opus-4", "openai/gpt-4o", "openai/o4-mini", "google/gemini-2.5-pro", "deepseek/deepseek-r1"],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "anthropic",
|
|
26
|
+
configured: !!process.env.ANTHROPIC_API_KEY,
|
|
27
|
+
authMethod: "api-key",
|
|
28
|
+
keyEnvVar: "ANTHROPIC_API_KEY",
|
|
29
|
+
models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-4-5-20251001"],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "openai",
|
|
33
|
+
configured: !!process.env.OPENAI_API_KEY,
|
|
34
|
+
authMethod: "api-key",
|
|
35
|
+
keyEnvVar: "OPENAI_API_KEY",
|
|
36
|
+
models: ["gpt-4o", "o4-mini", "o3"],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "codex",
|
|
40
|
+
configured: hasCodexToken(),
|
|
41
|
+
authMethod: "oauth",
|
|
42
|
+
models: ["gpt-4o", "o4-mini", "o3"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "ollama",
|
|
46
|
+
configured: (process.env.PHREN_OLLAMA_URL ?? "").toLowerCase() !== "off",
|
|
47
|
+
authMethod: "local",
|
|
48
|
+
models: ["qwen2.5-coder:14b", "llama3.2", "deepseek-r1:14b"],
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
function loadConfig() {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(PROVIDERS_FILE, "utf-8"));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { customModels: [] };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function saveConfig(config) {
|
|
61
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
62
|
+
const tmp = `${PROVIDERS_FILE}.${process.pid}.tmp`;
|
|
63
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n");
|
|
64
|
+
fs.renameSync(tmp, PROVIDERS_FILE);
|
|
65
|
+
}
|
|
66
|
+
export function addCustomModel(id, provider, opts) {
|
|
67
|
+
const config = loadConfig();
|
|
68
|
+
// Remove existing with same id
|
|
69
|
+
config.customModels = config.customModels.filter((m) => m.id !== id);
|
|
70
|
+
const entry = {
|
|
71
|
+
id,
|
|
72
|
+
provider,
|
|
73
|
+
label: opts?.label ?? id,
|
|
74
|
+
contextWindow: opts?.contextWindow ?? 128_000,
|
|
75
|
+
reasoning: opts?.reasoning ?? null,
|
|
76
|
+
reasoningRange: opts?.reasoningRange ?? [],
|
|
77
|
+
addedAt: new Date().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
config.customModels.push(entry);
|
|
80
|
+
saveConfig(config);
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
export function removeCustomModel(id) {
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
const before = config.customModels.length;
|
|
86
|
+
config.customModels = config.customModels.filter((m) => m.id !== id);
|
|
87
|
+
if (config.customModels.length === before)
|
|
88
|
+
return false;
|
|
89
|
+
saveConfig(config);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
export function getCustomModels() {
|
|
93
|
+
return loadConfig().customModels;
|
|
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
|
+
// ── Format helpers for CLI display ──────────────────────────────────────────
|
|
117
|
+
const DIM = "\x1b[2m";
|
|
118
|
+
const BOLD = "\x1b[1m";
|
|
119
|
+
const GREEN = "\x1b[32m";
|
|
120
|
+
const RED = "\x1b[31m";
|
|
121
|
+
const CYAN = "\x1b[36m";
|
|
122
|
+
const YELLOW = "\x1b[33m";
|
|
123
|
+
const RESET = "\x1b[0m";
|
|
124
|
+
export function formatProviderList() {
|
|
125
|
+
const statuses = getProviderStatuses();
|
|
126
|
+
const lines = [`\n ${BOLD}Providers${RESET}\n`];
|
|
127
|
+
for (const p of statuses) {
|
|
128
|
+
const icon = p.configured ? `${GREEN}●${RESET}` : `${RED}○${RESET}`;
|
|
129
|
+
const auth = p.configured ? `${GREEN}configured${RESET}` : p.authMethod === "oauth"
|
|
130
|
+
? `${DIM}run: phren agent auth login${RESET}`
|
|
131
|
+
: p.keyEnvVar
|
|
132
|
+
? `${DIM}set ${p.keyEnvVar}${RESET}`
|
|
133
|
+
: `${DIM}local${RESET}`;
|
|
134
|
+
const modelCount = `${DIM}${p.models.length} models${RESET}`;
|
|
135
|
+
lines.push(` ${icon} ${BOLD}${p.name}${RESET} ${auth} ${modelCount}`);
|
|
136
|
+
}
|
|
137
|
+
const custom = getCustomModels();
|
|
138
|
+
if (custom.length > 0) {
|
|
139
|
+
lines.push(`\n ${DIM}Custom models: ${custom.map((m) => m.id).join(", ")}${RESET}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(`\n ${DIM}/provider add${RESET} to configure ${DIM}/model add <id>${RESET} to add model\n`);
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
export function formatModelAddHelp() {
|
|
145
|
+
return `${DIM}Usage: /model add <model-id> [provider=X] [context=128000] [reasoning=low|medium|high|max]
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
/model add meta-llama/llama-3.1-405b provider=openrouter context=128000
|
|
149
|
+
/model add claude-3-haiku-20240307 provider=anthropic
|
|
150
|
+
/model add codestral:latest provider=ollama reasoning=medium${RESET}`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex-based syntax highlighter for terminal code blocks.
|
|
3
|
+
* Zero external dependencies — uses raw ANSI escape codes.
|
|
4
|
+
* Line-by-line, single-pass processing.
|
|
5
|
+
*/
|
|
6
|
+
const ESC = "\x1b[";
|
|
7
|
+
const RESET = `${ESC}0m`;
|
|
8
|
+
const BOLD = `${ESC}1m`;
|
|
9
|
+
const DIM = `${ESC}2m`;
|
|
10
|
+
const MAGENTA = `${ESC}35m`;
|
|
11
|
+
const GREEN = `${ESC}32m`;
|
|
12
|
+
const YELLOW = `${ESC}33m`;
|
|
13
|
+
const CYAN = `${ESC}36m`;
|
|
14
|
+
const GRAY = `${ESC}90m`;
|
|
15
|
+
// ── Language detection ──────────────────────────────────────────────
|
|
16
|
+
const EXT_MAP = {
|
|
17
|
+
ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript",
|
|
18
|
+
js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript",
|
|
19
|
+
py: "python", pyw: "python",
|
|
20
|
+
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
|
21
|
+
json: "json", jsonc: "json", json5: "json",
|
|
22
|
+
css: "css", scss: "scss", sass: "scss", less: "css",
|
|
23
|
+
// pass-through aliases
|
|
24
|
+
typescript: "typescript", javascript: "javascript",
|
|
25
|
+
python: "python", shell: "bash",
|
|
26
|
+
};
|
|
27
|
+
export function detectLanguage(filename) {
|
|
28
|
+
const lower = filename.toLowerCase().replace(/^\./, "");
|
|
29
|
+
return EXT_MAP[lower] ?? "generic";
|
|
30
|
+
}
|
|
31
|
+
// Helper: replace matches while preserving non-matched segments
|
|
32
|
+
function colorize(line, rules) {
|
|
33
|
+
// We process rules sequentially; each rule operates on uncolored segments only.
|
|
34
|
+
// Segments already colored are wrapped in \x1b and end with RESET.
|
|
35
|
+
let result = line;
|
|
36
|
+
for (const [re, color] of rules) {
|
|
37
|
+
result = result.replace(re, (match) => `${color}${match}${RESET}`);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
// ── TypeScript / JavaScript ─────────────────────────────────────────
|
|
42
|
+
const TS_KEYWORDS = /\b(const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|typeof|instanceof|void|delete|throw|try|catch|finally|import|export|from|default|class|extends|implements|async|await|yield|type|interface|enum|namespace|declare|readonly|abstract|static|get|set|of|in|as|is)\b/g;
|
|
43
|
+
const tsHighlight = (line) => {
|
|
44
|
+
// Single-line comment
|
|
45
|
+
const commentIdx = line.indexOf("//");
|
|
46
|
+
if (commentIdx !== -1 && !isInsideString(line, commentIdx)) {
|
|
47
|
+
const code = line.slice(0, commentIdx);
|
|
48
|
+
const comment = line.slice(commentIdx);
|
|
49
|
+
return highlightTSCode(code) + `${GRAY}${comment}${RESET}`;
|
|
50
|
+
}
|
|
51
|
+
// Block comment (whole line)
|
|
52
|
+
if (line.trimStart().startsWith("/*") || line.trimStart().startsWith("*")) {
|
|
53
|
+
return `${GRAY}${line}${RESET}`;
|
|
54
|
+
}
|
|
55
|
+
return highlightTSCode(line);
|
|
56
|
+
};
|
|
57
|
+
function highlightTSCode(line) {
|
|
58
|
+
return colorize(line, [
|
|
59
|
+
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, GREEN], // strings
|
|
60
|
+
[/\b\d+(\.\d+)?\b/g, YELLOW], // numbers
|
|
61
|
+
[TS_KEYWORDS, MAGENTA], // keywords
|
|
62
|
+
[/(?<=[:]\s*)\b[A-Z]\w*/g, CYAN], // types after :
|
|
63
|
+
[/(?<=\bas\s+)\b[A-Z]\w*/g, CYAN], // types after as
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
// ── Python ──────────────────────────────────────────────────────────
|
|
67
|
+
const PY_KEYWORDS = /\b(def|class|if|elif|else|for|while|return|import|from|with|as|try|except|finally|raise|pass|break|continue|yield|lambda|and|or|not|in|is|None|True|False|global|nonlocal|assert|del|async|await)\b/g;
|
|
68
|
+
const pyHighlight = (line) => {
|
|
69
|
+
// Comment
|
|
70
|
+
const hashIdx = line.indexOf("#");
|
|
71
|
+
if (hashIdx !== -1 && !isInsideString(line, hashIdx)) {
|
|
72
|
+
const code = line.slice(0, hashIdx);
|
|
73
|
+
const comment = line.slice(hashIdx);
|
|
74
|
+
return highlightPYCode(code) + `${GRAY}${comment}${RESET}`;
|
|
75
|
+
}
|
|
76
|
+
// Decorator
|
|
77
|
+
if (line.trimStart().startsWith("@")) {
|
|
78
|
+
return `${YELLOW}${line}${RESET}`;
|
|
79
|
+
}
|
|
80
|
+
return highlightPYCode(line);
|
|
81
|
+
};
|
|
82
|
+
function highlightPYCode(line) {
|
|
83
|
+
return colorize(line, [
|
|
84
|
+
[/""".*?"""|'''.*?'''|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, GREEN], // strings
|
|
85
|
+
[/\b\d+(\.\d+)?\b/g, YELLOW], // numbers
|
|
86
|
+
[PY_KEYWORDS, MAGENTA], // keywords
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
// ── Bash / Shell ────────────────────────────────────────────────────
|
|
90
|
+
const BASH_KEYWORDS = /\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|in|function|select|time|coproc|local|export|declare|unset|readonly|return|exit|source|eval)\b/g;
|
|
91
|
+
const bashHighlight = (line) => {
|
|
92
|
+
// Comment
|
|
93
|
+
const hashIdx = line.indexOf("#");
|
|
94
|
+
if (hashIdx === 0 || (hashIdx > 0 && line[hashIdx - 1] === " " && !isInsideString(line, hashIdx))) {
|
|
95
|
+
const code = line.slice(0, hashIdx);
|
|
96
|
+
const comment = line.slice(hashIdx);
|
|
97
|
+
return highlightBashCode(code) + `${GRAY}${comment}${RESET}`;
|
|
98
|
+
}
|
|
99
|
+
return highlightBashCode(line);
|
|
100
|
+
};
|
|
101
|
+
function highlightBashCode(line) {
|
|
102
|
+
return colorize(line, [
|
|
103
|
+
[/"(?:[^"\\]|\\.)*"|'[^']*'/g, GREEN], // strings
|
|
104
|
+
[/\$\{?\w+\}?/g, CYAN], // variables
|
|
105
|
+
[/\b\d+\b/g, YELLOW], // numbers
|
|
106
|
+
[BASH_KEYWORDS, MAGENTA], // keywords
|
|
107
|
+
[/(?<=\|\s*)\w+/g, BOLD], // commands after pipe
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
// ── JSON ────────────────────────────────────────────────────────────
|
|
111
|
+
const jsonHighlight = (line) => {
|
|
112
|
+
return colorize(line, [
|
|
113
|
+
[/"[^"]*"\s*(?=:)/g, CYAN], // keys
|
|
114
|
+
[/:\s*"[^"]*"/g, GREEN], // string values
|
|
115
|
+
[/\b\d+(\.\d+)?([eE][+-]?\d+)?\b/g, YELLOW], // numbers
|
|
116
|
+
[/\b(true|false|null)\b/g, MAGENTA], // literals
|
|
117
|
+
]);
|
|
118
|
+
};
|
|
119
|
+
// ── CSS / SCSS ──────────────────────────────────────────────────────
|
|
120
|
+
const cssHighlight = (line) => {
|
|
121
|
+
const trimmed = line.trimStart();
|
|
122
|
+
// Comment
|
|
123
|
+
if (trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("//")) {
|
|
124
|
+
return `${GRAY}${line}${RESET}`;
|
|
125
|
+
}
|
|
126
|
+
// Selector line (no colon, or starts with . # & @ or tag)
|
|
127
|
+
if (/^[.#&@a-zA-Z]/.test(trimmed) && !trimmed.includes(":")) {
|
|
128
|
+
return `${CYAN}${line}${RESET}`;
|
|
129
|
+
}
|
|
130
|
+
// Property: value
|
|
131
|
+
const propMatch = line.match(/^(\s*)([\w-]+)(\s*:\s*)(.+)/);
|
|
132
|
+
if (propMatch) {
|
|
133
|
+
return `${propMatch[1]}${MAGENTA}${propMatch[2]}${RESET}${propMatch[3]}${GREEN}${propMatch[4]}${RESET}`;
|
|
134
|
+
}
|
|
135
|
+
return line;
|
|
136
|
+
};
|
|
137
|
+
// ── Generic fallback ────────────────────────────────────────────────
|
|
138
|
+
const genericHighlight = (line) => {
|
|
139
|
+
return colorize(line, [
|
|
140
|
+
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, GREEN], // strings
|
|
141
|
+
[/\/\/.*$|#.*$/g, GRAY], // comments
|
|
142
|
+
[/\b\d+(\.\d+)?\b/g, YELLOW], // numbers
|
|
143
|
+
]);
|
|
144
|
+
};
|
|
145
|
+
// ── Dispatcher ──────────────────────────────────────────────────────
|
|
146
|
+
const HIGHLIGHTERS = {
|
|
147
|
+
typescript: tsHighlight,
|
|
148
|
+
javascript: tsHighlight,
|
|
149
|
+
python: pyHighlight,
|
|
150
|
+
bash: bashHighlight,
|
|
151
|
+
json: jsonHighlight,
|
|
152
|
+
css: cssHighlight,
|
|
153
|
+
scss: cssHighlight,
|
|
154
|
+
generic: genericHighlight,
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Highlight a code string for terminal output.
|
|
158
|
+
* Returns the input with ANSI color codes applied.
|
|
159
|
+
*/
|
|
160
|
+
export function highlightCode(code, language) {
|
|
161
|
+
const lang = EXT_MAP[language.toLowerCase()] ?? language.toLowerCase();
|
|
162
|
+
const hl = HIGHLIGHTERS[lang] ?? genericHighlight;
|
|
163
|
+
return code
|
|
164
|
+
.split("\n")
|
|
165
|
+
.map((line) => hl(line))
|
|
166
|
+
.join("\n");
|
|
167
|
+
}
|
|
168
|
+
// ── Utilities ───────────────────────────────────────────────────────
|
|
169
|
+
/** Rough check: is position idx inside a string literal? */
|
|
170
|
+
function isInsideString(line, idx) {
|
|
171
|
+
let inSingle = false;
|
|
172
|
+
let inDouble = false;
|
|
173
|
+
let inTemplate = false;
|
|
174
|
+
for (let i = 0; i < idx; i++) {
|
|
175
|
+
const ch = line[i];
|
|
176
|
+
if (ch === "\\" && (inSingle || inDouble || inTemplate)) {
|
|
177
|
+
i++; // skip escaped char
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (ch === "'" && !inDouble && !inTemplate)
|
|
181
|
+
inSingle = !inSingle;
|
|
182
|
+
else if (ch === '"' && !inSingle && !inTemplate)
|
|
183
|
+
inDouble = !inDouble;
|
|
184
|
+
else if (ch === "`" && !inSingle && !inDouble)
|
|
185
|
+
inTemplate = !inTemplate;
|
|
186
|
+
}
|
|
187
|
+
return inSingle || inDouble || inTemplate;
|
|
188
|
+
}
|
package/dist/multi/tui-multi.js
CHANGED
|
@@ -83,15 +83,6 @@ function statusColor(status) {
|
|
|
83
83
|
case "cancelled": return s.gray;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
function statusBg(status, selected) {
|
|
87
|
-
if (selected)
|
|
88
|
-
return s.invert;
|
|
89
|
-
switch (status) {
|
|
90
|
-
case "running": return s.bgGreen;
|
|
91
|
-
case "error": return s.bgRed;
|
|
92
|
-
default: return s.bgGray;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
86
|
// ── Tool call formatting ─────────────────────────────────────────────────────
|
|
96
87
|
function formatToolStart(toolName, input) {
|
|
97
88
|
const preview = JSON.stringify(input).slice(0, 60);
|
|
@@ -44,7 +44,10 @@ export function isAllowed(toolName, input) {
|
|
|
44
44
|
export function addAllow(toolName, input, scope) {
|
|
45
45
|
if (scope === "once")
|
|
46
46
|
return; // "once" approvals don't persist
|
|
47
|
-
|
|
47
|
+
// For shell commands, never allow "*" — always scope to the binary name
|
|
48
|
+
const pattern = scope === "tool" && toolName !== "shell"
|
|
49
|
+
? "*"
|
|
50
|
+
: extractPattern(toolName, input);
|
|
48
51
|
// Avoid duplicates
|
|
49
52
|
const exists = sessionAllowlist.some((e) => e.toolName === toolName && e.pattern === pattern);
|
|
50
53
|
if (!exists) {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as readline from "node:readline";
|
|
11
11
|
import { addAllow } from "./allowlist.js";
|
|
12
|
+
// ── Prompt serialization lock ───────────────────────────────────────────
|
|
13
|
+
// Prevents concurrent askUser() calls from interleaving their prompts.
|
|
14
|
+
let promptQueue = Promise.resolve();
|
|
12
15
|
// ── ANSI colors ─────────────────────────────────────────────────────────
|
|
13
16
|
const RESET = "\x1b[0m";
|
|
14
17
|
const BOLD = "\x1b[1m";
|
|
@@ -94,31 +97,42 @@ function summarizeCall(toolName, input) {
|
|
|
94
97
|
* Side effect: "a" and "s" responses add to the session allowlist.
|
|
95
98
|
*/
|
|
96
99
|
export async function askUser(toolName, input, reason) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
100
|
+
// Serialize: wait for any prior prompt to finish before showing ours
|
|
101
|
+
let resolve;
|
|
102
|
+
const gate = new Promise((r) => { resolve = r; });
|
|
103
|
+
const previous = promptQueue;
|
|
104
|
+
promptQueue = gate;
|
|
105
|
+
await previous;
|
|
106
|
+
try {
|
|
107
|
+
const risk = classifyRisk(toolName);
|
|
108
|
+
const color = riskColor(risk);
|
|
109
|
+
const label = riskLabel(risk);
|
|
110
|
+
const summary = summarizeCall(toolName, input);
|
|
111
|
+
// Header
|
|
112
|
+
process.stderr.write(`\n${color}${BOLD}[${label}]${RESET} ${BOLD}${toolName}${RESET}\n`);
|
|
113
|
+
process.stderr.write(`${DIM} ${reason}${RESET}\n`);
|
|
114
|
+
process.stderr.write(`${CYAN} ${summary}${RESET}\n`);
|
|
115
|
+
// Show full input for shell commands or when details matter
|
|
116
|
+
if (toolName === "shell") {
|
|
117
|
+
const cmd = input.command || "";
|
|
118
|
+
if (cmd.length > 120) {
|
|
119
|
+
process.stderr.write(`${DIM} Full command:${RESET}\n`);
|
|
120
|
+
process.stderr.write(`${DIM} ${cmd}${RESET}\n`);
|
|
121
|
+
}
|
|
111
122
|
}
|
|
123
|
+
const result = await promptKey();
|
|
124
|
+
// Persist allowlist entries for session/tool scopes
|
|
125
|
+
if (result === "allow-session") {
|
|
126
|
+
addAllow(toolName, input, "session");
|
|
127
|
+
}
|
|
128
|
+
else if (result === "allow-tool") {
|
|
129
|
+
addAllow(toolName, input, "tool");
|
|
130
|
+
}
|
|
131
|
+
return result !== "deny";
|
|
112
132
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (result === "allow-session") {
|
|
116
|
-
addAllow(toolName, input, "session");
|
|
117
|
-
}
|
|
118
|
-
else if (result === "allow-tool") {
|
|
119
|
-
addAllow(toolName, input, "tool");
|
|
133
|
+
finally {
|
|
134
|
+
resolve();
|
|
120
135
|
}
|
|
121
|
-
return result !== "deny";
|
|
122
136
|
}
|
|
123
137
|
/**
|
|
124
138
|
* Read a single keypress from stdin.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export class AnthropicProvider {
|
|
2
2
|
name = "anthropic";
|
|
3
3
|
contextWindow = 200_000;
|
|
4
|
+
maxOutputTokens;
|
|
4
5
|
apiKey;
|
|
5
6
|
model;
|
|
6
|
-
constructor(apiKey, model) {
|
|
7
|
+
constructor(apiKey, model, maxOutputTokens) {
|
|
7
8
|
this.apiKey = apiKey;
|
|
8
9
|
this.model = model ?? "claude-sonnet-4-20250514";
|
|
10
|
+
this.maxOutputTokens = maxOutputTokens ?? 8192;
|
|
9
11
|
}
|
|
10
12
|
async chat(system, messages, tools) {
|
|
11
13
|
const body = {
|
|
@@ -15,7 +17,7 @@ export class AnthropicProvider {
|
|
|
15
17
|
role: m.role,
|
|
16
18
|
content: m.content,
|
|
17
19
|
})),
|
|
18
|
-
max_tokens:
|
|
20
|
+
max_tokens: this.maxOutputTokens,
|
|
19
21
|
};
|
|
20
22
|
if (tools.length > 0) {
|
|
21
23
|
body.tools = tools.map((t) => ({
|
|
@@ -54,7 +56,7 @@ export class AnthropicProvider {
|
|
|
54
56
|
model: this.model,
|
|
55
57
|
system,
|
|
56
58
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
57
|
-
max_tokens:
|
|
59
|
+
max_tokens: this.maxOutputTokens,
|
|
58
60
|
stream: true,
|
|
59
61
|
};
|
|
60
62
|
if (tools.length > 0) {
|
|
@@ -15,7 +15,7 @@ const CALLBACK_PORT = 1455;
|
|
|
15
15
|
const SCOPES = "openid profile email offline_access";
|
|
16
16
|
function tokenPath() {
|
|
17
17
|
const dir = path.join(os.homedir(), ".phren-agent");
|
|
18
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
19
|
return path.join(dir, "codex-token.json");
|
|
20
20
|
}
|
|
21
21
|
function generatePKCE() {
|
package/dist/providers/codex.js
CHANGED
|
@@ -114,9 +114,11 @@ function parseResponsesOutput(data) {
|
|
|
114
114
|
export class CodexProvider {
|
|
115
115
|
name = "codex";
|
|
116
116
|
contextWindow = 128_000;
|
|
117
|
+
maxOutputTokens;
|
|
117
118
|
model;
|
|
118
|
-
constructor(model) {
|
|
119
|
-
this.model = model ?? "gpt-5.
|
|
119
|
+
constructor(model, maxOutputTokens) {
|
|
120
|
+
this.model = model ?? "gpt-5.3-codex";
|
|
121
|
+
this.maxOutputTokens = maxOutputTokens ?? 8192;
|
|
120
122
|
}
|
|
121
123
|
async chat(system, messages, tools) {
|
|
122
124
|
const { accessToken } = await getAccessToken();
|
package/dist/providers/ollama.js
CHANGED
|
@@ -33,16 +33,19 @@ function toOllamaMessages(system, messages) {
|
|
|
33
33
|
export class OllamaProvider {
|
|
34
34
|
name = "ollama";
|
|
35
35
|
contextWindow = 32_000;
|
|
36
|
+
maxOutputTokens;
|
|
36
37
|
baseUrl;
|
|
37
38
|
model;
|
|
38
|
-
constructor(model, baseUrl) {
|
|
39
|
+
constructor(model, baseUrl, maxOutputTokens) {
|
|
39
40
|
this.baseUrl = baseUrl ?? "http://localhost:11434";
|
|
40
41
|
this.model = model ?? "qwen2.5-coder:14b";
|
|
42
|
+
this.maxOutputTokens = maxOutputTokens ?? 8192;
|
|
41
43
|
}
|
|
42
44
|
async chat(system, messages, tools) {
|
|
43
45
|
const body = {
|
|
44
46
|
model: this.model,
|
|
45
47
|
messages: toOllamaMessages(system, messages),
|
|
48
|
+
options: { num_predict: this.maxOutputTokens },
|
|
46
49
|
stream: false,
|
|
47
50
|
};
|
|
48
51
|
if (tools.length > 0)
|
|
@@ -81,6 +84,7 @@ export class OllamaProvider {
|
|
|
81
84
|
const body = {
|
|
82
85
|
model: this.model,
|
|
83
86
|
messages: toOllamaMessages(system, messages),
|
|
87
|
+
options: { num_predict: this.maxOutputTokens },
|
|
84
88
|
stream: true,
|
|
85
89
|
};
|
|
86
90
|
if (tools.length > 0)
|
|
@@ -2,19 +2,21 @@ import { toOpenAiTools, toOpenAiMessages, parseOpenAiResponse, parseOpenAiStream
|
|
|
2
2
|
export class OpenRouterProvider {
|
|
3
3
|
name = "openrouter";
|
|
4
4
|
contextWindow = 200_000;
|
|
5
|
+
maxOutputTokens;
|
|
5
6
|
apiKey;
|
|
6
7
|
model;
|
|
7
8
|
baseUrl;
|
|
8
|
-
constructor(apiKey, model, baseUrl) {
|
|
9
|
+
constructor(apiKey, model, baseUrl, maxOutputTokens) {
|
|
9
10
|
this.apiKey = apiKey;
|
|
10
11
|
this.model = model ?? "anthropic/claude-sonnet-4-20250514";
|
|
11
12
|
this.baseUrl = baseUrl ?? "https://openrouter.ai/api/v1";
|
|
13
|
+
this.maxOutputTokens = maxOutputTokens ?? 8192;
|
|
12
14
|
}
|
|
13
15
|
async chat(system, messages, tools) {
|
|
14
16
|
const body = {
|
|
15
17
|
model: this.model,
|
|
16
18
|
messages: toOpenAiMessages(system, messages),
|
|
17
|
-
max_tokens:
|
|
19
|
+
max_tokens: this.maxOutputTokens,
|
|
18
20
|
};
|
|
19
21
|
if (tools.length > 0)
|
|
20
22
|
body.tools = toOpenAiTools(tools);
|
|
@@ -38,7 +40,7 @@ export class OpenRouterProvider {
|
|
|
38
40
|
const body = {
|
|
39
41
|
model: this.model,
|
|
40
42
|
messages: toOpenAiMessages(system, messages),
|
|
41
|
-
max_tokens:
|
|
43
|
+
max_tokens: this.maxOutputTokens,
|
|
42
44
|
stream: true,
|
|
43
45
|
stream_options: { include_usage: true },
|
|
44
46
|
};
|
|
@@ -65,19 +67,21 @@ export class OpenRouterProvider {
|
|
|
65
67
|
export class OpenAiProvider {
|
|
66
68
|
name = "openai";
|
|
67
69
|
contextWindow = 128_000;
|
|
70
|
+
maxOutputTokens;
|
|
68
71
|
apiKey;
|
|
69
72
|
model;
|
|
70
73
|
baseUrl;
|
|
71
|
-
constructor(apiKey, model, baseUrl) {
|
|
74
|
+
constructor(apiKey, model, baseUrl, maxOutputTokens) {
|
|
72
75
|
this.apiKey = apiKey;
|
|
73
76
|
this.model = model ?? "gpt-4o";
|
|
74
77
|
this.baseUrl = baseUrl ?? "https://api.openai.com/v1";
|
|
78
|
+
this.maxOutputTokens = maxOutputTokens ?? 8192;
|
|
75
79
|
}
|
|
76
80
|
async chat(system, messages, tools) {
|
|
77
81
|
const body = {
|
|
78
82
|
model: this.model,
|
|
79
83
|
messages: toOpenAiMessages(system, messages),
|
|
80
|
-
max_tokens:
|
|
84
|
+
max_tokens: this.maxOutputTokens,
|
|
81
85
|
};
|
|
82
86
|
if (tools.length > 0)
|
|
83
87
|
body.tools = toOpenAiTools(tools);
|
|
@@ -96,7 +100,7 @@ export class OpenAiProvider {
|
|
|
96
100
|
const body = {
|
|
97
101
|
model: this.model,
|
|
98
102
|
messages: toOpenAiMessages(system, messages),
|
|
99
|
-
max_tokens:
|
|
103
|
+
max_tokens: this.maxOutputTokens,
|
|
100
104
|
stream: true,
|
|
101
105
|
stream_options: { include_usage: true },
|
|
102
106
|
};
|
|
@@ -3,36 +3,45 @@ import { AnthropicProvider } from "./anthropic.js";
|
|
|
3
3
|
import { OllamaProvider } from "./ollama.js";
|
|
4
4
|
import { CodexProvider } from "./codex.js";
|
|
5
5
|
import { hasCodexToken } from "./codex-auth.js";
|
|
6
|
-
|
|
6
|
+
import { lookupMaxOutputTokens } from "../cost.js";
|
|
7
|
+
export function resolveProvider(overrideProvider, overrideModel, overrideMaxOutput) {
|
|
7
8
|
const explicit = overrideProvider ?? process.env.PHREN_AGENT_PROVIDER;
|
|
9
|
+
// Resolve max output tokens: CLI override > model lookup > default 8192
|
|
10
|
+
const resolveLimit = (model) => overrideMaxOutput ?? lookupMaxOutputTokens(model);
|
|
8
11
|
if (explicit === "openrouter" || (!explicit && process.env.OPENROUTER_API_KEY)) {
|
|
9
12
|
const key = process.env.OPENROUTER_API_KEY;
|
|
10
13
|
if (!key)
|
|
11
14
|
throw new Error("OPENROUTER_API_KEY is required for OpenRouter provider.");
|
|
12
|
-
|
|
15
|
+
const model = overrideModel ?? "anthropic/claude-sonnet-4-20250514";
|
|
16
|
+
return new OpenRouterProvider(key, overrideModel, undefined, resolveLimit(model));
|
|
13
17
|
}
|
|
14
18
|
if (explicit === "anthropic" || (!explicit && process.env.ANTHROPIC_API_KEY)) {
|
|
15
19
|
const key = process.env.ANTHROPIC_API_KEY;
|
|
16
20
|
if (!key)
|
|
17
21
|
throw new Error("ANTHROPIC_API_KEY is required for Anthropic provider.");
|
|
18
|
-
|
|
22
|
+
const model = overrideModel ?? "claude-sonnet-4-20250514";
|
|
23
|
+
return new AnthropicProvider(key, overrideModel, resolveLimit(model));
|
|
19
24
|
}
|
|
20
25
|
if (explicit === "openai" || (!explicit && process.env.OPENAI_API_KEY)) {
|
|
21
26
|
const key = process.env.OPENAI_API_KEY;
|
|
22
27
|
if (!key)
|
|
23
28
|
throw new Error("OPENAI_API_KEY is required for OpenAI provider.");
|
|
24
|
-
|
|
29
|
+
const model = overrideModel ?? "gpt-4o";
|
|
30
|
+
return new OpenAiProvider(key, overrideModel, undefined, resolveLimit(model));
|
|
25
31
|
}
|
|
26
32
|
// Codex: uses your ChatGPT subscription directly — no API key, no middleman
|
|
27
33
|
if (explicit === "codex" || (!explicit && hasCodexToken())) {
|
|
28
|
-
|
|
34
|
+
const model = overrideModel ?? "gpt-5.2-codex";
|
|
35
|
+
return new CodexProvider(overrideModel, resolveLimit(model));
|
|
29
36
|
}
|
|
30
37
|
if (explicit === "ollama" || (!explicit && process.env.PHREN_OLLAMA_URL && process.env.PHREN_OLLAMA_URL !== "off")) {
|
|
31
|
-
|
|
38
|
+
const model = overrideModel ?? "qwen2.5-coder:14b";
|
|
39
|
+
return new OllamaProvider(overrideModel, process.env.PHREN_OLLAMA_URL, resolveLimit(model));
|
|
32
40
|
}
|
|
33
41
|
// Last resort: try Ollama at default URL
|
|
34
42
|
if (!explicit) {
|
|
35
|
-
|
|
43
|
+
const model = overrideModel ?? "qwen2.5-coder:14b";
|
|
44
|
+
return new OllamaProvider(overrideModel, undefined, resolveLimit(model));
|
|
36
45
|
}
|
|
37
46
|
throw new Error(`Unknown provider "${explicit}". Supported: openrouter, anthropic, openai, codex, ollama.\n` +
|
|
38
47
|
"Set one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, or run 'phren-agent auth login' for Codex.");
|