|
1 | 1 | import fs from 'fs'; |
| 2 | +import os from 'os'; |
2 | 3 | import path from 'path'; |
3 | 4 |
|
4 | 5 | import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; |
@@ -188,47 +189,120 @@ const postToolUseHook: HookCallback = async () => { |
188 | 189 | return { continue: true }; |
189 | 190 | }; |
190 | 191 |
|
| 192 | +/** |
| 193 | + * Read a Claude transcript .jsonl, render a markdown summary, and drop it into |
| 194 | + * the agent's `conversations/` folder so context survives a compaction or a |
| 195 | + * session rotation. Best-effort: returns false (and logs) on any failure. |
| 196 | + */ |
| 197 | +function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: string | undefined, assistantName?: string): boolean { |
| 198 | + if (!transcriptPath || !fs.existsSync(transcriptPath)) { |
| 199 | + log('No transcript found for archiving'); |
| 200 | + return false; |
| 201 | + } |
| 202 | + |
| 203 | + try { |
| 204 | + const content = fs.readFileSync(transcriptPath, 'utf-8'); |
| 205 | + const messages = parseTranscript(content); |
| 206 | + if (messages.length === 0) return false; |
| 207 | + |
| 208 | + // Try to get summary from sessions index |
| 209 | + let summary: string | undefined; |
| 210 | + const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json'); |
| 211 | + if (fs.existsSync(indexPath)) { |
| 212 | + try { |
| 213 | + const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); |
| 214 | + summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary; |
| 215 | + } catch { |
| 216 | + /* ignore */ |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + const name = summary |
| 221 | + ? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) |
| 222 | + : `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`; |
| 223 | + |
| 224 | + const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations'; |
| 225 | + fs.mkdirSync(conversationsDir, { recursive: true }); |
| 226 | + const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; |
| 227 | + fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); |
| 228 | + log(`Archived conversation to ${filename}`); |
| 229 | + return true; |
| 230 | + } catch (err) { |
| 231 | + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); |
| 232 | + return false; |
| 233 | + } |
| 234 | +} |
| 235 | + |
191 | 236 | function createPreCompactHook(assistantName?: string): HookCallback { |
192 | 237 | return async (input) => { |
193 | 238 | const preCompact = input as PreCompactHookInput; |
194 | | - const { transcript_path: transcriptPath, session_id: sessionId } = preCompact; |
| 239 | + archiveTranscriptFile(preCompact.transcript_path, preCompact.session_id, assistantName); |
| 240 | + return {}; |
| 241 | + }; |
| 242 | +} |
195 | 243 |
|
196 | | - if (!transcriptPath || !fs.existsSync(transcriptPath)) { |
197 | | - log('No transcript found for archiving'); |
198 | | - return {}; |
199 | | - } |
| 244 | +// ── Continuation rotation (cold-resume guard) ── |
200 | 245 |
|
201 | | - try { |
202 | | - const content = fs.readFileSync(transcriptPath, 'utf-8'); |
203 | | - const messages = parseTranscript(content); |
204 | | - if (messages.length === 0) return {}; |
205 | | - |
206 | | - // Try to get summary from sessions index |
207 | | - let summary: string | undefined; |
208 | | - const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json'); |
209 | | - if (fs.existsSync(indexPath)) { |
210 | | - try { |
211 | | - const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); |
212 | | - summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary; |
213 | | - } catch { |
214 | | - /* ignore */ |
215 | | - } |
216 | | - } |
| 246 | +/** |
| 247 | + * Resume cost is dominated by transcript size. Past this many bytes a fresh |
| 248 | + * cold container can't reload the .jsonl before the host's 30-min idle ceiling |
| 249 | + * fires, so the session is dropped and started clean. Operator-overridable. |
| 250 | + */ |
| 251 | +function transcriptRotateBytes(): number { |
| 252 | + return Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES) || 12 * 1024 * 1024; |
| 253 | +} |
217 | 254 |
|
218 | | - const name = summary |
219 | | - ? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) |
220 | | - : `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`; |
| 255 | +/** |
| 256 | + * Secondary age trigger, measured from the transcript's first entry. 0 (or a |
| 257 | + * non-positive value) disables the age check; size alone then governs. |
| 258 | + */ |
| 259 | +function transcriptRotateAgeMs(): number { |
| 260 | + const days = Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS); |
| 261 | + return Number.isFinite(days) && days > 0 ? days * 86_400_000 : 14 * 86_400_000; |
| 262 | +} |
221 | 263 |
|
222 | | - const conversationsDir = '/workspace/agent/conversations'; |
223 | | - fs.mkdirSync(conversationsDir, { recursive: true }); |
224 | | - const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; |
225 | | - fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); |
226 | | - log(`Archived conversation to ${filename}`); |
227 | | - } catch (err) { |
228 | | - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); |
| 264 | +function claudeProjectsDir(): string { |
| 265 | + const base = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || os.homedir(), '.claude'); |
| 266 | + return path.join(base, 'projects'); |
| 267 | +} |
| 268 | + |
| 269 | +/** |
| 270 | + * Locate the .jsonl backing a session id. The SDK names project dirs by a |
| 271 | + * mangled cwd; rather than reproduce that convention we scan project dirs for |
| 272 | + * `<sessionId>.jsonl` (session ids are UUIDs, so this is unambiguous). |
| 273 | + */ |
| 274 | +function findTranscriptPath(sessionId: string): string | null { |
| 275 | + const projects = claudeProjectsDir(); |
| 276 | + let dirs: string[]; |
| 277 | + try { |
| 278 | + dirs = fs.readdirSync(projects); |
| 279 | + } catch { |
| 280 | + return null; |
| 281 | + } |
| 282 | + for (const dir of dirs) { |
| 283 | + const candidate = path.join(projects, dir, `${sessionId}.jsonl`); |
| 284 | + if (fs.existsSync(candidate)) return candidate; |
| 285 | + } |
| 286 | + return null; |
| 287 | +} |
| 288 | + |
| 289 | +/** Epoch-ms of the first transcript entry, or null if unreadable. */ |
| 290 | +function transcriptStartMs(transcriptPath: string): number | null { |
| 291 | + try { |
| 292 | + const fd = fs.openSync(transcriptPath, 'r'); |
| 293 | + try { |
| 294 | + const buf = Buffer.alloc(4096); |
| 295 | + const n = fs.readSync(fd, buf, 0, buf.length, 0); |
| 296 | + const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0]; |
| 297 | + const ts = JSON.parse(firstLine)?.timestamp; |
| 298 | + const ms = ts ? Date.parse(ts) : NaN; |
| 299 | + return Number.isNaN(ms) ? null : ms; |
| 300 | + } finally { |
| 301 | + fs.closeSync(fd); |
229 | 302 | } |
230 | | - return {}; |
231 | | - }; |
| 303 | + } catch { |
| 304 | + return null; |
| 305 | + } |
232 | 306 | } |
233 | 307 |
|
234 | 308 | // ── Provider ── |
@@ -277,6 +351,41 @@ export class ClaudeProvider implements AgentProvider { |
277 | 351 | return STALE_SESSION_RE.test(msg); |
278 | 352 | } |
279 | 353 |
|
| 354 | + maybeRotateContinuation(continuation: string): string | null { |
| 355 | + const transcriptPath = findTranscriptPath(continuation); |
| 356 | + if (!transcriptPath) return null; |
| 357 | + |
| 358 | + let size: number; |
| 359 | + try { |
| 360 | + size = fs.statSync(transcriptPath).size; |
| 361 | + } catch { |
| 362 | + return null; |
| 363 | + } |
| 364 | + |
| 365 | + const maxBytes = transcriptRotateBytes(); |
| 366 | + const startMs = transcriptStartMs(transcriptPath); |
| 367 | + const ageMs = startMs === null ? 0 : Date.now() - startMs; |
| 368 | + const maxAgeMs = transcriptRotateAgeMs(); |
| 369 | + |
| 370 | + let reason: string | null = null; |
| 371 | + if (size > maxBytes) { |
| 372 | + reason = `transcript ${(size / 1_048_576).toFixed(1)}MB > ${(maxBytes / 1_048_576).toFixed(0)}MB cap`; |
| 373 | + } else if (startMs !== null && ageMs > maxAgeMs) { |
| 374 | + reason = `transcript ${(ageMs / 86_400_000).toFixed(1)}d old > ${(maxAgeMs / 86_400_000).toFixed(0)}d cap`; |
| 375 | + } |
| 376 | + if (!reason) return null; |
| 377 | + |
| 378 | + // Preserve a readable summary, then move the heavy .jsonl out of the |
| 379 | + // resume path so the SDK starts a fresh session and the disk is reclaimed. |
| 380 | + archiveTranscriptFile(transcriptPath, continuation, this.assistantName); |
| 381 | + try { |
| 382 | + fs.renameSync(transcriptPath, `${transcriptPath}.rotated-${Date.now()}`); |
| 383 | + } catch (err) { |
| 384 | + log(`Failed to move rotated transcript aside: ${err instanceof Error ? err.message : String(err)}`); |
| 385 | + } |
| 386 | + return reason; |
| 387 | + } |
| 388 | + |
280 | 389 | query(input: QueryInput): AgentQuery { |
281 | 390 | const stream = new MessageStream(); |
282 | 391 | stream.push(input.prompt); |
|
0 commit comments