@phren/agent 0.1.2 → 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 +9 -2
- package/dist/commands.js +351 -4
- package/dist/config.js +6 -2
- package/dist/index.js +1 -0
- package/dist/multi/spawner.js +3 -2
- 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 +24 -26
- package/dist/tools/shell.js +5 -2
- package/dist/tui.js +288 -31
- package/package.json +2 -2
package/dist/agent-loop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSpinner, formatTurnHeader, formatToolCall } from "./spinner.js";
|
|
2
2
|
import { searchErrorRecovery } from "./memory/error-recovery.js";
|
|
3
3
|
import { shouldPrune, pruneMessages } from "./context/pruner.js";
|
|
4
|
+
import { estimateMessageTokens } from "./context/token-counter.js";
|
|
4
5
|
import { withRetry } from "./providers/retry.js";
|
|
5
6
|
import { createCaptureState, analyzeAndCapture } from "./memory/auto-capture.js";
|
|
6
7
|
import { AntiPatternTracker } from "./memory/anti-patterns.js";
|
|
@@ -133,9 +134,15 @@ export async function runTurn(userInput, session, config, hooks) {
|
|
|
133
134
|
}
|
|
134
135
|
// Prune context if approaching limit
|
|
135
136
|
if (shouldPrune(systemPrompt, session.messages, { contextLimit })) {
|
|
137
|
+
const preCount = session.messages.length;
|
|
138
|
+
const preTokens = estimateMessageTokens(session.messages);
|
|
136
139
|
session.messages = pruneMessages(session.messages, { contextLimit, keepRecentTurns: 6 });
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
const postCount = session.messages.length;
|
|
141
|
+
const postTokens = estimateMessageTokens(session.messages);
|
|
142
|
+
const reduction = preTokens > 0 ? ((1 - postTokens / preTokens) * 100).toFixed(0) : "0";
|
|
143
|
+
const fmtPre = preTokens >= 1000 ? `${(preTokens / 1000).toFixed(1)}k` : String(preTokens);
|
|
144
|
+
const fmtPost = postTokens >= 1000 ? `${(postTokens / 1000).toFixed(1)}k` : String(postTokens);
|
|
145
|
+
status(`\x1b[2m[context pruned: ${preCount} → ${postCount} messages, ~${fmtPre} → ~${fmtPost} tokens, ${reduction}% reduction]\x1b[0m\n`);
|
|
139
146
|
}
|
|
140
147
|
// For plan mode first turn, pass empty tools so LLM can't call any
|
|
141
148
|
const turnTools = planPending ? [] : toolDefs;
|
package/dist/commands.js
CHANGED
|
@@ -4,6 +4,16 @@ import { listPresets, loadPreset, savePreset, deletePreset, formatPreset } from
|
|
|
4
4
|
import { renderMarkdown } from "./multi/markdown.js";
|
|
5
5
|
import { showModelPicker } from "./multi/model-picker.js";
|
|
6
6
|
import { formatProviderList, formatModelAddHelp, addCustomModel, removeCustomModel } from "./multi/provider-manager.js";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import { saveSessionMessages } from "./memory/session.js";
|
|
12
|
+
import { buildIndex } from "@phren/cli/shared";
|
|
13
|
+
import { searchKnowledgeRows, rankResults } from "@phren/cli/shared/retrieval";
|
|
14
|
+
import { readFindings } from "@phren/cli/data/access";
|
|
15
|
+
import { readTasks } from "@phren/cli/data/tasks";
|
|
16
|
+
import { addFinding } from "@phren/cli/core/finding";
|
|
7
17
|
const DIM = "\x1b[2m";
|
|
8
18
|
const BOLD = "\x1b[1m";
|
|
9
19
|
const CYAN = "\x1b[36m";
|
|
@@ -19,6 +29,17 @@ export function createCommandContext(session, contextLimit) {
|
|
|
19
29
|
undoStack: [],
|
|
20
30
|
};
|
|
21
31
|
}
|
|
32
|
+
/** Format elapsed milliseconds as human-readable duration. */
|
|
33
|
+
function formatElapsed(ms) {
|
|
34
|
+
const secs = Math.floor(ms / 1000);
|
|
35
|
+
if (secs < 60)
|
|
36
|
+
return `${secs}s`;
|
|
37
|
+
const mins = Math.floor(secs / 60);
|
|
38
|
+
if (mins < 60)
|
|
39
|
+
return `${mins}m ${secs % 60}s`;
|
|
40
|
+
const hrs = Math.floor(mins / 60);
|
|
41
|
+
return `${hrs}h ${mins % 60}m`;
|
|
42
|
+
}
|
|
22
43
|
/** Truncate text to N lines, appending [+M lines] if overflow. */
|
|
23
44
|
function truncateText(text, maxLines) {
|
|
24
45
|
const lines = text.split("\n");
|
|
@@ -29,6 +50,7 @@ function truncateText(text, maxLines) {
|
|
|
29
50
|
}
|
|
30
51
|
/**
|
|
31
52
|
* Try to handle a slash command. Returns true if the input was a command.
|
|
53
|
+
* Returns a Promise<boolean> for async commands like /ask.
|
|
32
54
|
*/
|
|
33
55
|
export function handleCommand(input, ctx) {
|
|
34
56
|
const parts = input.trim().split(/\s+/);
|
|
@@ -42,15 +64,28 @@ export function handleCommand(input, ctx) {
|
|
|
42
64
|
/model remove <id> Remove a custom model
|
|
43
65
|
/provider Show configured providers + auth status
|
|
44
66
|
/turns Show turn and tool call counts
|
|
45
|
-
/clear Clear conversation history
|
|
67
|
+
/clear Clear conversation history and terminal screen
|
|
68
|
+
/cwd Show current working directory
|
|
69
|
+
/files Quick file tree (max depth 2, first 30 files)
|
|
46
70
|
/cost Show token usage and estimated cost
|
|
47
71
|
/plan Show conversation plan (tool calls so far)
|
|
48
72
|
/undo Undo last user message and response
|
|
49
73
|
/history [n|full] Show last N messages (default 10) with rich formatting
|
|
50
74
|
/compact Compact conversation to save context space
|
|
75
|
+
/context Show context window usage and provider info
|
|
51
76
|
/mode Toggle input mode (steering ↔ queue)
|
|
52
77
|
/spawn <name> <task> Spawn a background agent
|
|
53
78
|
/agents List running agents
|
|
79
|
+
/session Show session info (id, duration, stats)
|
|
80
|
+
/session save Save conversation checkpoint
|
|
81
|
+
/session export Export conversation as JSON
|
|
82
|
+
/diff [--staged] Show git diff with syntax highlighting
|
|
83
|
+
/git <cmd> Run common git commands (status, log, stash, stash pop)
|
|
84
|
+
/ask <question> Quick LLM query (no tools, not added to session)
|
|
85
|
+
/mem search <query> Search phren memory directly
|
|
86
|
+
/mem findings [project] Show recent findings
|
|
87
|
+
/mem tasks [project] Show tasks
|
|
88
|
+
/mem add <finding> Quick-add a finding
|
|
54
89
|
/preset [name|save|delete|list] Config presets
|
|
55
90
|
/exit Exit the REPL${RESET}\n`);
|
|
56
91
|
return true;
|
|
@@ -129,8 +164,32 @@ export function handleCommand(input, ctx) {
|
|
|
129
164
|
ctx.session.turns = 0;
|
|
130
165
|
ctx.session.toolCalls = 0;
|
|
131
166
|
ctx.undoStack.length = 0;
|
|
167
|
+
process.stdout.write("\x1b[2J\x1b[H"); // clear terminal screen
|
|
132
168
|
process.stderr.write(`${DIM}Conversation cleared.${RESET}\n`);
|
|
133
169
|
return true;
|
|
170
|
+
case "/cwd":
|
|
171
|
+
process.stderr.write(`${DIM}${process.cwd()}${RESET}\n`);
|
|
172
|
+
return true;
|
|
173
|
+
case "/files": {
|
|
174
|
+
try {
|
|
175
|
+
const countRaw = execSync("find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | wc -l", { encoding: "utf-8", timeout: 5_000, cwd: process.cwd() }).trim();
|
|
176
|
+
const total = parseInt(countRaw, 10) || 0;
|
|
177
|
+
const listRaw = execSync("find . -maxdepth 2 -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' | sort | head -30", { encoding: "utf-8", timeout: 5_000, cwd: process.cwd() }).trim();
|
|
178
|
+
if (!listRaw) {
|
|
179
|
+
process.stderr.write(`${DIM}No files found.${RESET}\n`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const lines = listRaw.split("\n");
|
|
183
|
+
const label = total > lines.length ? `${total} files (showing first ${lines.length})` : `${total} files`;
|
|
184
|
+
process.stderr.write(`${DIM}${label}\n${listRaw}${RESET}\n`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const e = err;
|
|
189
|
+
process.stderr.write(`${RED}${e.stderr || e.message || "find failed"}${RESET}\n`);
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
134
193
|
case "/cost": {
|
|
135
194
|
const ct = ctx.costTracker;
|
|
136
195
|
if (ct) {
|
|
@@ -234,10 +293,34 @@ export function handleCommand(input, ctx) {
|
|
|
234
293
|
return true;
|
|
235
294
|
}
|
|
236
295
|
case "/compact": {
|
|
237
|
-
const
|
|
296
|
+
const beforeCount = ctx.session.messages.length;
|
|
297
|
+
const beforeTokens = estimateMessageTokens(ctx.session.messages);
|
|
238
298
|
ctx.session.messages = pruneMessages(ctx.session.messages, { contextLimit: ctx.contextLimit, keepRecentTurns: 4 });
|
|
239
|
-
const
|
|
240
|
-
|
|
299
|
+
const afterCount = ctx.session.messages.length;
|
|
300
|
+
const afterTokens = estimateMessageTokens(ctx.session.messages);
|
|
301
|
+
const reduction = beforeTokens > 0 ? ((1 - afterTokens / beforeTokens) * 100).toFixed(0) : "0";
|
|
302
|
+
const fmtBefore = beforeTokens >= 1000 ? `${(beforeTokens / 1000).toFixed(1)}k` : String(beforeTokens);
|
|
303
|
+
const fmtAfter = afterTokens >= 1000 ? `${(afterTokens / 1000).toFixed(1)}k` : String(afterTokens);
|
|
304
|
+
process.stderr.write(`${DIM}Compacted: ${beforeCount} → ${afterCount} messages (~${fmtBefore} → ~${fmtAfter} tokens, ${reduction}% reduction)${RESET}\n`);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
case "/context": {
|
|
308
|
+
const ctxTokens = estimateMessageTokens(ctx.session.messages);
|
|
309
|
+
const ctxPct = ctx.contextLimit > 0 ? (ctxTokens / ctx.contextLimit) * 100 : 0;
|
|
310
|
+
const ctxPctStr = ctxPct.toFixed(1);
|
|
311
|
+
const ctxWindowK = ctx.contextLimit >= 1000 ? `${(ctx.contextLimit / 1000).toFixed(0)}k` : String(ctx.contextLimit);
|
|
312
|
+
const ctxTokensStr = ctxTokens >= 1000 ? `~${(ctxTokens / 1000).toFixed(1)}k` : `~${ctxTokens}`;
|
|
313
|
+
// Progress bar: 10 chars wide
|
|
314
|
+
const filled = Math.round(ctxPct / 10);
|
|
315
|
+
const bar = "█".repeat(Math.min(filled, 10)) + "░".repeat(Math.max(10 - filled, 0));
|
|
316
|
+
const barColor = ctxPct > 80 ? RED : ctxPct > 50 ? YELLOW : GREEN;
|
|
317
|
+
const providerLabel = ctx.providerName ?? "unknown";
|
|
318
|
+
const modelLabel = ctx.currentModel ?? "default";
|
|
319
|
+
process.stderr.write(`${DIM} Messages: ${ctx.session.messages.length}\n` +
|
|
320
|
+
` Tokens: ${ctxTokensStr} / ${ctxWindowK} (${ctxPctStr}%)\n` +
|
|
321
|
+
` Provider: ${providerLabel} (${modelLabel})\n` +
|
|
322
|
+
` Context window: ${ctxWindowK}\n` +
|
|
323
|
+
` ${barColor}[${bar}]${RESET}${DIM} ${ctxPctStr}%${RESET}\n`);
|
|
241
324
|
return true;
|
|
242
325
|
}
|
|
243
326
|
case "/spawn": {
|
|
@@ -346,6 +429,270 @@ export function handleCommand(input, ctx) {
|
|
|
346
429
|
}
|
|
347
430
|
return true;
|
|
348
431
|
}
|
|
432
|
+
case "/session": {
|
|
433
|
+
const sub = parts[1]?.toLowerCase();
|
|
434
|
+
if (sub === "save") {
|
|
435
|
+
if (!ctx.phrenPath || !ctx.sessionId) {
|
|
436
|
+
process.stderr.write(`${DIM}No active phren session to save.${RESET}\n`);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
saveSessionMessages(ctx.phrenPath, ctx.sessionId, ctx.session.messages);
|
|
441
|
+
process.stderr.write(`${GREEN}→ Checkpoint saved (${ctx.session.messages.length} messages)${RESET}\n`);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
if (sub === "export") {
|
|
449
|
+
const exportDir = path.join(os.homedir(), ".phren-agent", "exports");
|
|
450
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
451
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
452
|
+
const exportFile = path.join(exportDir, `session-${ts}.json`);
|
|
453
|
+
try {
|
|
454
|
+
fs.writeFileSync(exportFile, JSON.stringify(ctx.session.messages, null, 2) + "\n");
|
|
455
|
+
process.stderr.write(`${GREEN}→ Exported to ${exportFile}${RESET}\n`);
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
459
|
+
}
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
// Default: show session info
|
|
463
|
+
const duration = ctx.startTime ? formatElapsed(Date.now() - ctx.startTime) : "unknown";
|
|
464
|
+
const lines = [];
|
|
465
|
+
if (ctx.sessionId)
|
|
466
|
+
lines.push(` Session: ${ctx.sessionId}`);
|
|
467
|
+
lines.push(` Turns: ${ctx.session.turns}`);
|
|
468
|
+
lines.push(` Tools: ${ctx.session.toolCalls}`);
|
|
469
|
+
lines.push(` Messages: ${ctx.session.messages.length}`);
|
|
470
|
+
lines.push(` Duration: ${duration}`);
|
|
471
|
+
// Read session state file for findings/tasks counters
|
|
472
|
+
if (ctx.phrenPath && ctx.sessionId) {
|
|
473
|
+
try {
|
|
474
|
+
const stateFile = path.join(ctx.phrenPath, ".runtime", "sessions", `session-${ctx.sessionId}.json`);
|
|
475
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
|
|
476
|
+
lines.push(` Findings: ${state.findingsAdded ?? 0}`);
|
|
477
|
+
lines.push(` Tasks: ${state.tasksCompleted ?? 0}`);
|
|
478
|
+
}
|
|
479
|
+
catch { /* session file may not exist */ }
|
|
480
|
+
}
|
|
481
|
+
process.stderr.write(`${DIM}${lines.join("\n")}${RESET}\n`);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
case "/diff": {
|
|
485
|
+
const staged = parts.includes("--staged") || parts.includes("--cached");
|
|
486
|
+
const cmd = staged ? "git diff --staged" : "git diff";
|
|
487
|
+
try {
|
|
488
|
+
const raw = execSync(cmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
|
|
489
|
+
if (!raw.trim()) {
|
|
490
|
+
process.stderr.write(`${DIM}No ${staged ? "staged " : ""}changes.${RESET}\n`);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const colored = raw.split("\n").map((line) => {
|
|
494
|
+
if (line.startsWith("diff --git"))
|
|
495
|
+
return `${BOLD}${line}${RESET}`;
|
|
496
|
+
if (line.startsWith("@@"))
|
|
497
|
+
return `${CYAN}${line}${RESET}`;
|
|
498
|
+
if (line.startsWith("+"))
|
|
499
|
+
return `${GREEN}${line}${RESET}`;
|
|
500
|
+
if (line.startsWith("-"))
|
|
501
|
+
return `${RED}${line}${RESET}`;
|
|
502
|
+
return line;
|
|
503
|
+
}).join("\n");
|
|
504
|
+
process.stderr.write(colored + "\n");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
const e = err;
|
|
509
|
+
process.stderr.write(`${RED}${e.stderr || e.message || "git diff failed"}${RESET}\n`);
|
|
510
|
+
}
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
case "/git": {
|
|
514
|
+
const sub = parts.slice(1).join(" ").trim();
|
|
515
|
+
if (!sub) {
|
|
516
|
+
process.stderr.write(`${DIM}Usage: /git <status|log|stash|stash pop>${RESET}\n`);
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
const allowed = {
|
|
520
|
+
"status": "git status",
|
|
521
|
+
"log": "git log --oneline -5",
|
|
522
|
+
"stash": "git stash",
|
|
523
|
+
"stash pop": "git stash pop",
|
|
524
|
+
};
|
|
525
|
+
const gitCmd = allowed[sub];
|
|
526
|
+
if (!gitCmd) {
|
|
527
|
+
process.stderr.write(`${DIM}Supported: /git status, /git log, /git stash, /git stash pop${RESET}\n`);
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
const output = execSync(gitCmd, { encoding: "utf-8", timeout: 10_000, cwd: process.cwd() });
|
|
532
|
+
if (output.trim())
|
|
533
|
+
process.stderr.write(output.endsWith("\n") ? output : output + "\n");
|
|
534
|
+
else
|
|
535
|
+
process.stderr.write(`${DIM}(no output)${RESET}\n`);
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
const e = err;
|
|
539
|
+
process.stderr.write(`${RED}${e.stderr || e.message || "git command failed"}${RESET}\n`);
|
|
540
|
+
}
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
case "/mem": {
|
|
544
|
+
const sub = parts[1]?.toLowerCase();
|
|
545
|
+
if (!ctx.phrenCtx) {
|
|
546
|
+
process.stderr.write(`${DIM}No phren context available.${RESET}\n`);
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
const pCtx = ctx.phrenCtx;
|
|
550
|
+
if (!sub || sub === "help") {
|
|
551
|
+
process.stderr.write(`${DIM}Usage:
|
|
552
|
+
/mem search <query> Search phren memory
|
|
553
|
+
/mem findings [project] Show recent findings
|
|
554
|
+
/mem tasks [project] Show tasks
|
|
555
|
+
/mem add <finding> Quick-add a finding${RESET}\n`);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
if (sub === "search") {
|
|
559
|
+
const query = parts.slice(2).join(" ").trim();
|
|
560
|
+
if (!query) {
|
|
561
|
+
process.stderr.write(`${DIM}Usage: /mem search <query>${RESET}\n`);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
return (async () => {
|
|
565
|
+
try {
|
|
566
|
+
const db = await buildIndex(pCtx.phrenPath, pCtx.profile);
|
|
567
|
+
const result = await searchKnowledgeRows(db, {
|
|
568
|
+
query,
|
|
569
|
+
maxResults: 10,
|
|
570
|
+
filterProject: pCtx.project || null,
|
|
571
|
+
filterType: null,
|
|
572
|
+
phrenPath: pCtx.phrenPath,
|
|
573
|
+
});
|
|
574
|
+
const ranked = rankResults(result.rows ?? [], query, null, pCtx.project || null, pCtx.phrenPath, db);
|
|
575
|
+
if (ranked.length === 0) {
|
|
576
|
+
process.stderr.write(`${DIM}No results found.${RESET}\n`);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
const lines = ranked.slice(0, 10).map((r, i) => {
|
|
580
|
+
const snippet = r.content?.slice(0, 200) ?? "";
|
|
581
|
+
return ` ${CYAN}${i + 1}.${RESET} ${DIM}[${r.project}/${r.filename}]${RESET} ${snippet}`;
|
|
582
|
+
});
|
|
583
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
process.stderr.write(`${RED}Search failed: ${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
588
|
+
}
|
|
589
|
+
return true;
|
|
590
|
+
})();
|
|
591
|
+
}
|
|
592
|
+
if (sub === "findings") {
|
|
593
|
+
const project = parts[2] || pCtx.project;
|
|
594
|
+
if (!project) {
|
|
595
|
+
process.stderr.write(`${DIM}Usage: /mem findings <project>${RESET}\n`);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
const result = readFindings(pCtx.phrenPath, project);
|
|
599
|
+
if (!result.ok) {
|
|
600
|
+
process.stderr.write(`${RED}${result.error}${RESET}\n`);
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
const items = result.data ?? [];
|
|
604
|
+
if (items.length === 0) {
|
|
605
|
+
process.stderr.write(`${DIM}No findings for ${project}.${RESET}\n`);
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
const recent = items.slice(-15);
|
|
609
|
+
const lines = recent.map((f) => ` ${DIM}${f.date}${RESET} ${f.text.slice(0, 120)}${f.text.length > 120 ? "..." : ""}`);
|
|
610
|
+
process.stderr.write(`${DIM}── Findings (${items.length} total, showing last ${recent.length}) ──${RESET}\n`);
|
|
611
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
if (sub === "tasks") {
|
|
615
|
+
const project = parts[2] || pCtx.project;
|
|
616
|
+
if (!project) {
|
|
617
|
+
process.stderr.write(`${DIM}Usage: /mem tasks <project>${RESET}\n`);
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
const result = readTasks(pCtx.phrenPath, project);
|
|
621
|
+
if (!result.ok) {
|
|
622
|
+
process.stderr.write(`${RED}${result.error}${RESET}\n`);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
const sections = [];
|
|
626
|
+
for (const [section, items] of Object.entries(result.data.items)) {
|
|
627
|
+
if (section === "Done")
|
|
628
|
+
continue;
|
|
629
|
+
if (items.length === 0)
|
|
630
|
+
continue;
|
|
631
|
+
const lines = items.map((t) => {
|
|
632
|
+
const icon = t.checked ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
|
|
633
|
+
return ` ${icon} ${t.line}`;
|
|
634
|
+
});
|
|
635
|
+
sections.push(`${BOLD}${section}${RESET}\n${lines.join("\n")}`);
|
|
636
|
+
}
|
|
637
|
+
if (sections.length === 0) {
|
|
638
|
+
process.stderr.write(`${DIM}No active tasks for ${project}.${RESET}\n`);
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
process.stderr.write(sections.join("\n") + "\n");
|
|
642
|
+
}
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
if (sub === "add") {
|
|
646
|
+
const finding = parts.slice(2).join(" ").trim();
|
|
647
|
+
if (!finding) {
|
|
648
|
+
process.stderr.write(`${DIM}Usage: /mem add <finding text>${RESET}\n`);
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
const project = pCtx.project;
|
|
652
|
+
if (!project) {
|
|
653
|
+
process.stderr.write(`${DIM}No project context. Cannot add finding without a project.${RESET}\n`);
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
const result = addFinding(pCtx.phrenPath, project, finding);
|
|
657
|
+
if (result.ok) {
|
|
658
|
+
process.stderr.write(`${GREEN}→ Finding saved to ${project}.${RESET}\n`);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
process.stderr.write(`${RED}${result.message ?? "Failed to save finding."}${RESET}\n`);
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
process.stderr.write(`${DIM}Unknown /mem subcommand: ${sub}. Try /mem help${RESET}\n`);
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
case "/ask": {
|
|
669
|
+
const question = parts.slice(1).join(" ").trim();
|
|
670
|
+
if (!question) {
|
|
671
|
+
process.stderr.write(`${DIM}Usage: /ask <question>${RESET}\n`);
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
if (!ctx.provider) {
|
|
675
|
+
process.stderr.write(`${DIM}Provider not available for /ask.${RESET}\n`);
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
const provider = ctx.provider;
|
|
679
|
+
const sysPrompt = ctx.systemPrompt ?? "You are a helpful assistant.";
|
|
680
|
+
return (async () => {
|
|
681
|
+
process.stderr.write(`${DIM}◆ quick answer (no tools):${RESET}\n`);
|
|
682
|
+
try {
|
|
683
|
+
const response = await provider.chat(sysPrompt, [{ role: "user", content: question }], []);
|
|
684
|
+
for (const block of response.content) {
|
|
685
|
+
if (block.type === "text") {
|
|
686
|
+
process.stderr.write(renderMarkdown(block.text) + "\n");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
process.stderr.write(`${RED}${err instanceof Error ? err.message : String(err)}${RESET}\n`);
|
|
692
|
+
}
|
|
693
|
+
return true;
|
|
694
|
+
})();
|
|
695
|
+
}
|
|
349
696
|
case "/exit":
|
|
350
697
|
case "/quit":
|
|
351
698
|
case "/q":
|
package/dist/config.js
CHANGED
|
@@ -11,7 +11,8 @@ Options:
|
|
|
11
11
|
--max-output <n> Max output tokens per response (default: auto per model)
|
|
12
12
|
--budget <dollars> Max spend in USD (aborts when exceeded)
|
|
13
13
|
--plan Plan mode: show plan before executing tools
|
|
14
|
-
--permissions <mode> Permission mode: suggest, auto-confirm, full-auto
|
|
14
|
+
--permissions <mode> Permission mode: suggest (default), auto-confirm, full-auto
|
|
15
|
+
--yolo Full-auto permissions — no confirmations (alias for --permissions full-auto)
|
|
15
16
|
--interactive, -i Interactive REPL mode (multi-turn conversation)
|
|
16
17
|
--resume Resume last session's conversation
|
|
17
18
|
--lint-cmd <cmd> Override auto-detected lint command
|
|
@@ -46,7 +47,7 @@ Examples:
|
|
|
46
47
|
export function parseArgs(argv) {
|
|
47
48
|
const args = {
|
|
48
49
|
task: "",
|
|
49
|
-
permissions: "
|
|
50
|
+
permissions: "suggest",
|
|
50
51
|
maxTurns: 50,
|
|
51
52
|
budget: null,
|
|
52
53
|
plan: false,
|
|
@@ -120,6 +121,9 @@ export function parseArgs(argv) {
|
|
|
120
121
|
else if (arg === "--budget" && argv[i + 1]) {
|
|
121
122
|
args.budget = parseFloat(argv[++i]) || null;
|
|
122
123
|
}
|
|
124
|
+
else if (arg === "--yolo") {
|
|
125
|
+
args.permissions = "full-auto";
|
|
126
|
+
}
|
|
123
127
|
else if (arg === "--permissions" && argv[i + 1]) {
|
|
124
128
|
const mode = argv[++i];
|
|
125
129
|
if (mode === "suggest" || mode === "auto-confirm" || mode === "full-auto") {
|
package/dist/index.js
CHANGED
package/dist/multi/spawner.js
CHANGED
|
@@ -28,6 +28,7 @@ const ENV_FORWARD_KEYS = [
|
|
|
28
28
|
"PHREN_PROFILE",
|
|
29
29
|
"PHREN_DEBUG",
|
|
30
30
|
"HOME",
|
|
31
|
+
"USERPROFILE",
|
|
31
32
|
"PATH",
|
|
32
33
|
"NODE_EXTRA_CA_CERTS",
|
|
33
34
|
];
|
|
@@ -160,7 +161,7 @@ export class AgentSpawner extends EventEmitter {
|
|
|
160
161
|
// Give it a moment to clean up, then force kill
|
|
161
162
|
setTimeout(() => {
|
|
162
163
|
if (this.processes.has(agentId)) {
|
|
163
|
-
child.kill(
|
|
164
|
+
child.kill();
|
|
164
165
|
}
|
|
165
166
|
}, 5000);
|
|
166
167
|
const agent = this.agents.get(agentId);
|
|
@@ -207,7 +208,7 @@ export class AgentSpawner extends EventEmitter {
|
|
|
207
208
|
setTimeout(() => {
|
|
208
209
|
// Force kill remaining
|
|
209
210
|
for (const [id, child] of this.processes) {
|
|
210
|
-
child.kill(
|
|
211
|
+
child.kill();
|
|
211
212
|
this.processes.delete(id);
|
|
212
213
|
}
|
|
213
214
|
resolve();
|
|
@@ -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) {
|