@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { findPhrenPath, getProjectDirs } from "@phren/cli/paths";
|
|
4
|
+
import { resolveRuntimeProfile } from "@phren/cli/runtime-profile";
|
|
5
|
+
import { buildIndex } from "@phren/cli/shared";
|
|
6
|
+
import { searchKnowledgeRows, rankResults } from "@phren/cli/shared/retrieval";
|
|
7
|
+
import { readTasks } from "@phren/cli/data/tasks";
|
|
8
|
+
import { readFindings } from "@phren/cli/data/access";
|
|
9
|
+
/** Try to find phren path and detect the active project from cwd. */
|
|
10
|
+
export async function buildPhrenContext(projectOverride) {
|
|
11
|
+
try {
|
|
12
|
+
const phrenPath = findPhrenPath();
|
|
13
|
+
if (!phrenPath || !fs.existsSync(phrenPath))
|
|
14
|
+
return null;
|
|
15
|
+
let profile = "";
|
|
16
|
+
try {
|
|
17
|
+
profile = resolveRuntimeProfile(phrenPath) ?? "";
|
|
18
|
+
}
|
|
19
|
+
catch { /* no profile */ }
|
|
20
|
+
let project = projectOverride ?? null;
|
|
21
|
+
if (!project) {
|
|
22
|
+
try {
|
|
23
|
+
const projectDirs = getProjectDirs(phrenPath, profile || undefined);
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
for (const dir of projectDirs) {
|
|
26
|
+
const name = path.basename(dir);
|
|
27
|
+
try {
|
|
28
|
+
const configPath = path.join(dir, "project.yaml");
|
|
29
|
+
if (fs.existsSync(configPath)) {
|
|
30
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
31
|
+
const sourceMatch = content.match(/source:\s*(.+)/);
|
|
32
|
+
if (sourceMatch?.[1]) {
|
|
33
|
+
const sourcePath = sourceMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
|
34
|
+
if (cwd.startsWith(sourcePath) || cwd === sourcePath) {
|
|
35
|
+
project = name;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { /* skip */ }
|
|
42
|
+
if (path.basename(cwd) === name) {
|
|
43
|
+
project = name;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { /* no project detection */ }
|
|
49
|
+
}
|
|
50
|
+
return { phrenPath, profile, project };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Read truths.md pinned entries for a project. */
|
|
57
|
+
function readTruths(phrenPath, project) {
|
|
58
|
+
try {
|
|
59
|
+
const truthsPath = path.join(phrenPath, project, "truths.md");
|
|
60
|
+
if (!fs.existsSync(truthsPath))
|
|
61
|
+
return [];
|
|
62
|
+
const content = fs.readFileSync(truthsPath, "utf-8");
|
|
63
|
+
return content.split("\n").filter((line) => line.startsWith("- "));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Read source path from project.yaml. */
|
|
70
|
+
function readProjectSourcePath(phrenPath, project) {
|
|
71
|
+
try {
|
|
72
|
+
const configPath = path.join(phrenPath, project, "project.yaml");
|
|
73
|
+
if (!fs.existsSync(configPath))
|
|
74
|
+
return null;
|
|
75
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
76
|
+
const match = content.match(/source:\s*(.+)/);
|
|
77
|
+
if (!match?.[1])
|
|
78
|
+
return null;
|
|
79
|
+
return match[1].trim().replace(/^['"]|['"]$/g, "");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Build a context string from phren knowledge to inject into the system prompt. */
|
|
86
|
+
export async function buildContextSnippet(ctx, taskKeywords) {
|
|
87
|
+
const sections = [];
|
|
88
|
+
const label = ctx.project ?? "global";
|
|
89
|
+
// Section 1: Pinned truths
|
|
90
|
+
if (ctx.project) {
|
|
91
|
+
try {
|
|
92
|
+
const truths = readTruths(ctx.phrenPath, ctx.project);
|
|
93
|
+
if (truths.length > 0) {
|
|
94
|
+
sections.push(`## Pinned truths (${label})\n\n${truths.join("\n")}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch { /* silent */ }
|
|
98
|
+
}
|
|
99
|
+
// Section 2: Active tasks
|
|
100
|
+
if (ctx.project) {
|
|
101
|
+
try {
|
|
102
|
+
const result = readTasks(ctx.phrenPath, ctx.project);
|
|
103
|
+
if (result.ok && result.data) {
|
|
104
|
+
const items = result.data.items;
|
|
105
|
+
const lines = [];
|
|
106
|
+
const active = items.Active?.slice(0, 5) ?? [];
|
|
107
|
+
const queue = items.Queue?.slice(0, 3) ?? [];
|
|
108
|
+
for (const t of active)
|
|
109
|
+
lines.push(`- [Active] ${t.line}`);
|
|
110
|
+
for (const t of queue)
|
|
111
|
+
lines.push(`- [Queue] ${t.line}`);
|
|
112
|
+
if (lines.length > 0) {
|
|
113
|
+
sections.push(`## Tasks (${label})\n\n${lines.join("\n")}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch { /* silent */ }
|
|
118
|
+
}
|
|
119
|
+
// Section 3: Recent findings
|
|
120
|
+
if (ctx.project) {
|
|
121
|
+
try {
|
|
122
|
+
const result = readFindings(ctx.phrenPath, ctx.project);
|
|
123
|
+
if (result.ok && result.data) {
|
|
124
|
+
const active = result.data
|
|
125
|
+
.filter((f) => f.status === "active" && f.tier !== "archived")
|
|
126
|
+
.slice(-5);
|
|
127
|
+
if (active.length > 0) {
|
|
128
|
+
const lines = active.map((f) => `- ${f.text}`);
|
|
129
|
+
sections.push(`## Recent findings (${label})\n\n${lines.join("\n")}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch { /* silent */ }
|
|
134
|
+
}
|
|
135
|
+
// Section 4: Project CLAUDE.md
|
|
136
|
+
if (ctx.project) {
|
|
137
|
+
try {
|
|
138
|
+
const sourcePath = readProjectSourcePath(ctx.phrenPath, ctx.project);
|
|
139
|
+
if (sourcePath) {
|
|
140
|
+
const claudePath = path.join(sourcePath, "CLAUDE.md");
|
|
141
|
+
if (fs.existsSync(claudePath)) {
|
|
142
|
+
const content = fs.readFileSync(claudePath, "utf-8").slice(0, 800);
|
|
143
|
+
sections.push(`## Project CLAUDE.md (${label})\n\n${content}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch { /* silent */ }
|
|
148
|
+
}
|
|
149
|
+
// Section 5: FTS5 search
|
|
150
|
+
try {
|
|
151
|
+
const db = await buildIndex(ctx.phrenPath, ctx.profile || undefined);
|
|
152
|
+
const result = await searchKnowledgeRows(db, {
|
|
153
|
+
query: taskKeywords,
|
|
154
|
+
maxResults: 10,
|
|
155
|
+
filterProject: ctx.project || null,
|
|
156
|
+
filterType: null,
|
|
157
|
+
phrenPath: ctx.phrenPath,
|
|
158
|
+
});
|
|
159
|
+
const ranked = rankResults(result.rows ?? [], taskKeywords, null, ctx.project || null, ctx.phrenPath, db);
|
|
160
|
+
if (ranked.length > 0) {
|
|
161
|
+
const snippets = ranked.slice(0, 5).map((r) => {
|
|
162
|
+
const content = r.content?.slice(0, 400) ?? "";
|
|
163
|
+
return `[${r.project}/${r.filename}] ${content}`;
|
|
164
|
+
});
|
|
165
|
+
sections.push(`## Related knowledge (${label})\n\n${snippets.join("\n\n")}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* silent */ }
|
|
169
|
+
return sections.join("\n\n");
|
|
170
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { buildIndex } from "@phren/cli/shared";
|
|
2
|
+
import { searchKnowledgeRows, rankResults } from "@phren/cli/shared/retrieval";
|
|
3
|
+
const NOISE_WORDS = new Set([
|
|
4
|
+
"at", "in", "of", "the", "is", "was", "error", "err", "warning",
|
|
5
|
+
"from", "to", "and", "or", "not", "with", "for", "on", "by",
|
|
6
|
+
"null", "undefined", "true", "false", "function", "object",
|
|
7
|
+
]);
|
|
8
|
+
const HEX_PATTERN = /\b0x[0-9a-f]+\b/gi;
|
|
9
|
+
const STACK_LINE = /^\s+at\s+/;
|
|
10
|
+
const PATH_NOISE = /\(?\/?[\w./\\-]+:\d+:\d+\)?/g;
|
|
11
|
+
/** Extract meaningful keywords from an error string. */
|
|
12
|
+
function extractErrorKeywords(errorOutput) {
|
|
13
|
+
const lines = errorOutput.split("\n");
|
|
14
|
+
// Keep only non-stack-trace lines
|
|
15
|
+
const meaningful = lines
|
|
16
|
+
.filter((l) => !STACK_LINE.test(l))
|
|
17
|
+
.slice(0, 5)
|
|
18
|
+
.join(" ");
|
|
19
|
+
const cleaned = meaningful
|
|
20
|
+
.replace(HEX_PATTERN, "")
|
|
21
|
+
.replace(PATH_NOISE, "")
|
|
22
|
+
.replace(/[^a-zA-Z0-9_\s.-]/g, " ");
|
|
23
|
+
const words = cleaned
|
|
24
|
+
.split(/\s+/)
|
|
25
|
+
.filter((w) => w.length > 2 && !NOISE_WORDS.has(w.toLowerCase()))
|
|
26
|
+
.slice(0, 12);
|
|
27
|
+
return words.join(" ");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Search phren for past knowledge related to a tool error.
|
|
31
|
+
* Returns a formatted context string, or empty string on no results / error.
|
|
32
|
+
*/
|
|
33
|
+
export async function searchErrorRecovery(ctx, errorOutput) {
|
|
34
|
+
try {
|
|
35
|
+
const keywords = extractErrorKeywords(errorOutput);
|
|
36
|
+
if (!keywords.trim())
|
|
37
|
+
return "";
|
|
38
|
+
const db = await buildIndex(ctx.phrenPath, ctx.profile || undefined);
|
|
39
|
+
const result = await searchKnowledgeRows(db, {
|
|
40
|
+
query: keywords,
|
|
41
|
+
maxResults: 6,
|
|
42
|
+
filterProject: ctx.project || null,
|
|
43
|
+
filterType: null,
|
|
44
|
+
phrenPath: ctx.phrenPath,
|
|
45
|
+
});
|
|
46
|
+
const ranked = rankResults(result.rows ?? [], keywords, null, ctx.project || null, ctx.phrenPath, db);
|
|
47
|
+
if (ranked.length === 0)
|
|
48
|
+
return "";
|
|
49
|
+
const snippets = ranked.slice(0, 3).map((r) => {
|
|
50
|
+
const content = r.content?.slice(0, 300) ?? "";
|
|
51
|
+
return `[${r.project}/${r.filename}] ${content}`;
|
|
52
|
+
});
|
|
53
|
+
return `\n\n--- phren recovery context ---\n${snippets.join("\n\n")}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project context evolution: lightweight LLM reflection at session end
|
|
3
|
+
* and warm-start context loading.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
const CONTEXT_FILE = "agent-context.md";
|
|
8
|
+
const MAX_DATE_SECTIONS = 3;
|
|
9
|
+
function contextPath(ctx) {
|
|
10
|
+
if (!ctx.project)
|
|
11
|
+
return null;
|
|
12
|
+
return path.join(ctx.phrenPath, ctx.project, CONTEXT_FILE);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Load the last N date sections from agent-context.md for warm start.
|
|
16
|
+
*/
|
|
17
|
+
export function loadProjectContext(ctx) {
|
|
18
|
+
const file = contextPath(ctx);
|
|
19
|
+
if (!file || !fs.existsSync(file))
|
|
20
|
+
return "";
|
|
21
|
+
try {
|
|
22
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
23
|
+
const sections = content.split(/(?=^## \d{4}-\d{2}-\d{2})/m).filter(Boolean);
|
|
24
|
+
const recent = sections.slice(-MAX_DATE_SECTIONS);
|
|
25
|
+
return recent.join("\n").trim();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run a lightweight LLM reflection at session end and append to agent-context.md.
|
|
33
|
+
* Summarizes key learnings from the conversation.
|
|
34
|
+
*/
|
|
35
|
+
export async function evolveProjectContext(ctx, provider, sessionMessages) {
|
|
36
|
+
const file = contextPath(ctx);
|
|
37
|
+
if (!file)
|
|
38
|
+
return;
|
|
39
|
+
// Build a condensed conversation summary for the reflection prompt
|
|
40
|
+
const condensed = sessionMessages
|
|
41
|
+
.slice(-20) // last 20 messages max
|
|
42
|
+
.map((m) => {
|
|
43
|
+
if (typeof m.content === "string")
|
|
44
|
+
return `${m.role}: ${m.content.slice(0, 200)}`;
|
|
45
|
+
const text = m.content
|
|
46
|
+
.filter((b) => b.type === "text")
|
|
47
|
+
.map((b) => b.text.slice(0, 150))
|
|
48
|
+
.join(" ");
|
|
49
|
+
const tools = m.content
|
|
50
|
+
.filter((b) => b.type === "tool_use")
|
|
51
|
+
.map((b) => b.name);
|
|
52
|
+
const parts = [text, tools.length > 0 ? `[tools: ${tools.join(", ")}]` : ""].filter(Boolean);
|
|
53
|
+
return `${m.role}: ${parts.join(" ")}`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n");
|
|
56
|
+
const reflectionPrompt = "Based on this conversation excerpt, extract 2-4 key learnings about this project " +
|
|
57
|
+
"(patterns, pitfalls, architecture decisions, important paths/configs). " +
|
|
58
|
+
"Be extremely concise — one line per point. Output only the bullet points, nothing else.\n\n" +
|
|
59
|
+
condensed;
|
|
60
|
+
try {
|
|
61
|
+
const response = await provider.chat("You are a concise technical note-taker.", [{ role: "user", content: reflectionPrompt }], []);
|
|
62
|
+
const reflection = response.content
|
|
63
|
+
.filter((b) => b.type === "text")
|
|
64
|
+
.map((b) => b.text)
|
|
65
|
+
.join("\n")
|
|
66
|
+
.trim();
|
|
67
|
+
if (!reflection || reflection.length < 10)
|
|
68
|
+
return;
|
|
69
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
70
|
+
const entry = `\n## ${date}\n\n${reflection}\n`;
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
|
+
fs.appendFileSync(file, entry);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// best effort — don't fail the session over this
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
function sessionsDir(phrenPath) {
|
|
5
|
+
const dir = path.join(phrenPath, ".runtime", "sessions");
|
|
6
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
7
|
+
return dir;
|
|
8
|
+
}
|
|
9
|
+
function sessionFile(phrenPath, sessionId) {
|
|
10
|
+
return path.join(sessionsDir(phrenPath), `session-${sessionId}.json`);
|
|
11
|
+
}
|
|
12
|
+
export function startSession(ctx) {
|
|
13
|
+
const sessionId = crypto.randomUUID();
|
|
14
|
+
const state = {
|
|
15
|
+
sessionId,
|
|
16
|
+
project: ctx.project || undefined,
|
|
17
|
+
startedAt: new Date().toISOString(),
|
|
18
|
+
findingsAdded: 0,
|
|
19
|
+
tasksCompleted: 0,
|
|
20
|
+
agentCreated: true,
|
|
21
|
+
};
|
|
22
|
+
const file = sessionFile(ctx.phrenPath, sessionId);
|
|
23
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
|
24
|
+
return sessionId;
|
|
25
|
+
}
|
|
26
|
+
export function endSession(ctx, sessionId, summary) {
|
|
27
|
+
const file = sessionFile(ctx.phrenPath, sessionId);
|
|
28
|
+
if (!fs.existsSync(file))
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
const state = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
32
|
+
state.endedAt = new Date().toISOString();
|
|
33
|
+
if (summary) {
|
|
34
|
+
// Also write to last-summary.json for fast pickup by next session_start
|
|
35
|
+
const summaryFile = path.join(sessionsDir(ctx.phrenPath), "last-summary.json");
|
|
36
|
+
fs.writeFileSync(summaryFile, JSON.stringify({
|
|
37
|
+
summary,
|
|
38
|
+
sessionId,
|
|
39
|
+
project: state.project,
|
|
40
|
+
endedAt: state.endedAt,
|
|
41
|
+
}, null, 2) + "\n");
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
|
44
|
+
}
|
|
45
|
+
catch { /* best effort */ }
|
|
46
|
+
}
|
|
47
|
+
export function incrementSessionCounter(phrenPath, sessionId, counter) {
|
|
48
|
+
const file = sessionFile(phrenPath, sessionId);
|
|
49
|
+
if (!fs.existsSync(file))
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
const state = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
53
|
+
state[counter] = (state[counter] ?? 0) + 1;
|
|
54
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
|
55
|
+
}
|
|
56
|
+
catch { /* best effort */ }
|
|
57
|
+
}
|
|
58
|
+
/** Read the most recent session summary for prior context. */
|
|
59
|
+
export function getPriorSummary(ctx) {
|
|
60
|
+
try {
|
|
61
|
+
const summaryFile = path.join(sessionsDir(ctx.phrenPath), "last-summary.json");
|
|
62
|
+
if (!fs.existsSync(summaryFile))
|
|
63
|
+
return null;
|
|
64
|
+
const data = JSON.parse(fs.readFileSync(summaryFile, "utf-8"));
|
|
65
|
+
return data.summary || null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function messagesDir(phrenPath) {
|
|
72
|
+
const dir = path.join(phrenPath, ".runtime", "sessions");
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
return dir;
|
|
75
|
+
}
|
|
76
|
+
/** Save session messages for later resume. */
|
|
77
|
+
export function saveSessionMessages(phrenPath, sessionId, messages) {
|
|
78
|
+
const file = path.join(messagesDir(phrenPath), `session-${sessionId}-messages.json`);
|
|
79
|
+
fs.writeFileSync(file, JSON.stringify(messages, null, 2) + "\n");
|
|
80
|
+
}
|
|
81
|
+
/** Load the last session's messages for resume. Returns null if none found. */
|
|
82
|
+
export function loadLastSessionMessages(phrenPath) {
|
|
83
|
+
try {
|
|
84
|
+
const dir = messagesDir(phrenPath);
|
|
85
|
+
const files = fs.readdirSync(dir)
|
|
86
|
+
.filter(f => f.endsWith("-messages.json"))
|
|
87
|
+
.map(f => ({
|
|
88
|
+
name: f,
|
|
89
|
+
mtime: fs.statSync(path.join(dir, f)).mtimeMs,
|
|
90
|
+
}))
|
|
91
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
92
|
+
if (files.length === 0)
|
|
93
|
+
return null;
|
|
94
|
+
const data = fs.readFileSync(path.join(dir, files[0].name), "utf-8");
|
|
95
|
+
return JSON.parse(data);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unique colors and icons for spawned agents.
|
|
3
|
+
*
|
|
4
|
+
* Each agent gets a deterministic style based on its index (mod array length),
|
|
5
|
+
* so the same slot always looks the same even across re-renders.
|
|
6
|
+
*/
|
|
7
|
+
const ESC = "\x1b[";
|
|
8
|
+
const RESET = `${ESC}0m`;
|
|
9
|
+
// ── Color palette (8 distinct ANSI colors) ──────────────────────────────────
|
|
10
|
+
const AGENT_COLORS = [
|
|
11
|
+
{ name: "cyan", code: "36" },
|
|
12
|
+
{ name: "magenta", code: "35" },
|
|
13
|
+
{ name: "yellow", code: "33" },
|
|
14
|
+
{ name: "green", code: "32" },
|
|
15
|
+
{ name: "blue", code: "34" },
|
|
16
|
+
{ name: "red", code: "31" },
|
|
17
|
+
{ name: "white", code: "37" },
|
|
18
|
+
{ name: "bright-cyan", code: "96" },
|
|
19
|
+
];
|
|
20
|
+
// ── Icon palette (8 unicode icons) ──────────────────────────────────────────
|
|
21
|
+
const AGENT_ICONS = ["◆", "◇", "●", "○", "■", "□", "▲", "★"];
|
|
22
|
+
/** Get a deterministic style for agent at the given index. */
|
|
23
|
+
export function getAgentStyle(index) {
|
|
24
|
+
const c = AGENT_COLORS[index % AGENT_COLORS.length];
|
|
25
|
+
const icon = AGENT_ICONS[index % AGENT_ICONS.length];
|
|
26
|
+
return {
|
|
27
|
+
color: (text) => `${ESC}${c.code}m${text}${RESET}`,
|
|
28
|
+
icon,
|
|
29
|
+
colorName: c.name,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Format an agent name with its icon and color: "◆ agent-name" */
|
|
33
|
+
export function formatAgentName(name, index) {
|
|
34
|
+
const { color, icon } = getAgentStyle(index);
|
|
35
|
+
return color(`${icon} ${name}`);
|
|
36
|
+
}
|
|
37
|
+
/** Prefix a line with the agent's icon in its color. */
|
|
38
|
+
export function prefixLine(line, index) {
|
|
39
|
+
const { color, icon } = getAgentStyle(index);
|
|
40
|
+
return `${color(icon)} ${line}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Child agent entry point — standalone process that:
|
|
4
|
+
* 1. Receives SpawnPayload via IPC from parent
|
|
5
|
+
* 2. Resolves the LLM provider
|
|
6
|
+
* 3. Registers the standard tool set
|
|
7
|
+
* 4. Runs the agent loop with TurnHooks that serialize events back via process.send()
|
|
8
|
+
* 5. Sends a final "done" message with the result
|
|
9
|
+
*/
|
|
10
|
+
import { resolveProvider } from "../providers/resolve.js";
|
|
11
|
+
import { ToolRegistry } from "../tools/registry.js";
|
|
12
|
+
import { readFileTool } from "../tools/read-file.js";
|
|
13
|
+
import { writeFileTool } from "../tools/write-file.js";
|
|
14
|
+
import { editFileTool } from "../tools/edit-file.js";
|
|
15
|
+
import { shellTool } from "../tools/shell.js";
|
|
16
|
+
import { globTool } from "../tools/glob.js";
|
|
17
|
+
import { grepTool } from "../tools/grep.js";
|
|
18
|
+
import { createPhrenSearchTool } from "../tools/phren-search.js";
|
|
19
|
+
import { createPhrenFindingTool } from "../tools/phren-finding.js";
|
|
20
|
+
import { createPhrenGetTasksTool, createPhrenCompleteTaskTool } from "../tools/phren-tasks.js";
|
|
21
|
+
import { gitStatusTool, gitDiffTool, gitCommitTool } from "../tools/git.js";
|
|
22
|
+
import { buildPhrenContext, buildContextSnippet } from "../memory/context.js";
|
|
23
|
+
import { startSession, endSession, getPriorSummary } from "../memory/session.js";
|
|
24
|
+
import { loadProjectContext } from "../memory/project-context.js";
|
|
25
|
+
import { buildSystemPrompt } from "../system-prompt.js";
|
|
26
|
+
import { runAgent } from "../agent-loop.js";
|
|
27
|
+
import { createCostTracker } from "../cost.js";
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
/** Send a typed message to the parent process. */
|
|
30
|
+
function send(msg) {
|
|
31
|
+
if (process.send) {
|
|
32
|
+
process.send(msg);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Build TurnHooks that relay all events to the parent via IPC. */
|
|
36
|
+
function createIpcHooks(agentId) {
|
|
37
|
+
return {
|
|
38
|
+
onTextDelta(text) {
|
|
39
|
+
send({ type: "text_delta", agentId, text });
|
|
40
|
+
},
|
|
41
|
+
onTextDone() {
|
|
42
|
+
// No-op — parent reconstructs from deltas
|
|
43
|
+
},
|
|
44
|
+
onTextBlock(text) {
|
|
45
|
+
send({ type: "text_block", agentId, text });
|
|
46
|
+
},
|
|
47
|
+
onToolStart(name, input, count) {
|
|
48
|
+
send({ type: "tool_start", agentId, toolName: name, input, count });
|
|
49
|
+
},
|
|
50
|
+
onToolEnd(name, input, output, isError, durationMs) {
|
|
51
|
+
send({ type: "tool_end", agentId, toolName: name, input, output, isError, durationMs });
|
|
52
|
+
},
|
|
53
|
+
onStatus(msg) {
|
|
54
|
+
send({ type: "status", agentId, message: msg });
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Run the agent with the given spawn payload. */
|
|
59
|
+
async function runChildAgent(payload) {
|
|
60
|
+
const { agentId, task, cwd, provider: providerName, model, project, permissions, maxTurns, budget, plan, verbose } = payload;
|
|
61
|
+
// Set cwd
|
|
62
|
+
process.chdir(cwd);
|
|
63
|
+
// Resolve LLM provider
|
|
64
|
+
let provider;
|
|
65
|
+
try {
|
|
66
|
+
provider = resolveProvider(providerName, model);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
send({ type: "error", agentId, error: err instanceof Error ? err.message : String(err) });
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
// Build phren context
|
|
73
|
+
const phrenCtx = await buildPhrenContext(project);
|
|
74
|
+
let contextSnippet = "";
|
|
75
|
+
let priorSummary = null;
|
|
76
|
+
let sessionId = null;
|
|
77
|
+
if (phrenCtx) {
|
|
78
|
+
contextSnippet = await buildContextSnippet(phrenCtx, task);
|
|
79
|
+
priorSummary = getPriorSummary(phrenCtx);
|
|
80
|
+
sessionId = startSession(phrenCtx);
|
|
81
|
+
const projectCtx = loadProjectContext(phrenCtx);
|
|
82
|
+
if (projectCtx) {
|
|
83
|
+
contextSnippet += `\n\n## Agent context (${phrenCtx.project})\n\n${projectCtx}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const systemPrompt = buildSystemPrompt(contextSnippet, priorSummary);
|
|
87
|
+
// Register tools
|
|
88
|
+
const registry = new ToolRegistry();
|
|
89
|
+
registry.setPermissions({
|
|
90
|
+
mode: permissions,
|
|
91
|
+
allowedPaths: [],
|
|
92
|
+
projectRoot: cwd,
|
|
93
|
+
});
|
|
94
|
+
registry.register(readFileTool);
|
|
95
|
+
registry.register(writeFileTool);
|
|
96
|
+
registry.register(editFileTool);
|
|
97
|
+
registry.register(shellTool);
|
|
98
|
+
registry.register(globTool);
|
|
99
|
+
registry.register(grepTool);
|
|
100
|
+
if (phrenCtx) {
|
|
101
|
+
registry.register(createPhrenSearchTool(phrenCtx));
|
|
102
|
+
registry.register(createPhrenFindingTool(phrenCtx, sessionId));
|
|
103
|
+
registry.register(createPhrenGetTasksTool(phrenCtx));
|
|
104
|
+
registry.register(createPhrenCompleteTaskTool(phrenCtx, sessionId));
|
|
105
|
+
}
|
|
106
|
+
registry.register(gitStatusTool);
|
|
107
|
+
registry.register(gitDiffTool);
|
|
108
|
+
registry.register(gitCommitTool);
|
|
109
|
+
// Cost tracker
|
|
110
|
+
const modelName = model ?? provider.name;
|
|
111
|
+
const costTracker = createCostTracker(modelName, budget);
|
|
112
|
+
const config = {
|
|
113
|
+
provider,
|
|
114
|
+
registry,
|
|
115
|
+
systemPrompt,
|
|
116
|
+
maxTurns,
|
|
117
|
+
verbose,
|
|
118
|
+
phrenCtx,
|
|
119
|
+
costTracker,
|
|
120
|
+
plan,
|
|
121
|
+
};
|
|
122
|
+
// Run the agent
|
|
123
|
+
try {
|
|
124
|
+
const result = await runAgent(task, config);
|
|
125
|
+
// End phren session
|
|
126
|
+
if (phrenCtx && sessionId) {
|
|
127
|
+
const summary = result.finalText.slice(0, 500);
|
|
128
|
+
endSession(phrenCtx, sessionId, summary);
|
|
129
|
+
}
|
|
130
|
+
send({
|
|
131
|
+
type: "done",
|
|
132
|
+
agentId,
|
|
133
|
+
result: {
|
|
134
|
+
finalText: result.finalText,
|
|
135
|
+
turns: result.turns,
|
|
136
|
+
toolCalls: result.toolCalls,
|
|
137
|
+
totalCost: result.totalCost,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (phrenCtx && sessionId) {
|
|
143
|
+
endSession(phrenCtx, sessionId, `Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
144
|
+
}
|
|
145
|
+
send({
|
|
146
|
+
type: "error",
|
|
147
|
+
agentId,
|
|
148
|
+
error: err instanceof Error ? err.message : String(err),
|
|
149
|
+
});
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ── Main: wait for spawn payload from parent ────────────────────────────────
|
|
154
|
+
process.on("message", (msg) => {
|
|
155
|
+
if (msg.type === "spawn") {
|
|
156
|
+
runChildAgent(msg).then(() => {
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}).catch((err) => {
|
|
159
|
+
const agentId = msg.agentId;
|
|
160
|
+
send({ type: "error", agentId, error: err instanceof Error ? err.message : String(err) });
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else if (msg.type === "cancel") {
|
|
165
|
+
cancelled = true;
|
|
166
|
+
process.exit(130);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
// If no IPC channel (run directly), exit with error
|
|
170
|
+
if (!process.send) {
|
|
171
|
+
process.stderr.write("child-entry.ts must be spawned via AgentSpawner (requires IPC channel)\n");
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|