@phren/agent 0.0.1
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 +328 -0
- package/dist/bin.js +3 -0
- package/dist/checkpoint.js +103 -0
- package/dist/commands.js +292 -0
- package/dist/config.js +139 -0
- package/dist/context/pruner.js +62 -0
- package/dist/context/token-counter.js +28 -0
- package/dist/cost.js +71 -0
- package/dist/index.js +284 -0
- package/dist/mcp-client.js +168 -0
- package/dist/memory/anti-patterns.js +69 -0
- package/dist/memory/auto-capture.js +72 -0
- package/dist/memory/context-flush.js +24 -0
- package/dist/memory/context.js +170 -0
- package/dist/memory/error-recovery.js +58 -0
- package/dist/memory/project-context.js +77 -0
- package/dist/memory/session.js +100 -0
- package/dist/multi/agent-colors.js +41 -0
- package/dist/multi/child-entry.js +173 -0
- package/dist/multi/coordinator.js +263 -0
- package/dist/multi/diff-renderer.js +175 -0
- package/dist/multi/markdown.js +96 -0
- package/dist/multi/presets.js +107 -0
- package/dist/multi/progress.js +32 -0
- package/dist/multi/spawner.js +219 -0
- package/dist/multi/tui-multi.js +626 -0
- package/dist/multi/types.js +7 -0
- package/dist/permissions/allowlist.js +61 -0
- package/dist/permissions/checker.js +111 -0
- package/dist/permissions/prompt.js +190 -0
- package/dist/permissions/sandbox.js +95 -0
- package/dist/permissions/shell-safety.js +74 -0
- package/dist/permissions/types.js +2 -0
- package/dist/plan.js +38 -0
- package/dist/providers/anthropic.js +170 -0
- package/dist/providers/codex-auth.js +197 -0
- package/dist/providers/codex.js +265 -0
- package/dist/providers/ollama.js +142 -0
- package/dist/providers/openai-compat.js +163 -0
- package/dist/providers/openrouter.js +116 -0
- package/dist/providers/resolve.js +39 -0
- package/dist/providers/retry.js +55 -0
- package/dist/providers/types.js +2 -0
- package/dist/repl.js +180 -0
- package/dist/spinner.js +46 -0
- package/dist/system-prompt.js +31 -0
- package/dist/tools/edit-file.js +31 -0
- package/dist/tools/git.js +98 -0
- package/dist/tools/glob.js +65 -0
- package/dist/tools/grep.js +108 -0
- package/dist/tools/lint-test.js +76 -0
- package/dist/tools/phren-finding.js +35 -0
- package/dist/tools/phren-search.js +44 -0
- package/dist/tools/phren-tasks.js +71 -0
- package/dist/tools/read-file.js +44 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/shell.js +48 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/write-file.js +27 -0
- package/dist/tui.js +451 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs, printHelp } from "./config.js";
|
|
3
|
+
import { resolveProvider } from "./providers/resolve.js";
|
|
4
|
+
import { ToolRegistry } from "./tools/registry.js";
|
|
5
|
+
import { readFileTool } from "./tools/read-file.js";
|
|
6
|
+
import { writeFileTool } from "./tools/write-file.js";
|
|
7
|
+
import { editFileTool } from "./tools/edit-file.js";
|
|
8
|
+
import { shellTool } from "./tools/shell.js";
|
|
9
|
+
import { globTool } from "./tools/glob.js";
|
|
10
|
+
import { grepTool } from "./tools/grep.js";
|
|
11
|
+
import { createPhrenSearchTool } from "./tools/phren-search.js";
|
|
12
|
+
import { createPhrenFindingTool } from "./tools/phren-finding.js";
|
|
13
|
+
import { createPhrenGetTasksTool, createPhrenCompleteTaskTool } from "./tools/phren-tasks.js";
|
|
14
|
+
import { gitStatusTool, gitDiffTool, gitCommitTool } from "./tools/git.js";
|
|
15
|
+
import { buildPhrenContext, buildContextSnippet } from "./memory/context.js";
|
|
16
|
+
import { startSession, endSession, getPriorSummary, saveSessionMessages, loadLastSessionMessages } from "./memory/session.js";
|
|
17
|
+
import { loadProjectContext, evolveProjectContext } from "./memory/project-context.js";
|
|
18
|
+
import { buildSystemPrompt } from "./system-prompt.js";
|
|
19
|
+
import { runAgent, createSession, runTurn } from "./agent-loop.js";
|
|
20
|
+
import { createCostTracker } from "./cost.js";
|
|
21
|
+
import { codexLogin, codexLogout } from "./providers/codex-auth.js";
|
|
22
|
+
import { createCheckpoint } from "./checkpoint.js";
|
|
23
|
+
import { detectLintCommand, detectTestCommand } from "./tools/lint-test.js";
|
|
24
|
+
import { connectMcpServers, loadMcpConfig, parseMcpInline } from "./mcp-client.js";
|
|
25
|
+
const VERSION = "0.0.1";
|
|
26
|
+
/**
|
|
27
|
+
* Run the agent CLI with the given argv tokens.
|
|
28
|
+
* Called from `phren agent ...` or directly via `phren-agent ...`.
|
|
29
|
+
*/
|
|
30
|
+
export async function runAgentCli(raw) {
|
|
31
|
+
// Handle auth subcommands before normal arg parsing
|
|
32
|
+
if (raw[0] === "auth") {
|
|
33
|
+
if (raw[1] === "login") {
|
|
34
|
+
await codexLogin();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
if (raw[1] === "logout") {
|
|
38
|
+
codexLogout();
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
console.error("Usage: phren-agent auth login|logout");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const args = parseArgs(raw);
|
|
45
|
+
if (args.help) {
|
|
46
|
+
printHelp();
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
if (args.version) {
|
|
50
|
+
console.log(`phren-agent v${VERSION}`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
if (!args.task && !args.interactive && !args.multi && !args.team) {
|
|
54
|
+
console.error("Usage: phren-agent <task>\nRun phren-agent --help for more info.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
// Resolve LLM provider
|
|
58
|
+
let provider;
|
|
59
|
+
try {
|
|
60
|
+
provider = resolveProvider(args.provider, args.model);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
if (args.verbose) {
|
|
67
|
+
process.stderr.write(`Provider: ${provider.name}\n`);
|
|
68
|
+
}
|
|
69
|
+
// Build phren context
|
|
70
|
+
const phrenCtx = await buildPhrenContext(args.project);
|
|
71
|
+
let contextSnippet = "";
|
|
72
|
+
let priorSummary = null;
|
|
73
|
+
let sessionId = null;
|
|
74
|
+
if (phrenCtx) {
|
|
75
|
+
if (args.verbose) {
|
|
76
|
+
process.stderr.write(`Phren: ${phrenCtx.phrenPath} (project: ${phrenCtx.project ?? "none"})\n`);
|
|
77
|
+
}
|
|
78
|
+
contextSnippet = await buildContextSnippet(phrenCtx, args.task);
|
|
79
|
+
priorSummary = getPriorSummary(phrenCtx);
|
|
80
|
+
sessionId = startSession(phrenCtx);
|
|
81
|
+
// Load evolved project context for warm start
|
|
82
|
+
const projectCtx = loadProjectContext(phrenCtx);
|
|
83
|
+
if (projectCtx) {
|
|
84
|
+
contextSnippet += `\n\n## Agent context (${phrenCtx.project})\n\n${projectCtx}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const systemPrompt = buildSystemPrompt(contextSnippet, priorSummary);
|
|
88
|
+
// Dry run: print system prompt and exit
|
|
89
|
+
if (args.dryRun) {
|
|
90
|
+
console.log("=== System Prompt ===");
|
|
91
|
+
console.log(systemPrompt);
|
|
92
|
+
console.log("\n=== Task ===");
|
|
93
|
+
console.log(args.task);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
// Register tools
|
|
97
|
+
const registry = new ToolRegistry();
|
|
98
|
+
registry.setPermissions({
|
|
99
|
+
mode: args.permissions,
|
|
100
|
+
allowedPaths: [],
|
|
101
|
+
projectRoot: process.cwd(),
|
|
102
|
+
});
|
|
103
|
+
registry.register(readFileTool);
|
|
104
|
+
registry.register(writeFileTool);
|
|
105
|
+
registry.register(editFileTool);
|
|
106
|
+
registry.register(shellTool);
|
|
107
|
+
registry.register(globTool);
|
|
108
|
+
registry.register(grepTool);
|
|
109
|
+
if (phrenCtx) {
|
|
110
|
+
registry.register(createPhrenSearchTool(phrenCtx));
|
|
111
|
+
registry.register(createPhrenFindingTool(phrenCtx, sessionId));
|
|
112
|
+
registry.register(createPhrenGetTasksTool(phrenCtx));
|
|
113
|
+
registry.register(createPhrenCompleteTaskTool(phrenCtx, sessionId));
|
|
114
|
+
}
|
|
115
|
+
// Git tools
|
|
116
|
+
registry.register(gitStatusTool);
|
|
117
|
+
registry.register(gitDiffTool);
|
|
118
|
+
registry.register(gitCommitTool);
|
|
119
|
+
// MCP server connections
|
|
120
|
+
let mcpCleanup;
|
|
121
|
+
const mcpServers = {};
|
|
122
|
+
if (args.mcpConfig) {
|
|
123
|
+
Object.assign(mcpServers, loadMcpConfig(args.mcpConfig));
|
|
124
|
+
}
|
|
125
|
+
for (let idx = 0; idx < args.mcp.length; idx++) {
|
|
126
|
+
const entry = parseMcpInline(args.mcp[idx]);
|
|
127
|
+
mcpServers[`mcp-${idx}`] = entry;
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(mcpServers).length > 0) {
|
|
130
|
+
const { tools: mcpTools, cleanup } = await connectMcpServers(mcpServers, args.verbose);
|
|
131
|
+
mcpCleanup = cleanup;
|
|
132
|
+
for (const tool of mcpTools)
|
|
133
|
+
registry.register(tool);
|
|
134
|
+
}
|
|
135
|
+
// Build cost tracker from model info
|
|
136
|
+
const modelName = args.model ?? provider.name;
|
|
137
|
+
const costTracker = createCostTracker(modelName, args.budget);
|
|
138
|
+
// Build lint/test config from CLI flags or auto-detect
|
|
139
|
+
const cwd = process.cwd();
|
|
140
|
+
const lintCmd = args.lintCmd ?? detectLintCommand(cwd);
|
|
141
|
+
const testCmd = args.testCmd ?? detectTestCommand(cwd);
|
|
142
|
+
const lintTestConfig = (lintCmd || testCmd) ? { lintCmd: lintCmd ?? undefined, testCmd: testCmd ?? undefined } : undefined;
|
|
143
|
+
if (args.verbose && lintTestConfig) {
|
|
144
|
+
if (lintTestConfig.lintCmd)
|
|
145
|
+
process.stderr.write(`Lint: ${lintTestConfig.lintCmd}\n`);
|
|
146
|
+
if (lintTestConfig.testCmd)
|
|
147
|
+
process.stderr.write(`Test: ${lintTestConfig.testCmd}\n`);
|
|
148
|
+
}
|
|
149
|
+
const agentConfig = {
|
|
150
|
+
provider,
|
|
151
|
+
registry,
|
|
152
|
+
systemPrompt,
|
|
153
|
+
maxTurns: args.maxTurns,
|
|
154
|
+
verbose: args.verbose,
|
|
155
|
+
phrenCtx,
|
|
156
|
+
costTracker,
|
|
157
|
+
plan: args.plan,
|
|
158
|
+
lintTestConfig,
|
|
159
|
+
};
|
|
160
|
+
// Multi-agent TUI mode
|
|
161
|
+
if (args.multi || args.team) {
|
|
162
|
+
const { AgentSpawner } = await import("./multi/spawner.js");
|
|
163
|
+
const { startMultiTui } = await import("./multi/tui-multi.js");
|
|
164
|
+
const spawner = new AgentSpawner();
|
|
165
|
+
process.on("SIGINT", async () => {
|
|
166
|
+
await spawner.shutdown();
|
|
167
|
+
process.exit(130);
|
|
168
|
+
});
|
|
169
|
+
await startMultiTui(spawner, agentConfig);
|
|
170
|
+
await spawner.shutdown();
|
|
171
|
+
if (phrenCtx && sessionId) {
|
|
172
|
+
endSession(phrenCtx, sessionId, "Multi-agent session ended");
|
|
173
|
+
}
|
|
174
|
+
mcpCleanup?.();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Interactive mode — TUI if terminal, fallback to REPL if not
|
|
178
|
+
if (args.interactive) {
|
|
179
|
+
const isTTY = process.stdout.isTTY && process.stdin.isTTY;
|
|
180
|
+
const session = isTTY
|
|
181
|
+
? await (await import("./tui.js")).startTui(agentConfig)
|
|
182
|
+
: await (await import("./repl.js")).startRepl(agentConfig);
|
|
183
|
+
// Flush anti-patterns at session end
|
|
184
|
+
if (phrenCtx) {
|
|
185
|
+
try {
|
|
186
|
+
await session.antiPatterns.flushAntiPatterns(phrenCtx);
|
|
187
|
+
}
|
|
188
|
+
catch { /* best effort */ }
|
|
189
|
+
try {
|
|
190
|
+
await evolveProjectContext(phrenCtx, provider, session.messages);
|
|
191
|
+
}
|
|
192
|
+
catch { /* best effort */ }
|
|
193
|
+
}
|
|
194
|
+
if (phrenCtx && sessionId) {
|
|
195
|
+
const lastText = session.messages.length > 0 ? "Interactive session ended" : "Empty session";
|
|
196
|
+
endSession(phrenCtx, sessionId, lastText);
|
|
197
|
+
}
|
|
198
|
+
mcpCleanup?.();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Create initial checkpoint before agent starts
|
|
202
|
+
const initCheckpoint = createCheckpoint(cwd, "pre-agent");
|
|
203
|
+
if (args.verbose && initCheckpoint) {
|
|
204
|
+
process.stderr.write(`Checkpoint: ${initCheckpoint.slice(0, 8)}\n`);
|
|
205
|
+
}
|
|
206
|
+
// SIGINT handler: offer rollback
|
|
207
|
+
process.on("SIGINT", () => {
|
|
208
|
+
process.stderr.write("\nInterrupted. Use --resume to continue later.\n");
|
|
209
|
+
if (process.stdin.isTTY) {
|
|
210
|
+
try {
|
|
211
|
+
process.stdin.setRawMode(false);
|
|
212
|
+
}
|
|
213
|
+
catch { }
|
|
214
|
+
}
|
|
215
|
+
mcpCleanup?.();
|
|
216
|
+
if (phrenCtx && sessionId) {
|
|
217
|
+
endSession(phrenCtx, sessionId, "Interrupted by user");
|
|
218
|
+
}
|
|
219
|
+
process.exit(130);
|
|
220
|
+
});
|
|
221
|
+
// One-shot mode
|
|
222
|
+
try {
|
|
223
|
+
let result;
|
|
224
|
+
if (args.resume && phrenCtx) {
|
|
225
|
+
// Resume: load previous messages and continue
|
|
226
|
+
const prevMessages = loadLastSessionMessages(phrenCtx.phrenPath);
|
|
227
|
+
if (prevMessages && prevMessages.length > 0) {
|
|
228
|
+
if (args.verbose)
|
|
229
|
+
process.stderr.write(`Resuming session with ${prevMessages.length} messages\n`);
|
|
230
|
+
const contextLimit = provider.contextWindow ?? 200_000;
|
|
231
|
+
const session = createSession(contextLimit);
|
|
232
|
+
session.messages = prevMessages;
|
|
233
|
+
const turnResult = await runTurn("Continuing where we left off. Please review the conversation and continue with the task.", session, agentConfig);
|
|
234
|
+
// Save messages for future resume
|
|
235
|
+
saveSessionMessages(phrenCtx.phrenPath, sessionId, session.messages);
|
|
236
|
+
result = {
|
|
237
|
+
finalText: turnResult.text,
|
|
238
|
+
turns: turnResult.turns,
|
|
239
|
+
toolCalls: turnResult.toolCalls,
|
|
240
|
+
totalCost: agentConfig.costTracker?.formatCost(),
|
|
241
|
+
messages: session.messages,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
process.stderr.write("No previous session to resume.\n");
|
|
246
|
+
result = await runAgent(args.task, agentConfig);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
result = await runAgent(args.task, agentConfig);
|
|
251
|
+
}
|
|
252
|
+
if (args.verbose) {
|
|
253
|
+
const costStr = result.totalCost ? `, ${result.totalCost}` : "";
|
|
254
|
+
process.stderr.write(`\nDone: ${result.turns} turns, ${result.toolCalls} tool calls${costStr}\n`);
|
|
255
|
+
}
|
|
256
|
+
// End session with summary + memory intelligence
|
|
257
|
+
if (phrenCtx && sessionId) {
|
|
258
|
+
const summary = result.finalText.slice(0, 500);
|
|
259
|
+
endSession(phrenCtx, sessionId, summary);
|
|
260
|
+
// Save messages for resume
|
|
261
|
+
saveSessionMessages(phrenCtx.phrenPath, sessionId, result.messages);
|
|
262
|
+
// Evolve project context via lightweight LLM reflection
|
|
263
|
+
try {
|
|
264
|
+
await evolveProjectContext(phrenCtx, provider, result.messages);
|
|
265
|
+
}
|
|
266
|
+
catch { /* best effort */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
271
|
+
if (phrenCtx && sessionId) {
|
|
272
|
+
endSession(phrenCtx, sessionId, `Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
273
|
+
}
|
|
274
|
+
mcpCleanup?.();
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
mcpCleanup?.();
|
|
278
|
+
}
|
|
279
|
+
// When run directly (phren-agent binary), parse from process.argv
|
|
280
|
+
const isDirectRun = process.argv[1]?.endsWith("/agent/index.js") ||
|
|
281
|
+
process.argv[1]?.endsWith("/agent/index.ts");
|
|
282
|
+
if (isDirectRun) {
|
|
283
|
+
runAgentCli(process.argv.slice(2));
|
|
284
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client — connects to MCP servers, discovers tools, wraps them as AgentTools.
|
|
3
|
+
* Uses stdio transport (spawns server as child process).
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as readline from "readline";
|
|
8
|
+
/** Active MCP server connection. */
|
|
9
|
+
class McpConnection {
|
|
10
|
+
proc;
|
|
11
|
+
nextId = 1;
|
|
12
|
+
pending = new Map();
|
|
13
|
+
rl;
|
|
14
|
+
name;
|
|
15
|
+
constructor(name, config) {
|
|
16
|
+
this.name = name;
|
|
17
|
+
const env = { ...process.env, ...config.env };
|
|
18
|
+
this.proc = spawn(config.command, config.args ?? [], {
|
|
19
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
env,
|
|
21
|
+
});
|
|
22
|
+
this.rl = readline.createInterface({ input: this.proc.stdout });
|
|
23
|
+
this.rl.on("line", (line) => {
|
|
24
|
+
try {
|
|
25
|
+
const msg = JSON.parse(line);
|
|
26
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
27
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
28
|
+
this.pending.delete(msg.id);
|
|
29
|
+
if (msg.error)
|
|
30
|
+
reject(new Error(`MCP error: ${msg.error.message}`));
|
|
31
|
+
else
|
|
32
|
+
resolve(msg.result);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* ignore non-JSON lines */ }
|
|
36
|
+
});
|
|
37
|
+
this.proc.on("error", (err) => {
|
|
38
|
+
for (const { reject } of this.pending.values())
|
|
39
|
+
reject(err);
|
|
40
|
+
this.pending.clear();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
send(method, params) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const id = this.nextId++;
|
|
46
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
47
|
+
this.pending.set(id, { resolve, reject });
|
|
48
|
+
this.proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
49
|
+
// Timeout after 30s
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
if (this.pending.has(id)) {
|
|
52
|
+
this.pending.delete(id);
|
|
53
|
+
reject(new Error(`MCP call ${method} timed out (30s)`));
|
|
54
|
+
}
|
|
55
|
+
}, 30_000);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async initialize() {
|
|
59
|
+
await this.send("initialize", {
|
|
60
|
+
protocolVersion: "2024-11-05",
|
|
61
|
+
capabilities: {},
|
|
62
|
+
clientInfo: { name: "phren-agent", version: "0.0.1" },
|
|
63
|
+
});
|
|
64
|
+
// Send initialized notification (no id)
|
|
65
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
|
66
|
+
}
|
|
67
|
+
async listTools() {
|
|
68
|
+
const result = await this.send("tools/list");
|
|
69
|
+
return result?.tools ?? [];
|
|
70
|
+
}
|
|
71
|
+
async callTool(name, args) {
|
|
72
|
+
return await this.send("tools/call", { name, arguments: args });
|
|
73
|
+
}
|
|
74
|
+
close() {
|
|
75
|
+
try {
|
|
76
|
+
this.proc.stdin.end();
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
try {
|
|
80
|
+
this.proc.kill();
|
|
81
|
+
}
|
|
82
|
+
catch { /* ignore */ }
|
|
83
|
+
this.rl.close();
|
|
84
|
+
for (const { reject } of this.pending.values())
|
|
85
|
+
reject(new Error("Connection closed"));
|
|
86
|
+
this.pending.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Wrap an MCP tool as an AgentTool. */
|
|
90
|
+
function wrapMcpTool(conn, def) {
|
|
91
|
+
return {
|
|
92
|
+
name: `mcp_${conn.name}_${def.name}`,
|
|
93
|
+
description: `[${conn.name}] ${def.description ?? def.name}`,
|
|
94
|
+
input_schema: def.inputSchema ?? { type: "object", properties: {} },
|
|
95
|
+
async execute(input) {
|
|
96
|
+
try {
|
|
97
|
+
const result = await conn.callTool(def.name, input);
|
|
98
|
+
const text = result.content
|
|
99
|
+
?.map((c) => c.text ?? JSON.stringify(c))
|
|
100
|
+
.join("\n") ?? "OK";
|
|
101
|
+
return { output: text };
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
+
return { output: `MCP error: ${msg}`, is_error: true };
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Connect to MCP servers and return their tools as AgentTools. */
|
|
111
|
+
export async function connectMcpServers(servers, verbose = false) {
|
|
112
|
+
const connections = [];
|
|
113
|
+
const tools = [];
|
|
114
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
115
|
+
try {
|
|
116
|
+
if (verbose)
|
|
117
|
+
process.stderr.write(`Connecting to MCP server: ${name}...\n`);
|
|
118
|
+
const conn = new McpConnection(name, config);
|
|
119
|
+
await conn.initialize();
|
|
120
|
+
const mcpTools = await conn.listTools();
|
|
121
|
+
for (const def of mcpTools) {
|
|
122
|
+
tools.push(wrapMcpTool(conn, def));
|
|
123
|
+
}
|
|
124
|
+
connections.push(conn);
|
|
125
|
+
if (verbose)
|
|
126
|
+
process.stderr.write(` ${name}: ${mcpTools.length} tools\n`);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
130
|
+
process.stderr.write(`Failed to connect to MCP server "${name}": ${msg}\n`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
tools,
|
|
135
|
+
cleanup: () => { for (const conn of connections)
|
|
136
|
+
conn.close(); },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Load MCP server config from a JSON file (same format as Claude Code's mcpServers). */
|
|
140
|
+
export function loadMcpConfig(configPath) {
|
|
141
|
+
if (!fs.existsSync(configPath))
|
|
142
|
+
return {};
|
|
143
|
+
try {
|
|
144
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
145
|
+
// Support both { mcpServers: {...} } and direct { serverName: {...} } formats
|
|
146
|
+
const servers = raw.mcpServers ?? raw;
|
|
147
|
+
const result = {};
|
|
148
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
149
|
+
const e = entry;
|
|
150
|
+
if (e.command && typeof e.command === "string") {
|
|
151
|
+
result[name] = {
|
|
152
|
+
command: e.command,
|
|
153
|
+
args: Array.isArray(e.args) ? e.args : undefined,
|
|
154
|
+
env: typeof e.env === "object" && e.env !== null ? e.env : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Parse --mcp "command args..." into an McpConfigEntry. */
|
|
165
|
+
export function parseMcpInline(spec) {
|
|
166
|
+
const parts = spec.split(/\s+/);
|
|
167
|
+
return { command: parts[0], args: parts.slice(1) };
|
|
168
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { addFinding } from "@phren/cli/core/finding";
|
|
2
|
+
export class AntiPatternTracker {
|
|
3
|
+
attempts = [];
|
|
4
|
+
/** Record a tool execution result. */
|
|
5
|
+
recordAttempt(name, input, succeeded, output) {
|
|
6
|
+
this.attempts.push({
|
|
7
|
+
name,
|
|
8
|
+
input: JSON.stringify(input).slice(0, 300),
|
|
9
|
+
succeeded,
|
|
10
|
+
output: output.slice(0, 300),
|
|
11
|
+
timestamp: Date.now(),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Extract anti-patterns: find cases where a tool failed then later
|
|
16
|
+
* succeeded with different input (same tool name).
|
|
17
|
+
*/
|
|
18
|
+
extractAntiPatterns() {
|
|
19
|
+
const patterns = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (let i = 0; i < this.attempts.length; i++) {
|
|
22
|
+
const fail = this.attempts[i];
|
|
23
|
+
if (fail.succeeded)
|
|
24
|
+
continue;
|
|
25
|
+
// Look for a later success with same tool name
|
|
26
|
+
for (let j = i + 1; j < this.attempts.length; j++) {
|
|
27
|
+
const success = this.attempts[j];
|
|
28
|
+
if (success.name !== fail.name || !success.succeeded)
|
|
29
|
+
continue;
|
|
30
|
+
if (success.input === fail.input)
|
|
31
|
+
continue; // Same input, not an anti-pattern
|
|
32
|
+
const key = `${fail.name}:${fail.input}`;
|
|
33
|
+
if (seen.has(key))
|
|
34
|
+
break;
|
|
35
|
+
seen.add(key);
|
|
36
|
+
patterns.push({
|
|
37
|
+
tool: fail.name,
|
|
38
|
+
failedInput: fail.input,
|
|
39
|
+
failedOutput: fail.output,
|
|
40
|
+
succeededInput: success.input,
|
|
41
|
+
});
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return patterns;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Save top 3 anti-patterns as findings at session end.
|
|
49
|
+
*/
|
|
50
|
+
async flushAntiPatterns(ctx) {
|
|
51
|
+
if (!ctx.project)
|
|
52
|
+
return 0;
|
|
53
|
+
const patterns = this.extractAntiPatterns().slice(0, 3);
|
|
54
|
+
if (patterns.length === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
let saved = 0;
|
|
57
|
+
try {
|
|
58
|
+
for (const p of patterns) {
|
|
59
|
+
const finding = `[anti-pattern] ${p.tool}: failed with ${p.failedInput.slice(0, 100)} (${p.failedOutput.slice(0, 80)}), succeeded with ${p.succeededInput.slice(0, 100)}`;
|
|
60
|
+
await addFinding(ctx.phrenPath, ctx.project, finding);
|
|
61
|
+
saved++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// best effort
|
|
66
|
+
}
|
|
67
|
+
return saved;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-capture: extract findings from error tool results.
|
|
3
|
+
* Runs after each turn on error results only.
|
|
4
|
+
*/
|
|
5
|
+
import * as crypto from "crypto";
|
|
6
|
+
import { addFinding } from "@phren/cli/core/finding";
|
|
7
|
+
const SESSION_CAP = 10;
|
|
8
|
+
const COOLDOWN_MS = 30_000;
|
|
9
|
+
/** Patterns that indicate capturable knowledge in error output. */
|
|
10
|
+
const CAPTURE_PATTERNS = [
|
|
11
|
+
{ pattern: /ENOENT.*no such file/i, label: "missing-file" },
|
|
12
|
+
{ pattern: /EACCES|permission denied/i, label: "permission-error" },
|
|
13
|
+
{ pattern: /Cannot find module/i, label: "missing-module" },
|
|
14
|
+
{ pattern: /ERR_MODULE_NOT_FOUND/i, label: "missing-module" },
|
|
15
|
+
{ pattern: /ECONNREFUSED|ETIMEDOUT/i, label: "connection-error" },
|
|
16
|
+
{ pattern: /port\s+\d+\s+(already|in use)/i, label: "port-conflict" },
|
|
17
|
+
{ pattern: /deprecated/i, label: "deprecation" },
|
|
18
|
+
{ pattern: /version\s+mismatch|incompatible/i, label: "version-mismatch" },
|
|
19
|
+
{ pattern: /out of memory|heap|OOM/i, label: "memory-issue" },
|
|
20
|
+
{ pattern: /syntax\s*error/i, label: "syntax-error" },
|
|
21
|
+
{ pattern: /type\s*error.*is not a function/i, label: "type-error" },
|
|
22
|
+
{ pattern: /config(uration)?\s+(not found|missing|invalid)/i, label: "config-issue" },
|
|
23
|
+
{ pattern: /https?:\/\/\S+:\d+/i, label: "service-endpoint" },
|
|
24
|
+
{ pattern: /env\s+var(iable)?.*not set|missing.*env/i, label: "env-var" },
|
|
25
|
+
];
|
|
26
|
+
export function createCaptureState() {
|
|
27
|
+
return { captured: 0, hashes: new Set(), lastCaptureTime: 0 };
|
|
28
|
+
}
|
|
29
|
+
function hashText(text) {
|
|
30
|
+
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Analyze error output and auto-capture relevant findings.
|
|
34
|
+
* Returns the number of findings captured this call.
|
|
35
|
+
*/
|
|
36
|
+
export async function analyzeAndCapture(ctx, errorOutput, state) {
|
|
37
|
+
if (!ctx.project)
|
|
38
|
+
return 0;
|
|
39
|
+
if (state.captured >= SESSION_CAP)
|
|
40
|
+
return 0;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (now - state.lastCaptureTime < COOLDOWN_MS)
|
|
43
|
+
return 0;
|
|
44
|
+
let captured = 0;
|
|
45
|
+
for (const { pattern, label } of CAPTURE_PATTERNS) {
|
|
46
|
+
if (state.captured + captured >= SESSION_CAP)
|
|
47
|
+
break;
|
|
48
|
+
const match = errorOutput.match(pattern);
|
|
49
|
+
if (!match)
|
|
50
|
+
continue;
|
|
51
|
+
// Build a concise finding from the matched line
|
|
52
|
+
const lines = errorOutput.split("\n");
|
|
53
|
+
const matchedLine = lines.find((l) => pattern.test(l))?.trim() ?? match[0];
|
|
54
|
+
const finding = `[auto-capture:${label}] ${matchedLine.slice(0, 200)}`;
|
|
55
|
+
const hash = hashText(finding);
|
|
56
|
+
if (state.hashes.has(hash))
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
await addFinding(ctx.phrenPath, ctx.project, finding);
|
|
60
|
+
state.hashes.add(hash);
|
|
61
|
+
captured++;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// best effort
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (captured > 0) {
|
|
68
|
+
state.captured += captured;
|
|
69
|
+
state.lastCaptureTime = now;
|
|
70
|
+
}
|
|
71
|
+
return captured;
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { estimateTokens, estimateMessageTokens } from "../context/token-counter.js";
|
|
2
|
+
export function createFlushConfig(contextLimit) {
|
|
3
|
+
return { contextLimit, triggered: false };
|
|
4
|
+
}
|
|
5
|
+
const FLUSH_PROMPT = "Before continuing, briefly summarize the key decisions, patterns, and discoveries from this conversation so far. " +
|
|
6
|
+
"Focus on non-obvious findings that would be valuable to remember in future sessions. " +
|
|
7
|
+
"Be concise — 3-5 bullet points maximum.";
|
|
8
|
+
/**
|
|
9
|
+
* Check if a context flush should be injected.
|
|
10
|
+
* Returns the flush message to inject, or null if not needed.
|
|
11
|
+
* Only triggers once per session.
|
|
12
|
+
*/
|
|
13
|
+
export function checkFlushNeeded(systemPrompt, messages, config) {
|
|
14
|
+
if (config.triggered)
|
|
15
|
+
return null;
|
|
16
|
+
const systemTokens = estimateTokens(systemPrompt);
|
|
17
|
+
const msgTokens = estimateMessageTokens(messages);
|
|
18
|
+
const usage = (systemTokens + msgTokens) / config.contextLimit;
|
|
19
|
+
if (usage >= 0.75) {
|
|
20
|
+
config.triggered = true;
|
|
21
|
+
return FLUSH_PROMPT;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|