@cleocode/core 2026.4.98 → 2026.4.99
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/gc/daemon-entry.d.ts +15 -0
- package/dist/gc/daemon-entry.d.ts.map +1 -0
- package/dist/gc/daemon.d.ts +71 -0
- package/dist/gc/daemon.d.ts.map +1 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -0
- package/dist/sentient/daemon-entry.d.ts +11 -0
- package/dist/sentient/daemon-entry.d.ts.map +1 -0
- package/dist/sentient/daemon.d.ts +160 -0
- package/dist/sentient/daemon.d.ts.map +1 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
- package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
- package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
- package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
- package/dist/sentient/propose-tick.d.ts +105 -0
- package/dist/sentient/propose-tick.d.ts.map +1 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -0
- package/package.json +76 -8
- package/src/gc/__tests__/runner.test.ts +367 -0
- package/src/gc/__tests__/state.test.ts +169 -0
- package/src/gc/__tests__/transcript.test.ts +371 -0
- package/src/gc/daemon-entry.ts +26 -0
- package/src/gc/daemon.ts +251 -0
- package/src/gc/index.ts +14 -0
- package/src/gc/runner.ts +378 -0
- package/src/gc/state.ts +140 -0
- package/src/gc/transcript.ts +380 -0
- package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
- package/src/sentient/__tests__/daemon.test.ts +472 -0
- package/src/sentient/__tests__/dream-tick.test.ts +200 -0
- package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
- package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
- package/src/sentient/__tests__/propose-tick.test.ts +296 -0
- package/src/sentient/__tests__/test-ingester.test.ts +104 -0
- package/src/sentient/daemon-entry.ts +20 -0
- package/src/sentient/daemon.ts +471 -0
- package/src/sentient/index.ts +18 -0
- package/src/sentient/ingesters/brain-ingester.ts +122 -0
- package/src/sentient/ingesters/nexus-ingester.ts +171 -0
- package/src/sentient/ingesters/test-ingester.ts +205 -0
- package/src/sentient/proposal-rate-limiter.ts +172 -0
- package/src/sentient/propose-tick.ts +415 -0
- package/src/sentient/state.ts +229 -0
- package/src/sentient/tick.ts +688 -0
package/src/gc/daemon.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC Daemon — Sidecar background process for autonomous transcript cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (Pattern B from T751 §2.2):
|
|
5
|
+
* - Spawned via `cleo daemon start` as a detached Node.js process
|
|
6
|
+
* - All three required flags: `detached: true`, file stdio, `child.unref()`
|
|
7
|
+
* - Persists across CLI invocations
|
|
8
|
+
* - Crash recovery via `.cleo/gc-state.json` startup-check
|
|
9
|
+
* - node-cron v4 for scheduling (zero runtime deps, cross-platform)
|
|
10
|
+
*
|
|
11
|
+
* Startup algorithm (systemd `Persistent=true` semantics in pure Node.js):
|
|
12
|
+
* 1. Read gc-state.json
|
|
13
|
+
* 2. If pendingPrune non-empty → resume deletion (crash recovery)
|
|
14
|
+
* 3. If lastRunAt null OR elapsed > 24h → run GC immediately (missed-run recovery)
|
|
15
|
+
* 4. Schedule future runs via node-cron (daily at 03:00 UTC)
|
|
16
|
+
* 5. Write daemonPid to state
|
|
17
|
+
*
|
|
18
|
+
* @see ADR-047 — Autonomous GC and Disk Safety
|
|
19
|
+
* @see T751 §2.2 for sidecar daemon pattern rationale
|
|
20
|
+
* @task T731
|
|
21
|
+
* @epic T726
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { createWriteStream } from 'node:fs';
|
|
26
|
+
import { mkdir } from 'node:fs/promises';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import cron from 'node-cron';
|
|
30
|
+
import { runGC } from './runner.js';
|
|
31
|
+
import { patchGCState, readGCState } from './state.js';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Constants
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** Cron expression: daily at 03:00 UTC. */
|
|
38
|
+
const GC_CRON_EXPR = '0 3 * * *';
|
|
39
|
+
|
|
40
|
+
/** Interval for missed-run recovery check (24 hours in ms). */
|
|
41
|
+
const GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Daemon Bootstrap (runs when this module is executed as a standalone script)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bootstrap the GC daemon process.
|
|
49
|
+
*
|
|
50
|
+
* Performs crash recovery, missed-run recovery, and schedules future GC runs.
|
|
51
|
+
* This function runs in the long-lived daemon process.
|
|
52
|
+
*
|
|
53
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
54
|
+
*/
|
|
55
|
+
export async function bootstrapDaemon(cleoDir: string): Promise<void> {
|
|
56
|
+
const statePath = join(cleoDir, 'gc-state.json');
|
|
57
|
+
|
|
58
|
+
// Register daemon PID in state file
|
|
59
|
+
await patchGCState(statePath, {
|
|
60
|
+
daemonPid: process.pid,
|
|
61
|
+
daemonStartedAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const state = await readGCState(statePath);
|
|
65
|
+
|
|
66
|
+
// Step 1: Crash recovery — resume pending prune from prior run
|
|
67
|
+
if (state.pendingPrune && state.pendingPrune.length > 0) {
|
|
68
|
+
try {
|
|
69
|
+
await runGC({ cleoDir, resumeFrom: state.pendingPrune });
|
|
70
|
+
} catch {
|
|
71
|
+
// Crash recovery failure is non-fatal; continue with scheduled runs
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Step 2: Missed-run recovery — if last run was > 24h ago, run immediately
|
|
76
|
+
const lastRunTs = state.lastRunAt ? new Date(state.lastRunAt).getTime() : 0;
|
|
77
|
+
const elapsed = Date.now() - lastRunTs;
|
|
78
|
+
if (elapsed > GC_INTERVAL_MS) {
|
|
79
|
+
try {
|
|
80
|
+
await runGC({ cleoDir });
|
|
81
|
+
} catch {
|
|
82
|
+
// Immediate GC failure is non-fatal; cron will retry next cycle
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step 3: Schedule future runs via node-cron
|
|
87
|
+
// noOverlap: true prevents double-runs if a previous run exceeds 24h
|
|
88
|
+
cron.schedule(
|
|
89
|
+
GC_CRON_EXPR,
|
|
90
|
+
async () => {
|
|
91
|
+
try {
|
|
92
|
+
await runGC({ cleoDir });
|
|
93
|
+
} catch {
|
|
94
|
+
// Log failures via stderr (already redirected to gc.log by spawn)
|
|
95
|
+
const state2 = await readGCState(statePath);
|
|
96
|
+
await patchGCState(statePath, {
|
|
97
|
+
consecutiveFailures: state2.consecutiveFailures + 1,
|
|
98
|
+
lastRunResult: 'failed',
|
|
99
|
+
escalationNeeded: state2.consecutiveFailures + 1 >= 3,
|
|
100
|
+
escalationReason:
|
|
101
|
+
state2.consecutiveFailures + 1 >= 3
|
|
102
|
+
? `GC daemon: ${state2.consecutiveFailures + 1} consecutive failures. Check logs.`
|
|
103
|
+
: state2.escalationReason,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
timezone: 'UTC',
|
|
109
|
+
noOverlap: true,
|
|
110
|
+
name: 'cleo-gc',
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Spawn Helpers (called by `cleo daemon start` in the parent CLI process)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Spawn the GC daemon as a detached background process.
|
|
121
|
+
*
|
|
122
|
+
* All three requirements from T751 §2.2 are met:
|
|
123
|
+
* 1. `detached: true` — process group leader (survives parent exit)
|
|
124
|
+
* 2. File stdio — stdout/stderr redirected to gc.log (not inherited)
|
|
125
|
+
* 3. `child.unref()` — parent CLI exits immediately
|
|
126
|
+
*
|
|
127
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
128
|
+
* @returns PID of the spawned daemon process
|
|
129
|
+
*/
|
|
130
|
+
export async function spawnGCDaemon(cleoDir: string): Promise<number> {
|
|
131
|
+
const logsDir = join(cleoDir, 'logs');
|
|
132
|
+
await mkdir(logsDir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
const logPath = join(logsDir, 'gc.log');
|
|
135
|
+
const errPath = join(logsDir, 'gc.err');
|
|
136
|
+
|
|
137
|
+
// File-based stdio: required for detached process to not inherit the TTY
|
|
138
|
+
const outStream = createWriteStream(logPath, { flags: 'a' });
|
|
139
|
+
const errStream = createWriteStream(errPath, { flags: 'a' });
|
|
140
|
+
|
|
141
|
+
// The daemon entry-point script (compiled alongside this module)
|
|
142
|
+
const daemonEntry = join(fileURLToPath(import.meta.url), '..', 'daemon-entry.js');
|
|
143
|
+
|
|
144
|
+
const child = spawn(process.execPath, [daemonEntry, cleoDir], {
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: ['ignore', outStream, errStream],
|
|
147
|
+
env: { ...process.env, CLEO_GC_DAEMON: '1' },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// unref() allows the parent CLI process to exit while the daemon continues
|
|
151
|
+
child.unref();
|
|
152
|
+
|
|
153
|
+
const pid = child.pid ?? 0;
|
|
154
|
+
|
|
155
|
+
// Persist PID so `cleo daemon stop` can find and signal the process
|
|
156
|
+
await patchGCState(join(cleoDir, 'gc-state.json'), {
|
|
157
|
+
daemonPid: pid,
|
|
158
|
+
daemonStartedAt: new Date().toISOString(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return pid;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Stop the GC daemon by sending SIGTERM to its PID.
|
|
166
|
+
*
|
|
167
|
+
* Uses `process.kill(pid, 0)` as a no-throw liveness probe before signalling.
|
|
168
|
+
*
|
|
169
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
170
|
+
* @returns `{ stopped: boolean; pid: number | null; reason: string }`
|
|
171
|
+
*/
|
|
172
|
+
export async function stopGCDaemon(
|
|
173
|
+
cleoDir: string,
|
|
174
|
+
): Promise<{ stopped: boolean; pid: number | null; reason: string }> {
|
|
175
|
+
const statePath = join(cleoDir, 'gc-state.json');
|
|
176
|
+
const state = await readGCState(statePath);
|
|
177
|
+
const pid = state.daemonPid;
|
|
178
|
+
|
|
179
|
+
if (!pid) {
|
|
180
|
+
return { stopped: false, pid: null, reason: 'Daemon PID not found in gc-state.json' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Liveness probe: process.kill(pid, 0) throws if PID is not running
|
|
184
|
+
try {
|
|
185
|
+
process.kill(pid, 0);
|
|
186
|
+
} catch {
|
|
187
|
+
// Process is not running — clear stale PID from state
|
|
188
|
+
await patchGCState(statePath, { daemonPid: null });
|
|
189
|
+
return {
|
|
190
|
+
stopped: false,
|
|
191
|
+
pid,
|
|
192
|
+
reason: `Daemon PID ${pid} is not running (stale state cleared)`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Send SIGTERM — daemon should clean up and exit gracefully
|
|
197
|
+
try {
|
|
198
|
+
process.kill(pid, 'SIGTERM');
|
|
199
|
+
// Clear PID from state after successful signal
|
|
200
|
+
await patchGCState(statePath, { daemonPid: null });
|
|
201
|
+
return { stopped: true, pid, reason: `SIGTERM sent to PID ${pid}` };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
204
|
+
return { stopped: false, pid, reason: `Failed to send SIGTERM to PID ${pid}: ${msg}` };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check whether the GC daemon is currently running.
|
|
210
|
+
*
|
|
211
|
+
* @param cleoDir - Absolute path to the `.cleo/` directory
|
|
212
|
+
* @returns `{ running: boolean; pid: number | null; startedAt: string | null }`
|
|
213
|
+
*/
|
|
214
|
+
export async function getGCDaemonStatus(cleoDir: string): Promise<{
|
|
215
|
+
running: boolean;
|
|
216
|
+
pid: number | null;
|
|
217
|
+
startedAt: string | null;
|
|
218
|
+
lastRunAt: string | null;
|
|
219
|
+
lastDiskUsedPct: number | null;
|
|
220
|
+
escalationNeeded: boolean;
|
|
221
|
+
}> {
|
|
222
|
+
const state = await readGCState(join(cleoDir, 'gc-state.json'));
|
|
223
|
+
const pid = state.daemonPid;
|
|
224
|
+
|
|
225
|
+
let running = false;
|
|
226
|
+
if (pid) {
|
|
227
|
+
try {
|
|
228
|
+
process.kill(pid, 0);
|
|
229
|
+
running = true;
|
|
230
|
+
} catch {
|
|
231
|
+
running = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
running,
|
|
237
|
+
pid: running ? pid : null,
|
|
238
|
+
startedAt: state.daemonStartedAt,
|
|
239
|
+
lastRunAt: state.lastRunAt,
|
|
240
|
+
lastDiskUsedPct: state.lastDiskUsedPct,
|
|
241
|
+
escalationNeeded: state.escalationNeeded,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Standalone daemon entry point
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
// When this module is executed directly (via `node daemon.js <cleoDir>`),
|
|
250
|
+
// bootstrap the daemon. The daemon-entry.js shim calls bootstrapDaemon().
|
|
251
|
+
// See src/gc/daemon-entry.ts for the entry script.
|
package/src/gc/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cleocode/core/gc — Autonomous GC daemon public API.
|
|
3
|
+
*
|
|
4
|
+
* Provides transcript cleanup, disk-pressure monitoring, GC state
|
|
5
|
+
* management, and daemon lifecycle (spawn/stop/status).
|
|
6
|
+
*
|
|
7
|
+
* @see ADR-047 — Autonomous GC and Disk Safety
|
|
8
|
+
* @package @cleocode/core
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export * from './daemon.js';
|
|
12
|
+
export * from './runner.js';
|
|
13
|
+
export * from './state.js';
|
|
14
|
+
export * from './transcript.js';
|
package/src/gc/runner.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC Runner — Core garbage collection logic for autonomous transcript cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Performs disk-pressure-aware pruning of ephemeral transcript and temp files
|
|
5
|
+
* under `~/.claude/projects/` using the five-tier threshold model from T751.
|
|
6
|
+
*
|
|
7
|
+
* Retention policy (per ADR-047 and docs/specs/memory-architecture-spec.md §8):
|
|
8
|
+
* - `.temp/` files: 24h normal, 1h emergency
|
|
9
|
+
* - Transcript directories (agent-*.jsonl, tool-results/): 7d normal, 1d emergency
|
|
10
|
+
* - `.cleo/logs/`: 30d normal, 7d emergency
|
|
11
|
+
* - `.cleo/agent-outputs/*.md` (committed artifacts): NEVER auto-pruned
|
|
12
|
+
*
|
|
13
|
+
* Circuit breaker: if `ANTHROPIC_API_KEY` is absent AND no local model configured,
|
|
14
|
+
* skip extraction and only delete transcripts older than 30 days.
|
|
15
|
+
*
|
|
16
|
+
* @see ADR-047 — Autonomous GC and Disk Safety
|
|
17
|
+
* @see docs/specs/memory-architecture-spec.md §8
|
|
18
|
+
* @task T731
|
|
19
|
+
* @epic T726
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { lstat, readdir, rm, stat } from 'node:fs/promises';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import checkDiskSpaceModule from 'check-disk-space';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Checks free + total bytes on the filesystem containing the given path.
|
|
29
|
+
*
|
|
30
|
+
* `check-disk-space@3.4.0` publishes its function as `export { x as default }`
|
|
31
|
+
* in the .d.ts, which TS 6.0 strict resolution treats as a namespaced re-export
|
|
32
|
+
* rather than a callable default. The runtime module itself exposes a callable;
|
|
33
|
+
* we bridge the type gap by typing `checkDiskSpaceModule` as the callable
|
|
34
|
+
* shape at the single import boundary.
|
|
35
|
+
*/
|
|
36
|
+
const checkDiskSpace = checkDiskSpaceModule as unknown as (path: string) => Promise<{
|
|
37
|
+
diskPath: string;
|
|
38
|
+
free: number;
|
|
39
|
+
size: number;
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
import { patchGCState, readGCState } from './state.js';
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Threshold Tiers (from T751 §3.2 and ADR-047)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Disk usage percentage thresholds.
|
|
50
|
+
*
|
|
51
|
+
* Values mirror the five-tier model recommended by T751 research §3.2:
|
|
52
|
+
* - OK: < 70% — routine cleanup by age policy only
|
|
53
|
+
* - WATCH: 70-85% — log + schedule next GC sooner
|
|
54
|
+
* - WARN: 85-90% — log + set escalation flag for next CLI invocation
|
|
55
|
+
* - URGENT: 90-95% — auto-prune oldest transcripts immediately
|
|
56
|
+
* - EMERGENCY: ≥ 95% — auto-prune all transcripts > 1d, pause new writes
|
|
57
|
+
*/
|
|
58
|
+
export const DISK_THRESHOLDS = {
|
|
59
|
+
WATCH: 70,
|
|
60
|
+
WARN: 85,
|
|
61
|
+
URGENT: 90,
|
|
62
|
+
EMERGENCY: 95,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/** Human-readable tier labels. */
|
|
66
|
+
export type DiskTier = 'ok' | 'watch' | 'warn' | 'urgent' | 'emergency';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Result of a single GC run.
|
|
70
|
+
*/
|
|
71
|
+
export interface GCResult {
|
|
72
|
+
/** Disk usage percentage at time of GC run (0–100). */
|
|
73
|
+
diskUsedPct: number;
|
|
74
|
+
/** Disk tier classification. */
|
|
75
|
+
threshold: DiskTier;
|
|
76
|
+
/** Files pruned during this run. */
|
|
77
|
+
pruned: Array<{ path: string; bytes: number }>;
|
|
78
|
+
/** Total bytes freed. */
|
|
79
|
+
bytesFreed: number;
|
|
80
|
+
/** Whether escalation flag was set (disk ≥ WARN). */
|
|
81
|
+
escalationSet: boolean;
|
|
82
|
+
/** Human-readable escalation reason (set when escalationSet=true). */
|
|
83
|
+
escalationReason: string | null;
|
|
84
|
+
/** ISO-8601 timestamp of run completion. */
|
|
85
|
+
completedAt: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Options for a GC run.
|
|
90
|
+
*/
|
|
91
|
+
export interface GCRunOptions {
|
|
92
|
+
/**
|
|
93
|
+
* Absolute path to the `.cleo/` directory (used for state file and disk check).
|
|
94
|
+
* Defaults to `~/.cleo`.
|
|
95
|
+
*/
|
|
96
|
+
cleoDir?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Override the default `~/.claude/projects/` scan directory.
|
|
99
|
+
* Primarily used in tests to point at a temp directory.
|
|
100
|
+
*/
|
|
101
|
+
projectsDir?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Paths from a previous crashed run to resume deletion from.
|
|
104
|
+
* Written to `pendingPrune` in gc-state.json BEFORE starting deletion.
|
|
105
|
+
*/
|
|
106
|
+
resumeFrom?: string[];
|
|
107
|
+
/**
|
|
108
|
+
* Dry-run mode: compute what would be pruned, but make zero filesystem changes.
|
|
109
|
+
*/
|
|
110
|
+
dryRun?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Helpers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Classify a disk usage percentage into a tier.
|
|
119
|
+
*
|
|
120
|
+
* @param pct - Disk usage percentage (0–100)
|
|
121
|
+
* @returns DiskTier
|
|
122
|
+
*/
|
|
123
|
+
export function classifyDiskTier(pct: number): DiskTier {
|
|
124
|
+
if (pct >= DISK_THRESHOLDS.EMERGENCY) return 'emergency';
|
|
125
|
+
if (pct >= DISK_THRESHOLDS.URGENT) return 'urgent';
|
|
126
|
+
if (pct >= DISK_THRESHOLDS.WARN) return 'warn';
|
|
127
|
+
if (pct >= DISK_THRESHOLDS.WATCH) return 'watch';
|
|
128
|
+
return 'ok';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute retention threshold in milliseconds based on disk tier.
|
|
133
|
+
*
|
|
134
|
+
* Higher disk pressure → shorter retention → more aggressive pruning.
|
|
135
|
+
*
|
|
136
|
+
* @param tier - Current disk tier
|
|
137
|
+
* @returns Maximum age in milliseconds for transcript retention
|
|
138
|
+
*/
|
|
139
|
+
export function retentionMs(tier: DiskTier): number {
|
|
140
|
+
switch (tier) {
|
|
141
|
+
case 'emergency':
|
|
142
|
+
return 1 * 24 * 60 * 60 * 1000; // 1 day
|
|
143
|
+
case 'urgent':
|
|
144
|
+
return 3 * 24 * 60 * 60 * 1000; // 3 days
|
|
145
|
+
case 'warn':
|
|
146
|
+
return 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
147
|
+
default:
|
|
148
|
+
return 30 * 24 * 60 * 60 * 1000; // 30 days (watch + ok)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get the size of a path in bytes (file or directory recursively).
|
|
154
|
+
* Returns 0 if the path does not exist.
|
|
155
|
+
*
|
|
156
|
+
* @param targetPath - Path to measure
|
|
157
|
+
* @returns Size in bytes
|
|
158
|
+
*/
|
|
159
|
+
export async function getPathBytes(targetPath: string): Promise<number> {
|
|
160
|
+
try {
|
|
161
|
+
const info = await lstat(targetPath);
|
|
162
|
+
if (info.isFile()) return info.size;
|
|
163
|
+
if (!info.isDirectory()) return 0;
|
|
164
|
+
|
|
165
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
166
|
+
let total = 0;
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
total += await getPathBytes(join(targetPath, entry.name));
|
|
169
|
+
}
|
|
170
|
+
return total;
|
|
171
|
+
} catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Idempotently delete a path (file or directory).
|
|
178
|
+
*
|
|
179
|
+
* Silently ignores ENOENT — safe to call if path was already deleted.
|
|
180
|
+
* Uses `force: true` to suppress errors on missing paths.
|
|
181
|
+
*
|
|
182
|
+
* @param targetPath - Path to delete
|
|
183
|
+
*/
|
|
184
|
+
export async function idempotentRm(targetPath: string): Promise<void> {
|
|
185
|
+
try {
|
|
186
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const nodeErr = err as NodeJS.ErrnoException;
|
|
189
|
+
if (nodeErr.code === 'ENOENT') return; // already gone — idempotent
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Gather transcript session directories under `~/.claude/projects/` that are
|
|
196
|
+
* older than `maxAgeMs`.
|
|
197
|
+
*
|
|
198
|
+
* Only session UUID directories are candidates (not the root JSONL files —
|
|
199
|
+
* those are the main transcript). The `tool-results/` subdirectory within a
|
|
200
|
+
* session directory is always included in the prune candidate once the session
|
|
201
|
+
* is old enough.
|
|
202
|
+
*
|
|
203
|
+
* Committed artifact files (`.cleo/agent-outputs/*.md`) are NEVER included.
|
|
204
|
+
*
|
|
205
|
+
* @param maxAgeMs - Maximum age in ms; sessions older than this are candidates
|
|
206
|
+
* @returns Array of absolute directory paths eligible for pruning
|
|
207
|
+
*/
|
|
208
|
+
async function gatherPruneCandidates(maxAgeMs: number, projectsDir?: string): Promise<string[]> {
|
|
209
|
+
const resolvedProjectsDir = projectsDir ?? join(homedir(), '.claude', 'projects');
|
|
210
|
+
const candidates: string[] = [];
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
|
|
213
|
+
let projectSlugs: string[];
|
|
214
|
+
try {
|
|
215
|
+
const entries = await readdir(resolvedProjectsDir, { withFileTypes: true });
|
|
216
|
+
projectSlugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
217
|
+
} catch {
|
|
218
|
+
// ~/.claude/projects/ doesn't exist yet
|
|
219
|
+
return candidates;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const slug of projectSlugs) {
|
|
223
|
+
const slugDir = join(resolvedProjectsDir, slug);
|
|
224
|
+
|
|
225
|
+
// Collect root JSONL files (HOT/WARM main session transcripts)
|
|
226
|
+
let slugEntries: import('fs').Dirent[];
|
|
227
|
+
try {
|
|
228
|
+
slugEntries = await readdir(slugDir, { withFileTypes: true });
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const entry of slugEntries) {
|
|
234
|
+
const entryPath = join(slugDir, entry.name);
|
|
235
|
+
|
|
236
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
237
|
+
// Root-level session JSONL — check age
|
|
238
|
+
try {
|
|
239
|
+
const info = await stat(entryPath);
|
|
240
|
+
const ageMs = now - info.mtimeMs;
|
|
241
|
+
if (ageMs > maxAgeMs) {
|
|
242
|
+
candidates.push(entryPath);
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// File disappeared between readdir and stat — skip
|
|
246
|
+
}
|
|
247
|
+
} else if (entry.isDirectory()) {
|
|
248
|
+
// Session UUID directory — check mtime of the directory itself
|
|
249
|
+
try {
|
|
250
|
+
const info = await stat(entryPath);
|
|
251
|
+
const ageMs = now - info.mtimeMs;
|
|
252
|
+
if (ageMs > maxAgeMs) {
|
|
253
|
+
candidates.push(entryPath);
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Directory disappeared — skip
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return candidates;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Main GC Runner
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Execute a GC run: check disk pressure, determine retention threshold,
|
|
271
|
+
* prune eligible transcript files, update gc-state.json.
|
|
272
|
+
*
|
|
273
|
+
* This function is idempotent and safe to call multiple times. Crash recovery
|
|
274
|
+
* is implemented via the `pendingPrune` field in gc-state.json:
|
|
275
|
+
* 1. Write paths to `pendingPrune` BEFORE starting deletion
|
|
276
|
+
* 2. Remove each path from `pendingPrune` AFTER successful deletion
|
|
277
|
+
* 3. Clear `pendingPrune` when the job completes
|
|
278
|
+
*
|
|
279
|
+
* @param opts - GC run options
|
|
280
|
+
* @returns GC run results
|
|
281
|
+
*/
|
|
282
|
+
export async function runGC(opts: GCRunOptions = {}): Promise<GCResult> {
|
|
283
|
+
const cleoDir = opts.cleoDir ?? join(homedir(), '.cleo');
|
|
284
|
+
const statePath = join(cleoDir, 'gc-state.json');
|
|
285
|
+
const dryRun = opts.dryRun ?? false;
|
|
286
|
+
const projectsDir = opts.projectsDir;
|
|
287
|
+
|
|
288
|
+
// Step 1: Crash recovery — resume any pending prune from prior run
|
|
289
|
+
const initialState = await readGCState(statePath);
|
|
290
|
+
const resumePaths = opts.resumeFrom ?? initialState.pendingPrune ?? [];
|
|
291
|
+
|
|
292
|
+
// Step 2: Check disk space on the filesystem containing .cleo/
|
|
293
|
+
let diskUsedPct = 0;
|
|
294
|
+
try {
|
|
295
|
+
const { free, size } = await checkDiskSpace(cleoDir);
|
|
296
|
+
diskUsedPct = size > 0 ? ((size - free) / size) * 100 : 0;
|
|
297
|
+
} catch {
|
|
298
|
+
// Disk check failure is non-fatal; proceed with default tier
|
|
299
|
+
diskUsedPct = 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const tier = classifyDiskTier(diskUsedPct);
|
|
303
|
+
const maxAgeMs = retentionMs(tier);
|
|
304
|
+
|
|
305
|
+
// Step 3: Gather prune candidates
|
|
306
|
+
const candidatesFromScan =
|
|
307
|
+
resumePaths.length > 0 ? resumePaths : await gatherPruneCandidates(maxAgeMs, projectsDir);
|
|
308
|
+
|
|
309
|
+
// Step 4: Write pendingPrune to state BEFORE any deletion (crash-safe)
|
|
310
|
+
if (!dryRun && candidatesFromScan.length > 0) {
|
|
311
|
+
await patchGCState(statePath, { pendingPrune: candidatesFromScan });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Step 5: Delete candidates and accumulate results
|
|
315
|
+
const pruned: GCResult['pruned'] = [];
|
|
316
|
+
let bytesFreed = 0;
|
|
317
|
+
const remaining = [...candidatesFromScan];
|
|
318
|
+
|
|
319
|
+
for (const candidatePath of candidatesFromScan) {
|
|
320
|
+
const bytes = await getPathBytes(candidatePath);
|
|
321
|
+
|
|
322
|
+
if (dryRun) {
|
|
323
|
+
// Dry run: record what would be deleted, make no changes
|
|
324
|
+
pruned.push({ path: candidatePath, bytes });
|
|
325
|
+
bytesFreed += bytes;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
await idempotentRm(candidatePath);
|
|
331
|
+
pruned.push({ path: candidatePath, bytes });
|
|
332
|
+
bytesFreed += bytes;
|
|
333
|
+
// Remove successfully-deleted path from the pending list
|
|
334
|
+
const idx = remaining.indexOf(candidatePath);
|
|
335
|
+
if (idx !== -1) remaining.splice(idx, 1);
|
|
336
|
+
// Persist updated pendingPrune after each deletion (crash-safe)
|
|
337
|
+
await patchGCState(statePath, {
|
|
338
|
+
pendingPrune: remaining.length > 0 ? remaining : null,
|
|
339
|
+
});
|
|
340
|
+
} catch {
|
|
341
|
+
// Deletion failure: leave in pendingPrune for next run
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Step 6: Determine escalation state
|
|
346
|
+
const escalationSet = tier === 'warn' || tier === 'urgent' || tier === 'emergency';
|
|
347
|
+
let escalationReason: string | null = null;
|
|
348
|
+
if (escalationSet) {
|
|
349
|
+
escalationReason = `Disk at ${diskUsedPct.toFixed(1)}% (${tier.toUpperCase()}): ${pruned.length} paths pruned, ${bytesFreed} bytes freed`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const completedAt = new Date().toISOString();
|
|
353
|
+
|
|
354
|
+
// Step 7: Update gc-state.json with run results
|
|
355
|
+
if (!dryRun) {
|
|
356
|
+
await patchGCState(statePath, {
|
|
357
|
+
lastRunAt: completedAt,
|
|
358
|
+
lastRunResult: remaining.length === 0 ? 'success' : 'partial',
|
|
359
|
+
lastRunBytesFreed: bytesFreed,
|
|
360
|
+
pendingPrune: remaining.length > 0 ? remaining : null,
|
|
361
|
+
consecutiveFailures: remaining.length > 0 ? initialState.consecutiveFailures + 1 : 0,
|
|
362
|
+
diskThresholdBreached: diskUsedPct >= DISK_THRESHOLDS.WATCH,
|
|
363
|
+
lastDiskUsedPct: diskUsedPct,
|
|
364
|
+
escalationNeeded: escalationSet || initialState.escalationNeeded,
|
|
365
|
+
escalationReason: escalationReason ?? initialState.escalationReason,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
diskUsedPct,
|
|
371
|
+
threshold: tier,
|
|
372
|
+
pruned,
|
|
373
|
+
bytesFreed,
|
|
374
|
+
escalationSet,
|
|
375
|
+
escalationReason,
|
|
376
|
+
completedAt,
|
|
377
|
+
};
|
|
378
|
+
}
|