@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,328 @@
|
|
|
1
|
+
import { createSpinner, formatTurnHeader, formatToolCall } from "./spinner.js";
|
|
2
|
+
import { searchErrorRecovery } from "./memory/error-recovery.js";
|
|
3
|
+
import { shouldPrune, pruneMessages } from "./context/pruner.js";
|
|
4
|
+
import { withRetry } from "./providers/retry.js";
|
|
5
|
+
import { createCaptureState, analyzeAndCapture } from "./memory/auto-capture.js";
|
|
6
|
+
import { AntiPatternTracker } from "./memory/anti-patterns.js";
|
|
7
|
+
import { createFlushConfig, checkFlushNeeded } from "./memory/context-flush.js";
|
|
8
|
+
import { injectPlanPrompt, requestPlanApproval } from "./plan.js";
|
|
9
|
+
import { detectLintCommand, detectTestCommand, runPostEditCheck } from "./tools/lint-test.js";
|
|
10
|
+
import { createCheckpoint } from "./checkpoint.js";
|
|
11
|
+
const MAX_TOOL_CONCURRENCY = 5;
|
|
12
|
+
const MAX_LINT_TEST_RETRIES = 3;
|
|
13
|
+
export function createSession(contextLimit) {
|
|
14
|
+
return {
|
|
15
|
+
messages: [],
|
|
16
|
+
turns: 0,
|
|
17
|
+
toolCalls: 0,
|
|
18
|
+
captureState: createCaptureState(),
|
|
19
|
+
antiPatterns: new AntiPatternTracker(),
|
|
20
|
+
flushConfig: createFlushConfig(contextLimit ?? 200_000),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Run tool blocks with concurrency limit. */
|
|
24
|
+
async function runToolsConcurrently(blocks, registry) {
|
|
25
|
+
const results = [];
|
|
26
|
+
for (let i = 0; i < blocks.length; i += MAX_TOOL_CONCURRENCY) {
|
|
27
|
+
const batch = blocks.slice(i, i + MAX_TOOL_CONCURRENCY);
|
|
28
|
+
const batchResults = await Promise.all(batch.map(async (block) => {
|
|
29
|
+
const TOOL_TIMEOUT_MS = 120_000;
|
|
30
|
+
try {
|
|
31
|
+
const result = await Promise.race([
|
|
32
|
+
registry.execute(block.name, block.input),
|
|
33
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Tool '${block.name}' timed out after ${TOOL_TIMEOUT_MS / 1000}s`)), TOOL_TIMEOUT_MS)),
|
|
34
|
+
]);
|
|
35
|
+
return { block, output: result.output, is_error: !!result.is_error };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
39
|
+
return { block, output: msg, is_error: true };
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
results.push(...batchResults);
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
/** Consume a chatStream into ContentBlock[] + stop_reason, streaming text via callback. */
|
|
47
|
+
async function consumeStream(stream, costTracker, onTextDelta) {
|
|
48
|
+
const content = [];
|
|
49
|
+
let stop_reason = "end_turn";
|
|
50
|
+
let currentText = "";
|
|
51
|
+
// Map block index -> tool state for Anthropic-style index-based IDs
|
|
52
|
+
const toolsByIndex = new Map();
|
|
53
|
+
for await (const delta of stream) {
|
|
54
|
+
if (delta.type === "text_delta") {
|
|
55
|
+
(onTextDelta ?? process.stdout.write.bind(process.stdout))(delta.text);
|
|
56
|
+
currentText += delta.text;
|
|
57
|
+
}
|
|
58
|
+
else if (delta.type === "tool_use_start") {
|
|
59
|
+
// Flush accumulated text
|
|
60
|
+
if (currentText) {
|
|
61
|
+
content.push({ type: "text", text: currentText });
|
|
62
|
+
currentText = "";
|
|
63
|
+
}
|
|
64
|
+
toolsByIndex.set(delta.id, { id: delta.id, name: delta.name, jsonParts: [] });
|
|
65
|
+
}
|
|
66
|
+
else if (delta.type === "tool_use_delta") {
|
|
67
|
+
const tool = toolsByIndex.get(delta.id);
|
|
68
|
+
if (tool)
|
|
69
|
+
tool.jsonParts.push(delta.json);
|
|
70
|
+
}
|
|
71
|
+
else if (delta.type === "tool_use_end") {
|
|
72
|
+
const tool = toolsByIndex.get(delta.id);
|
|
73
|
+
if (tool) {
|
|
74
|
+
const jsonStr = tool.jsonParts.join("");
|
|
75
|
+
let input = {};
|
|
76
|
+
try {
|
|
77
|
+
input = JSON.parse(jsonStr);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
process.stderr.write(`\x1b[33m[warning] Malformed tool_use JSON for ${tool.name} (${tool.id}), skipping block\x1b[0m\n`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
content.push({ type: "tool_use", id: tool.id, name: tool.name, input });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (delta.type === "done") {
|
|
87
|
+
stop_reason = delta.stop_reason;
|
|
88
|
+
if (costTracker && delta.usage) {
|
|
89
|
+
costTracker.recordUsage(delta.usage.input_tokens, delta.usage.output_tokens);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Flush remaining text
|
|
94
|
+
if (currentText) {
|
|
95
|
+
if (!currentText.endsWith("\n")) {
|
|
96
|
+
(onTextDelta ?? process.stdout.write.bind(process.stdout))("\n");
|
|
97
|
+
}
|
|
98
|
+
content.push({ type: "text", text: currentText });
|
|
99
|
+
}
|
|
100
|
+
return { content, stop_reason };
|
|
101
|
+
}
|
|
102
|
+
export async function runTurn(userInput, session, config, hooks) {
|
|
103
|
+
const { provider, registry, maxTurns, verbose, costTracker } = config;
|
|
104
|
+
let systemPrompt = config.systemPrompt;
|
|
105
|
+
const toolDefs = registry.getDefinitions();
|
|
106
|
+
const spinner = createSpinner();
|
|
107
|
+
const useStream = typeof provider.chatStream === "function";
|
|
108
|
+
const write = hooks?.onTextDelta ?? process.stdout.write.bind(process.stdout);
|
|
109
|
+
const status = hooks?.onStatus ?? ((msg) => process.stderr.write(msg));
|
|
110
|
+
// Plan mode: modify system prompt for first turn
|
|
111
|
+
let planPending = config.plan && session.turns === 0;
|
|
112
|
+
if (planPending) {
|
|
113
|
+
systemPrompt = injectPlanPrompt(systemPrompt);
|
|
114
|
+
}
|
|
115
|
+
// Append user message
|
|
116
|
+
session.messages.push({ role: "user", content: userInput });
|
|
117
|
+
let turnToolCalls = 0;
|
|
118
|
+
const turnStart = session.turns;
|
|
119
|
+
while (session.turns - turnStart < maxTurns) {
|
|
120
|
+
// Budget check
|
|
121
|
+
if (costTracker?.isOverBudget()) {
|
|
122
|
+
status(`\x1b[33m[budget exceeded: ${costTracker.formatCost()}]\x1b[0m\n`);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (verbose && session.turns > turnStart) {
|
|
126
|
+
status(`\n${formatTurnHeader(session.turns + 1, turnToolCalls)}\n`);
|
|
127
|
+
}
|
|
128
|
+
// Check if context flush is needed (one-time per session) — must run before pruning
|
|
129
|
+
const contextLimit = provider.contextWindow ?? 200_000;
|
|
130
|
+
const flushPrompt = checkFlushNeeded(systemPrompt, session.messages, session.flushConfig);
|
|
131
|
+
if (flushPrompt) {
|
|
132
|
+
session.messages.push({ role: "user", content: flushPrompt });
|
|
133
|
+
if (verbose)
|
|
134
|
+
status("[context flush injected]\n");
|
|
135
|
+
}
|
|
136
|
+
// Prune context if approaching limit
|
|
137
|
+
if (shouldPrune(systemPrompt, session.messages, { contextLimit })) {
|
|
138
|
+
session.messages = pruneMessages(session.messages, { contextLimit, keepRecentTurns: 6 });
|
|
139
|
+
if (verbose)
|
|
140
|
+
status("[context pruned]\n");
|
|
141
|
+
}
|
|
142
|
+
// For plan mode first turn, pass empty tools so LLM can't call any
|
|
143
|
+
const turnTools = planPending ? [] : toolDefs;
|
|
144
|
+
let assistantContent;
|
|
145
|
+
let stopReason;
|
|
146
|
+
if (useStream) {
|
|
147
|
+
// Streaming path — retry the initial connection (before consuming deltas)
|
|
148
|
+
const stream = await withRetry(async () => provider.chatStream(systemPrompt, session.messages, turnTools), undefined, verbose);
|
|
149
|
+
const result = await consumeStream(stream, costTracker, hooks?.onTextDelta);
|
|
150
|
+
assistantContent = result.content;
|
|
151
|
+
stopReason = result.stop_reason;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Batch path
|
|
155
|
+
spinner.start("Thinking...");
|
|
156
|
+
const response = await withRetry(() => provider.chat(systemPrompt, session.messages, turnTools), undefined, verbose);
|
|
157
|
+
spinner.stop();
|
|
158
|
+
assistantContent = response.content;
|
|
159
|
+
stopReason = response.stop_reason;
|
|
160
|
+
// Track cost from batch response
|
|
161
|
+
if (costTracker && response.usage) {
|
|
162
|
+
costTracker.recordUsage(response.usage.input_tokens, response.usage.output_tokens);
|
|
163
|
+
}
|
|
164
|
+
// Print text blocks (streaming already prints inline)
|
|
165
|
+
for (const block of assistantContent) {
|
|
166
|
+
if (block.type === "text" && block.text) {
|
|
167
|
+
if (hooks?.onTextBlock) {
|
|
168
|
+
hooks.onTextBlock(block.text);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
process.stdout.write(block.text);
|
|
172
|
+
if (!block.text.endsWith("\n"))
|
|
173
|
+
process.stdout.write("\n");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
session.messages.push({ role: "assistant", content: assistantContent });
|
|
179
|
+
session.turns++;
|
|
180
|
+
// Show turn cost
|
|
181
|
+
if (verbose && costTracker) {
|
|
182
|
+
status(`\x1b[2m cost: ${costTracker.formatCost()}\x1b[0m\n`);
|
|
183
|
+
}
|
|
184
|
+
// Plan mode gate: after first response, ask for approval
|
|
185
|
+
if (planPending) {
|
|
186
|
+
planPending = false;
|
|
187
|
+
const { approved, feedback } = await requestPlanApproval();
|
|
188
|
+
if (!approved) {
|
|
189
|
+
// Always restore original system prompt on rejection to prevent plan prompt leaking
|
|
190
|
+
systemPrompt = config.systemPrompt;
|
|
191
|
+
const msg = feedback
|
|
192
|
+
? `The user rejected the plan with feedback: ${feedback}\nPlease revise your plan.`
|
|
193
|
+
: "The user rejected the plan. Task aborted.";
|
|
194
|
+
if (feedback) {
|
|
195
|
+
// Let the LLM revise — add feedback as user message and continue
|
|
196
|
+
session.messages.push({ role: "user", content: msg });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
// Approved — restore original system prompt and continue with tools enabled
|
|
202
|
+
systemPrompt = config.systemPrompt;
|
|
203
|
+
session.messages.push({ role: "user", content: "Plan approved. Proceed with execution." });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
// If max_tokens, warn user and inject continuation prompt
|
|
207
|
+
if (stopReason === "max_tokens") {
|
|
208
|
+
status("\x1b[33m[response truncated: max_tokens reached, requesting continuation]\x1b[0m\n");
|
|
209
|
+
session.messages.push({ role: "user", content: "Your response was truncated due to length. Please continue where you left off." });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// If no tool use, we're done
|
|
213
|
+
if (stopReason !== "tool_use")
|
|
214
|
+
break;
|
|
215
|
+
// Execute tool calls with concurrency
|
|
216
|
+
const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use");
|
|
217
|
+
// Log all tool calls upfront
|
|
218
|
+
if (hooks?.onToolStart) {
|
|
219
|
+
for (const block of toolUseBlocks)
|
|
220
|
+
hooks.onToolStart(block.name, block.input, toolUseBlocks.length);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
for (const block of toolUseBlocks)
|
|
224
|
+
status(formatToolCall(block.name, block.input) + "\n");
|
|
225
|
+
}
|
|
226
|
+
if (!hooks?.onToolStart)
|
|
227
|
+
spinner.start(`Running ${toolUseBlocks.length} tool${toolUseBlocks.length > 1 ? "s" : ""}...`);
|
|
228
|
+
const execResults = await runToolsConcurrently(toolUseBlocks, registry);
|
|
229
|
+
if (!hooks?.onToolStart)
|
|
230
|
+
spinner.stop();
|
|
231
|
+
const toolResults = [];
|
|
232
|
+
for (const { block, output, is_error } of execResults) {
|
|
233
|
+
session.toolCalls++;
|
|
234
|
+
turnToolCalls++;
|
|
235
|
+
let finalOutput = output;
|
|
236
|
+
// Record for anti-pattern tracking
|
|
237
|
+
session.antiPatterns.recordAttempt(block.name, block.input, !is_error, output);
|
|
238
|
+
// Append phren recovery context on tool errors
|
|
239
|
+
if (is_error && config.phrenCtx) {
|
|
240
|
+
try {
|
|
241
|
+
const recovery = await searchErrorRecovery(config.phrenCtx, output);
|
|
242
|
+
if (recovery)
|
|
243
|
+
finalOutput += recovery;
|
|
244
|
+
}
|
|
245
|
+
catch { /* best effort */ }
|
|
246
|
+
// Auto-capture error patterns
|
|
247
|
+
try {
|
|
248
|
+
await analyzeAndCapture(config.phrenCtx, output, session.captureState);
|
|
249
|
+
}
|
|
250
|
+
catch { /* best effort */ }
|
|
251
|
+
}
|
|
252
|
+
if (hooks?.onToolEnd) {
|
|
253
|
+
hooks.onToolEnd(block.name, block.input, finalOutput, is_error, 0);
|
|
254
|
+
}
|
|
255
|
+
else if (verbose) {
|
|
256
|
+
const preview = finalOutput.slice(0, 200);
|
|
257
|
+
status(`\x1b[2m ← ${is_error ? "ERROR: " : ""}${preview}${finalOutput.length > 200 ? "..." : ""}\x1b[0m\n`);
|
|
258
|
+
}
|
|
259
|
+
toolResults.push({
|
|
260
|
+
type: "tool_result",
|
|
261
|
+
tool_use_id: block.id,
|
|
262
|
+
content: finalOutput,
|
|
263
|
+
is_error,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Post-edit lint/test check
|
|
267
|
+
const mutatingTools = new Set(["edit_file", "write_file"]);
|
|
268
|
+
const hasMutation = toolUseBlocks.some(b => mutatingTools.has(b.name));
|
|
269
|
+
if (hasMutation && config.lintTestConfig) {
|
|
270
|
+
const cwd = process.cwd();
|
|
271
|
+
const lintCmd = config.lintTestConfig.lintCmd ?? detectLintCommand(cwd);
|
|
272
|
+
const testCmd = config.lintTestConfig.testCmd ?? detectTestCommand(cwd);
|
|
273
|
+
const lintFailures = [];
|
|
274
|
+
for (const cmd of [lintCmd, testCmd].filter(Boolean)) {
|
|
275
|
+
const check = runPostEditCheck(cmd, cwd);
|
|
276
|
+
if (!check.passed) {
|
|
277
|
+
if (verbose)
|
|
278
|
+
status(`\x1b[33m[post-edit check failed: ${cmd}]\x1b[0m\n`);
|
|
279
|
+
lintFailures.push(`Post-edit check failed (${cmd}):\n${check.output.slice(0, 2000)}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (lintFailures.length > 0) {
|
|
283
|
+
// Inject as plain text in the tool results user message (not as a fabricated tool_result)
|
|
284
|
+
toolResults.push({
|
|
285
|
+
type: "text",
|
|
286
|
+
text: lintFailures.join("\n\n"),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Create checkpoint before mutating tool results are committed to conversation
|
|
291
|
+
if (hasMutation) {
|
|
292
|
+
createCheckpoint(process.cwd(), `turn-${session.turns}`);
|
|
293
|
+
}
|
|
294
|
+
// Add tool results as a user message
|
|
295
|
+
session.messages.push({ role: "user", content: toolResults });
|
|
296
|
+
// Steering input injection (TUI mid-turn input)
|
|
297
|
+
const steer = hooks?.getSteeringInput?.();
|
|
298
|
+
if (steer) {
|
|
299
|
+
session.messages.push({ role: "user", content: steer });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Extract text from the last assistant message in this turn
|
|
303
|
+
const lastAssistant = [...session.messages].reverse().find((m) => m.role === "assistant");
|
|
304
|
+
let text = "";
|
|
305
|
+
if (lastAssistant && Array.isArray(lastAssistant.content)) {
|
|
306
|
+
text = lastAssistant.content
|
|
307
|
+
.filter((b) => b.type === "text")
|
|
308
|
+
.map((b) => b.text)
|
|
309
|
+
.join("\n");
|
|
310
|
+
}
|
|
311
|
+
else if (lastAssistant && typeof lastAssistant.content === "string") {
|
|
312
|
+
text = lastAssistant.content;
|
|
313
|
+
}
|
|
314
|
+
return { text, turns: session.turns - turnStart, toolCalls: turnToolCalls };
|
|
315
|
+
}
|
|
316
|
+
/** One-shot agent run — thin wrapper around createSession + runTurn. */
|
|
317
|
+
export async function runAgent(task, config) {
|
|
318
|
+
const contextLimit = config.provider.contextWindow ?? 200_000;
|
|
319
|
+
const session = createSession(contextLimit);
|
|
320
|
+
const result = await runTurn(task, session, config);
|
|
321
|
+
return {
|
|
322
|
+
finalText: result.text,
|
|
323
|
+
turns: result.turns,
|
|
324
|
+
toolCalls: result.toolCalls,
|
|
325
|
+
totalCost: config.costTracker?.formatCost(),
|
|
326
|
+
messages: session.messages,
|
|
327
|
+
};
|
|
328
|
+
}
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
function isGitRepo(cwd) {
|
|
6
|
+
try {
|
|
7
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
8
|
+
cwd,
|
|
9
|
+
encoding: "utf-8",
|
|
10
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
11
|
+
});
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function storeFile(_cwd) {
|
|
19
|
+
const dir = path.join(os.homedir(), ".phren-agent");
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
return path.join(dir, "checkpoints.json");
|
|
22
|
+
}
|
|
23
|
+
function loadStore(cwd) {
|
|
24
|
+
const file = storeFile(cwd);
|
|
25
|
+
if (fs.existsSync(file)) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore corrupt */ }
|
|
30
|
+
}
|
|
31
|
+
return { checkpoints: [] };
|
|
32
|
+
}
|
|
33
|
+
function saveStore(cwd, store) {
|
|
34
|
+
fs.writeFileSync(storeFile(cwd), JSON.stringify(store, null, 2) + "\n");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a checkpoint via `git stash create`. Returns the ref or null if
|
|
38
|
+
* the working tree is clean (stash create produces no output when clean).
|
|
39
|
+
*/
|
|
40
|
+
export function createCheckpoint(cwd, label) {
|
|
41
|
+
if (!isGitRepo(cwd))
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const ref = execFileSync("git", ["stash", "create"], {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: "utf-8",
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
}).trim();
|
|
49
|
+
if (!ref)
|
|
50
|
+
return null; // clean working tree
|
|
51
|
+
// Store the ref so `git gc` won't collect it
|
|
52
|
+
execFileSync("git", ["stash", "store", "-m", label || "phren-checkpoint", ref], {
|
|
53
|
+
cwd,
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
});
|
|
57
|
+
const store = loadStore(cwd);
|
|
58
|
+
store.checkpoints.push({
|
|
59
|
+
ref,
|
|
60
|
+
label: label || `checkpoint-${store.checkpoints.length + 1}`,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
saveStore(cwd, store);
|
|
64
|
+
return ref;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Rollback to a checkpoint by discarding current changes and applying the stash. */
|
|
71
|
+
export function rollbackToCheckpoint(cwd, ref) {
|
|
72
|
+
if (!isGitRepo(cwd))
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
// Discard current working tree changes
|
|
76
|
+
execFileSync("git", ["checkout", "."], {
|
|
77
|
+
cwd,
|
|
78
|
+
encoding: "utf-8",
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
});
|
|
81
|
+
// Apply the stash ref
|
|
82
|
+
execFileSync("git", ["stash", "apply", ref], {
|
|
83
|
+
cwd,
|
|
84
|
+
encoding: "utf-8",
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
});
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** List stored checkpoints. */
|
|
94
|
+
export function listCheckpoints(cwd) {
|
|
95
|
+
return loadStore(cwd).checkpoints;
|
|
96
|
+
}
|
|
97
|
+
/** Get the latest checkpoint ref. */
|
|
98
|
+
export function getLatestCheckpoint(cwd) {
|
|
99
|
+
const store = loadStore(cwd);
|
|
100
|
+
return store.checkpoints.length > 0
|
|
101
|
+
? store.checkpoints[store.checkpoints.length - 1].ref
|
|
102
|
+
: null;
|
|
103
|
+
}
|