s-app-scoder-agent 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +501 -0
- package/dist/build.js +41 -0
- package/dist/config.js +117 -0
- package/dist/constants.js +69 -0
- package/dist/context.js +44 -0
- package/dist/cost.js +141 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +314 -0
- package/dist/llm.js +276 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +298 -0
- package/dist/provider-anthropic.js +332 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +361 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +51 -4
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { LlmError, LLM_REQUEST_TIMEOUT_MS, combinedSignal } from "./llm.js";
|
|
2
|
+
import { withRetry } from "./retry.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Translation: OpenAI-style -> Anthropic
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export function toAnthropicMessages(messages) {
|
|
7
|
+
const systemParts = [];
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const m of messages) {
|
|
10
|
+
if (m.role === "system") {
|
|
11
|
+
if (m.content)
|
|
12
|
+
systemParts.push(m.content);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (m.role === "tool") {
|
|
16
|
+
// Tool results become user tool_result blocks; consecutive tool results
|
|
17
|
+
// are merged into one user message (Anthropic requirement).
|
|
18
|
+
const block = {
|
|
19
|
+
type: "tool_result",
|
|
20
|
+
tool_use_id: m.tool_call_id ?? "",
|
|
21
|
+
content: m.content ?? "",
|
|
22
|
+
};
|
|
23
|
+
const last = out[out.length - 1];
|
|
24
|
+
if (last && last.role === "user" && Array.isArray(last.content)) {
|
|
25
|
+
last.content.push(block);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
out.push({ role: "user", content: [block] });
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (m.role === "assistant") {
|
|
33
|
+
const blocks = [];
|
|
34
|
+
if (m.content)
|
|
35
|
+
blocks.push({ type: "text", text: m.content });
|
|
36
|
+
for (const tc of m.tool_calls ?? []) {
|
|
37
|
+
let input;
|
|
38
|
+
try {
|
|
39
|
+
input = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
input = {};
|
|
43
|
+
}
|
|
44
|
+
blocks.push({ type: "tool_use", id: tc.id || `call_${tc.name}`, name: tc.name, input });
|
|
45
|
+
}
|
|
46
|
+
out.push({
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content: blocks.length > 0 ? blocks : (m.content ?? ""),
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// user
|
|
53
|
+
out.push({ role: "user", content: m.content ?? "" });
|
|
54
|
+
}
|
|
55
|
+
return { system: systemParts.join("\n\n") || undefined, messages: out };
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Translation: Anthropic -> OpenAI-style ChatResult
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function fromAnthropicResponse(data) {
|
|
61
|
+
let text = "";
|
|
62
|
+
const toolCalls = [];
|
|
63
|
+
for (const block of data.content ?? []) {
|
|
64
|
+
if (block.type === "text") {
|
|
65
|
+
text = text ? `${text}\n${block.text ?? ""}` : (block.text ?? "");
|
|
66
|
+
}
|
|
67
|
+
else if (block.type === "tool_use") {
|
|
68
|
+
toolCalls.push({
|
|
69
|
+
id: block.id ?? "",
|
|
70
|
+
name: block.name ?? "",
|
|
71
|
+
arguments: JSON.stringify(block.input ?? {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const usage = {
|
|
76
|
+
prompt_tokens: data.usage?.input_tokens,
|
|
77
|
+
completion_tokens: data.usage?.output_tokens,
|
|
78
|
+
};
|
|
79
|
+
if (typeof data.usage?.cache_read_input_tokens === "number") {
|
|
80
|
+
usage.cached_tokens = data.usage.cache_read_input_tokens;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
message: {
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: text.length > 0 ? text : null,
|
|
86
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
87
|
+
},
|
|
88
|
+
usage,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// AnthropicClient — same surface as LlmClient
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
95
|
+
export class AnthropicClient {
|
|
96
|
+
opts;
|
|
97
|
+
constructor(opts) {
|
|
98
|
+
this.opts = opts;
|
|
99
|
+
}
|
|
100
|
+
get model() {
|
|
101
|
+
return this.opts.model;
|
|
102
|
+
}
|
|
103
|
+
async chat(messages, tools, opts = {}) {
|
|
104
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
|
|
105
|
+
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
106
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
107
|
+
const body = JSON.stringify({
|
|
108
|
+
model: this.opts.model,
|
|
109
|
+
max_tokens: this.opts.maxTokens,
|
|
110
|
+
temperature: this.opts.temperature,
|
|
111
|
+
system,
|
|
112
|
+
messages: translated,
|
|
113
|
+
tools: tools.length > 0
|
|
114
|
+
? tools.map((t) => ({
|
|
115
|
+
name: t.function.name,
|
|
116
|
+
description: t.function.description,
|
|
117
|
+
input_schema: t.function.parameters,
|
|
118
|
+
}))
|
|
119
|
+
: undefined,
|
|
120
|
+
});
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await withRetry(async () => {
|
|
124
|
+
const r = await fetch(url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
"x-api-key": this.opts.apiKey,
|
|
129
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
130
|
+
},
|
|
131
|
+
body,
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
if (!r.ok)
|
|
135
|
+
throw await toAnthropicError(r, url);
|
|
136
|
+
return r;
|
|
137
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ Anthropic request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
cleanup();
|
|
141
|
+
}
|
|
142
|
+
const data = (await res.json());
|
|
143
|
+
if (data.error) {
|
|
144
|
+
throw new LlmError(`Anthropic API error: ${data.error.message ?? "unknown"}`);
|
|
145
|
+
}
|
|
146
|
+
return fromAnthropicResponse(data);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Streaming: parse Anthropic SSE events (message_start, content_block_delta,
|
|
150
|
+
* message_delta, message_stop) and translate them to StreamChunks:
|
|
151
|
+
* - text_delta -> contentDelta
|
|
152
|
+
* - tool_use + input_json_delta -> toolCallDelta accumulation
|
|
153
|
+
* - message_delta stop_reason/usage -> final ChatResult
|
|
154
|
+
*/
|
|
155
|
+
async *streamChat(messages, tools, opts = {}) {
|
|
156
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/messages`;
|
|
157
|
+
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
158
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
159
|
+
const body = JSON.stringify({
|
|
160
|
+
model: this.opts.model,
|
|
161
|
+
max_tokens: this.opts.maxTokens,
|
|
162
|
+
temperature: this.opts.temperature,
|
|
163
|
+
system,
|
|
164
|
+
messages: translated,
|
|
165
|
+
stream: true,
|
|
166
|
+
tools: tools.length > 0
|
|
167
|
+
? tools.map((t) => ({
|
|
168
|
+
name: t.function.name,
|
|
169
|
+
description: t.function.description,
|
|
170
|
+
input_schema: t.function.parameters,
|
|
171
|
+
}))
|
|
172
|
+
: undefined,
|
|
173
|
+
});
|
|
174
|
+
let res;
|
|
175
|
+
try {
|
|
176
|
+
res = await withRetry(async () => {
|
|
177
|
+
const r = await fetch(url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
"x-api-key": this.opts.apiKey,
|
|
182
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
183
|
+
},
|
|
184
|
+
body,
|
|
185
|
+
signal,
|
|
186
|
+
});
|
|
187
|
+
if (!r.ok)
|
|
188
|
+
throw await toAnthropicError(r, url);
|
|
189
|
+
return r;
|
|
190
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ Anthropic stream request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s...`));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
cleanup(); // don't leak the 120s request timer — it would hang the process
|
|
194
|
+
throw new LlmError(`Anthropic stream fetch error after retries at ${url}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
if (!res.body) {
|
|
197
|
+
throw new LlmError("Anthropic stream: response body is not readable");
|
|
198
|
+
}
|
|
199
|
+
// Accumulators — text content + tool_use blocks (by block index)
|
|
200
|
+
let text = "";
|
|
201
|
+
const toolBlocks = new Map();
|
|
202
|
+
let stopReason;
|
|
203
|
+
let usage;
|
|
204
|
+
let sawStop = false;
|
|
205
|
+
const reader = res.body.getReader();
|
|
206
|
+
const decoder = new TextDecoder();
|
|
207
|
+
let buffer = "";
|
|
208
|
+
const handleEvent = (raw) => {
|
|
209
|
+
let ev;
|
|
210
|
+
try {
|
|
211
|
+
ev = JSON.parse(raw);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (ev.type === "message_start") {
|
|
217
|
+
// Initial usage (input tokens) arrives on message_start
|
|
218
|
+
if (ev.message?.usage) {
|
|
219
|
+
usage = {
|
|
220
|
+
prompt_tokens: ev.message.usage.input_tokens,
|
|
221
|
+
completion_tokens: ev.message.usage.output_tokens,
|
|
222
|
+
};
|
|
223
|
+
if (typeof ev.message.usage.cache_read_input_tokens === "number") {
|
|
224
|
+
usage.cached_tokens = ev.message.usage.cache_read_input_tokens;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (ev.type === "content_block_start" && ev.content_block?.type === "tool_use") {
|
|
230
|
+
toolBlocks.set(ev.index ?? 0, {
|
|
231
|
+
id: ev.content_block.id ?? "",
|
|
232
|
+
name: ev.content_block.name ?? "",
|
|
233
|
+
json: "",
|
|
234
|
+
});
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
if (ev.type === "content_block_delta") {
|
|
238
|
+
if (ev.delta?.type === "text_delta" && ev.delta.text) {
|
|
239
|
+
text += ev.delta.text;
|
|
240
|
+
return { contentDelta: ev.delta.text, done: false };
|
|
241
|
+
}
|
|
242
|
+
if (ev.delta?.type === "input_json_delta" && ev.delta.partial_json) {
|
|
243
|
+
const block = toolBlocks.get(ev.index ?? 0);
|
|
244
|
+
if (block)
|
|
245
|
+
block.json += ev.delta.partial_json;
|
|
246
|
+
return {
|
|
247
|
+
toolCallDelta: { index: ev.index ?? 0, argumentsDelta: ev.delta.partial_json },
|
|
248
|
+
done: false,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
if (ev.type === "message_delta") {
|
|
254
|
+
stopReason = ev.delta?.stop_reason ?? stopReason;
|
|
255
|
+
// Merge: message_delta usage only carries output_tokens
|
|
256
|
+
if (ev.usage) {
|
|
257
|
+
usage = {
|
|
258
|
+
...usage,
|
|
259
|
+
prompt_tokens: usage?.prompt_tokens ?? ev.usage.input_tokens,
|
|
260
|
+
completion_tokens: ev.usage.output_tokens ?? usage?.completion_tokens,
|
|
261
|
+
};
|
|
262
|
+
if (usage?.cached_tokens === undefined && typeof ev.usage.cache_read_input_tokens === "number") {
|
|
263
|
+
usage = { ...usage, cached_tokens: ev.usage.cache_read_input_tokens };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (ev.type === "message_stop") {
|
|
269
|
+
sawStop = true;
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
};
|
|
274
|
+
let finalResult;
|
|
275
|
+
try {
|
|
276
|
+
while (true) {
|
|
277
|
+
const { done, value } = await reader.read();
|
|
278
|
+
if (done)
|
|
279
|
+
break;
|
|
280
|
+
buffer += decoder.decode(value, { stream: true });
|
|
281
|
+
const lines = buffer.split("\n");
|
|
282
|
+
buffer = lines.pop() ?? "";
|
|
283
|
+
for (const rawLine of lines) {
|
|
284
|
+
const line = rawLine.trim();
|
|
285
|
+
if (!line.startsWith("data:"))
|
|
286
|
+
continue;
|
|
287
|
+
const payload = line.slice(5).trim();
|
|
288
|
+
if (!payload)
|
|
289
|
+
continue;
|
|
290
|
+
const chunk = handleEvent(payload);
|
|
291
|
+
if (chunk)
|
|
292
|
+
yield chunk;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (sawStop) {
|
|
296
|
+
const toolCalls = [...toolBlocks.values()]
|
|
297
|
+
.filter((b) => b.name)
|
|
298
|
+
.map((b) => ({ id: b.id, name: b.name, arguments: b.json || "{}" }));
|
|
299
|
+
finalResult = {
|
|
300
|
+
message: {
|
|
301
|
+
role: "assistant",
|
|
302
|
+
content: text.length > 0 ? text : null,
|
|
303
|
+
stop_reason: stopReason,
|
|
304
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
305
|
+
},
|
|
306
|
+
usage,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
try {
|
|
312
|
+
reader.cancel();
|
|
313
|
+
}
|
|
314
|
+
catch { /* already released */ }
|
|
315
|
+
reader.releaseLock();
|
|
316
|
+
cleanup();
|
|
317
|
+
}
|
|
318
|
+
yield { done: true, final: finalResult };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function toAnthropicError(res, url) {
|
|
322
|
+
let body = await res.text().catch(() => "");
|
|
323
|
+
body = body.replace(/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***");
|
|
324
|
+
const err = new LlmError(`Anthropic API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
|
|
325
|
+
const retryAfterHeader = res.headers.get("retry-after");
|
|
326
|
+
if (retryAfterHeader != null && retryAfterHeader !== "") {
|
|
327
|
+
const sec = Number(retryAfterHeader);
|
|
328
|
+
if (Number.isFinite(sec) && sec > 0)
|
|
329
|
+
err.retryAfterMs = sec * 1000;
|
|
330
|
+
}
|
|
331
|
+
return err;
|
|
332
|
+
}
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry with exponential backoff + jitter for transient failures.
|
|
3
|
+
*/
|
|
4
|
+
export class NonRetryableError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** HTTP statuses worth retrying (transient). */
|
|
7
|
+
export function isRetryableStatus(status) {
|
|
8
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
9
|
+
}
|
|
10
|
+
/** Network-level errors worth retrying. */
|
|
11
|
+
export function isRetryableNetworkError(err) {
|
|
12
|
+
const msg = String(err?.message ?? err ?? "");
|
|
13
|
+
return (/ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|socket hang up|network|fetch failed/i.test(msg));
|
|
14
|
+
}
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Run fn with retries. Retries on retryable HTTP statuses (thrown as errors
|
|
20
|
+
* carrying `.status`) and network errors. Client errors (4xx except 429)
|
|
21
|
+
* fail immediately via NonRetryableError semantics.
|
|
22
|
+
*/
|
|
23
|
+
export async function withRetry(fn, opts = {}, onRetry) {
|
|
24
|
+
const maxRetries = opts.maxRetries ?? 3;
|
|
25
|
+
const baseDelayMs = opts.baseDelayMs ?? 1000;
|
|
26
|
+
const maxDelayMs = opts.maxDelayMs ?? 30_000;
|
|
27
|
+
let lastErr;
|
|
28
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
lastErr = err;
|
|
34
|
+
const status = err.status;
|
|
35
|
+
const retryable = (typeof status === "number" && isRetryableStatus(status)) ||
|
|
36
|
+
(!(err instanceof NonRetryableError) && isRetryableNetworkError(err));
|
|
37
|
+
// Client errors (except 429) — fail immediately
|
|
38
|
+
if (typeof status === "number" && status >= 400 && status < 500 && status !== 429) {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
if (!retryable || attempt === maxRetries) {
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
// Respect Retry-After header if present (seconds)
|
|
45
|
+
const retryAfter = err.retryAfterMs;
|
|
46
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
47
|
+
const delay = Math.min(retryAfter ?? baseDelayMs * 2 ** attempt + jitter, maxDelayMs);
|
|
48
|
+
onRetry?.(attempt + 1, delay, status ? `HTTP ${status}` : String(err.message).slice(0, 80));
|
|
49
|
+
await sleep(delay);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw lastErr; // unreachable
|
|
53
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence: save agent state (messages, steps, build) to
|
|
3
|
+
* <workdir>/.scoder/sessions/<id>.json after every step so runs can be resumed.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, writeFileSync, renameSync, readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
const SESSIONS_DIR = ".scoder/sessions";
|
|
8
|
+
export function newSessionId(now = new Date()) {
|
|
9
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
10
|
+
const y = now.getFullYear();
|
|
11
|
+
const mo = pad(now.getMonth() + 1);
|
|
12
|
+
const d = pad(now.getDate());
|
|
13
|
+
const h = pad(now.getHours());
|
|
14
|
+
const mi = pad(now.getMinutes());
|
|
15
|
+
const s = pad(now.getSeconds());
|
|
16
|
+
const rand = Math.floor(Math.random() * 0xffff).toString(16).padStart(4, "0");
|
|
17
|
+
return `${y}${mo}${d}-${h}${mi}${s}-${rand}`;
|
|
18
|
+
}
|
|
19
|
+
function sessionsDir(workdir) {
|
|
20
|
+
return resolve(workdir, SESSIONS_DIR);
|
|
21
|
+
}
|
|
22
|
+
/** Atomic write: write to .tmp then rename. */
|
|
23
|
+
function atomicWrite(path, content) {
|
|
24
|
+
const tmp = `${path}.tmp`;
|
|
25
|
+
writeFileSync(tmp, content, "utf8");
|
|
26
|
+
renameSync(tmp, path);
|
|
27
|
+
}
|
|
28
|
+
export class SessionStore {
|
|
29
|
+
id;
|
|
30
|
+
filePath;
|
|
31
|
+
enabled;
|
|
32
|
+
_workdir;
|
|
33
|
+
constructor(workdir, enabled, id = newSessionId()) {
|
|
34
|
+
this.id = id;
|
|
35
|
+
this._workdir = workdir;
|
|
36
|
+
this.enabled = enabled;
|
|
37
|
+
this.filePath = join(sessionsDir(workdir), `${id}.json`);
|
|
38
|
+
}
|
|
39
|
+
save(data, createdAt) {
|
|
40
|
+
if (!this.enabled)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(sessionsDir(this._workdir), { recursive: true });
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
const full = {
|
|
46
|
+
...data,
|
|
47
|
+
id: this.id,
|
|
48
|
+
createdAt: createdAt ?? now,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
};
|
|
51
|
+
atomicWrite(this.filePath, JSON.stringify(full, null, 2));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
// Never crash the agent because of a logging/persistence failure
|
|
55
|
+
console.error(`⚠ session save failed: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Load / list / prune
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
export function loadSession(workdir, id) {
|
|
63
|
+
const path = join(sessionsDir(workdir), `${id}.json`);
|
|
64
|
+
if (!existsSync(path))
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function listSessions(workdir) {
|
|
74
|
+
const dir = sessionsDir(workdir);
|
|
75
|
+
if (!existsSync(dir))
|
|
76
|
+
return [];
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const f of readdirSync(dir).sort().reverse()) {
|
|
79
|
+
if (!f.endsWith(".json"))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
83
|
+
out.push({
|
|
84
|
+
id: s.id,
|
|
85
|
+
task: (s.task ?? "").slice(0, 60),
|
|
86
|
+
steps: s.steps ?? 0,
|
|
87
|
+
updatedAt: s.updatedAt ?? "",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// skip malformed
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Delete session files older than `days` (default 7).
|
|
98
|
+
* Completed sessions (lastBuild.success === true) are removed; sessions that
|
|
99
|
+
* did NOT finish successfully are kept longer — they may still be worth
|
|
100
|
+
* resuming. Returns count removed.
|
|
101
|
+
*/
|
|
102
|
+
export function pruneSessions(workdir, days = 7) {
|
|
103
|
+
const dir = sessionsDir(workdir);
|
|
104
|
+
if (!existsSync(dir))
|
|
105
|
+
return 0;
|
|
106
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
107
|
+
let removed = 0;
|
|
108
|
+
for (const f of readdirSync(dir)) {
|
|
109
|
+
if (!f.endsWith(".json"))
|
|
110
|
+
continue;
|
|
111
|
+
const full = join(dir, f);
|
|
112
|
+
try {
|
|
113
|
+
const st = statSync(full);
|
|
114
|
+
if (st.mtimeMs >= cutoff)
|
|
115
|
+
continue;
|
|
116
|
+
const s = JSON.parse(readFileSync(full, "utf8"));
|
|
117
|
+
const completed = s.lastBuild?.success === true;
|
|
118
|
+
if (completed) {
|
|
119
|
+
rmSync(full);
|
|
120
|
+
removed++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// malformed JSON — prune it (unreadable session is worthless)
|
|
125
|
+
try {
|
|
126
|
+
rmSync(full);
|
|
127
|
+
removed++;
|
|
128
|
+
}
|
|
129
|
+
catch { /* ignore */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return removed;
|
|
133
|
+
}
|
|
134
|
+
/** Resolve "last" or an explicit id to a SessionFile, or null. */
|
|
135
|
+
export function resolveSessionId(workdir, idOrLast) {
|
|
136
|
+
if (idOrLast === "last") {
|
|
137
|
+
const all = listSessions(workdir);
|
|
138
|
+
if (all.length === 0)
|
|
139
|
+
return null;
|
|
140
|
+
return loadSession(workdir, all[0].id);
|
|
141
|
+
}
|
|
142
|
+
return loadSession(workdir, idOrLast);
|
|
143
|
+
}
|