@a13xu/lucid 1.16.2 → 1.19.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/build/database.d.ts +51 -0
- package/build/database.js +86 -0
- package/build/guardian/session-tracker.d.ts +34 -0
- package/build/guardian/session-tracker.js +105 -0
- package/build/guardian/truncate-guard.d.ts +54 -0
- package/build/guardian/truncate-guard.js +136 -0
- package/build/index.js +254 -0
- package/build/local-llm/client.d.ts +20 -0
- package/build/local-llm/client.js +140 -0
- package/build/local-llm/config.d.ts +11 -0
- package/build/local-llm/config.js +50 -0
- package/build/local-llm/runtimes.d.ts +16 -0
- package/build/local-llm/runtimes.js +82 -0
- package/build/local-llm/setup-cli.d.ts +5 -0
- package/build/local-llm/setup-cli.js +298 -0
- package/build/local-llm/types.d.ts +34 -0
- package/build/local-llm/types.js +5 -0
- package/build/tools/backup.d.ts +47 -0
- package/build/tools/backup.js +107 -0
- package/build/tools/delegate-local.d.ts +23 -0
- package/build/tools/delegate-local.js +75 -0
- package/build/tools/init.js +124 -2
- package/build/tools/session.d.ts +13 -0
- package/build/tools/session.js +59 -0
- package/package.json +1 -1
package/build/database.d.ts
CHANGED
|
@@ -45,6 +45,36 @@ export interface PlanRow {
|
|
|
45
45
|
created_at: number;
|
|
46
46
|
updated_at: number;
|
|
47
47
|
}
|
|
48
|
+
export interface FileBackupRow {
|
|
49
|
+
id: number;
|
|
50
|
+
filepath: string;
|
|
51
|
+
content: Buffer;
|
|
52
|
+
content_hash: string;
|
|
53
|
+
original_size: number;
|
|
54
|
+
compressed_size: number;
|
|
55
|
+
reason: string;
|
|
56
|
+
created_at: number;
|
|
57
|
+
}
|
|
58
|
+
export interface CliSessionRow {
|
|
59
|
+
session_id: string;
|
|
60
|
+
started_at: number;
|
|
61
|
+
last_activity_at: number;
|
|
62
|
+
prompt_count: number;
|
|
63
|
+
last_compact_hint_at: number | null;
|
|
64
|
+
last_clear_hint_at: number | null;
|
|
65
|
+
last_compact_event_at: number | null;
|
|
66
|
+
compact_count: number;
|
|
67
|
+
cwd: string | null;
|
|
68
|
+
}
|
|
69
|
+
export interface TruncateEventRow {
|
|
70
|
+
id: number;
|
|
71
|
+
filepath: string;
|
|
72
|
+
prev_size: number;
|
|
73
|
+
new_size: number;
|
|
74
|
+
shrink_ratio: number;
|
|
75
|
+
blocked: number;
|
|
76
|
+
created_at: number;
|
|
77
|
+
}
|
|
48
78
|
export interface PlanTaskRow {
|
|
49
79
|
id: number;
|
|
50
80
|
plan_id: number;
|
|
@@ -115,6 +145,27 @@ export interface Statements {
|
|
|
115
145
|
countRemainingTasks: Stmt<[number], {
|
|
116
146
|
count: number;
|
|
117
147
|
}>;
|
|
148
|
+
insertBackup: WriteStmt<[string, Buffer, string, number, number, string]>;
|
|
149
|
+
getBackupsByPath: Stmt<[string], FileBackupRow>;
|
|
150
|
+
getLatestBackup: Stmt<[string], FileBackupRow>;
|
|
151
|
+
getBackupById: Stmt<[number], FileBackupRow>;
|
|
152
|
+
deleteOldBackups: WriteStmt<[string, string, number]>;
|
|
153
|
+
countBackups: Stmt<[string], {
|
|
154
|
+
count: number;
|
|
155
|
+
}>;
|
|
156
|
+
insertTruncateEvent: WriteStmt<[string, number, number, number, number]>;
|
|
157
|
+
recentTruncateEvents: Stmt<[number], TruncateEventRow>;
|
|
158
|
+
countRecentTruncates: Stmt<[number], {
|
|
159
|
+
count: number;
|
|
160
|
+
}>;
|
|
161
|
+
getCliSession: Stmt<[string], CliSessionRow>;
|
|
162
|
+
insertCliSession: WriteStmt<[string, number, number, string | null]>;
|
|
163
|
+
tickCliSession: WriteStmt<[number, string]>;
|
|
164
|
+
markCompactHint: WriteStmt<[number, string]>;
|
|
165
|
+
markClearHint: WriteStmt<[number, string]>;
|
|
166
|
+
markCompactEvent: WriteStmt<[number, string]>;
|
|
167
|
+
recentCliSessions: Stmt<[number], CliSessionRow>;
|
|
168
|
+
resetCliSessionCount: WriteStmt<[string]>;
|
|
118
169
|
}
|
|
119
170
|
export declare function prepareStatements(db: Database.Database): Statements;
|
|
120
171
|
export {};
|
package/build/database.js
CHANGED
|
@@ -170,6 +170,46 @@ function createSchema(db) {
|
|
|
170
170
|
|
|
171
171
|
CREATE INDEX IF NOT EXISTS idx_plan_tasks_plan ON plan_tasks(plan_id, seq);
|
|
172
172
|
CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
|
|
173
|
+
|
|
174
|
+
-- Versioned backups of file content (last N kept per file)
|
|
175
|
+
CREATE TABLE IF NOT EXISTS file_backups (
|
|
176
|
+
id INTEGER PRIMARY KEY,
|
|
177
|
+
filepath TEXT NOT NULL,
|
|
178
|
+
content BLOB NOT NULL,
|
|
179
|
+
content_hash TEXT NOT NULL,
|
|
180
|
+
original_size INTEGER NOT NULL,
|
|
181
|
+
compressed_size INTEGER NOT NULL,
|
|
182
|
+
reason TEXT NOT NULL DEFAULT 'manual',
|
|
183
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
184
|
+
);
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_fb_path ON file_backups(filepath, created_at DESC);
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_fb_created ON file_backups(created_at);
|
|
187
|
+
|
|
188
|
+
-- Claude Code session tracking — drives /compact and /clear hints
|
|
189
|
+
CREATE TABLE IF NOT EXISTS cli_sessions (
|
|
190
|
+
session_id TEXT PRIMARY KEY,
|
|
191
|
+
started_at INTEGER NOT NULL,
|
|
192
|
+
last_activity_at INTEGER NOT NULL,
|
|
193
|
+
prompt_count INTEGER NOT NULL DEFAULT 0,
|
|
194
|
+
last_compact_hint_at INTEGER,
|
|
195
|
+
last_clear_hint_at INTEGER,
|
|
196
|
+
last_compact_event_at INTEGER,
|
|
197
|
+
compact_count INTEGER NOT NULL DEFAULT 0,
|
|
198
|
+
cwd TEXT
|
|
199
|
+
);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_cli_sessions_activity ON cli_sessions(last_activity_at DESC);
|
|
201
|
+
|
|
202
|
+
-- Truncate-event log (drives cascade detection)
|
|
203
|
+
CREATE TABLE IF NOT EXISTS truncate_events (
|
|
204
|
+
id INTEGER PRIMARY KEY,
|
|
205
|
+
filepath TEXT NOT NULL,
|
|
206
|
+
prev_size INTEGER NOT NULL,
|
|
207
|
+
new_size INTEGER NOT NULL,
|
|
208
|
+
shrink_ratio REAL NOT NULL,
|
|
209
|
+
blocked INTEGER NOT NULL DEFAULT 1,
|
|
210
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
211
|
+
);
|
|
212
|
+
CREATE INDEX IF NOT EXISTS idx_te_created ON truncate_events(created_at DESC);
|
|
173
213
|
`);
|
|
174
214
|
}
|
|
175
215
|
export function prepareStatements(db) {
|
|
@@ -261,5 +301,51 @@ export function prepareStatements(db) {
|
|
|
261
301
|
getTaskById: db.prepare("SELECT * FROM plan_tasks WHERE id = ?"),
|
|
262
302
|
updateTaskStatus: db.prepare("UPDATE plan_tasks SET status = ?, notes = ?, updated_at = unixepoch() WHERE id = ?"),
|
|
263
303
|
countRemainingTasks: db.prepare("SELECT COUNT(*) as count FROM plan_tasks WHERE plan_id = ? AND status != 'done'"),
|
|
304
|
+
// file_backups
|
|
305
|
+
insertBackup: db.prepare(`INSERT INTO file_backups (filepath, content, content_hash, original_size, compressed_size, reason)
|
|
306
|
+
VALUES (?, ?, ?, ?, ?, ?)`),
|
|
307
|
+
getBackupsByPath: db.prepare("SELECT * FROM file_backups WHERE filepath = ? ORDER BY created_at DESC, id DESC"),
|
|
308
|
+
getLatestBackup: db.prepare("SELECT * FROM file_backups WHERE filepath = ? ORDER BY created_at DESC, id DESC LIMIT 1"),
|
|
309
|
+
getBackupById: db.prepare("SELECT * FROM file_backups WHERE id = ?"),
|
|
310
|
+
deleteOldBackups: db.prepare(`DELETE FROM file_backups
|
|
311
|
+
WHERE filepath = ?
|
|
312
|
+
AND id NOT IN (
|
|
313
|
+
SELECT id FROM file_backups
|
|
314
|
+
WHERE filepath = ?
|
|
315
|
+
ORDER BY created_at DESC, id DESC
|
|
316
|
+
LIMIT ?
|
|
317
|
+
)`),
|
|
318
|
+
countBackups: db.prepare("SELECT COUNT(*) as count FROM file_backups WHERE filepath = ?"),
|
|
319
|
+
// truncate_events
|
|
320
|
+
insertTruncateEvent: db.prepare(`INSERT INTO truncate_events (filepath, prev_size, new_size, shrink_ratio, blocked)
|
|
321
|
+
VALUES (?, ?, ?, ?, ?)`),
|
|
322
|
+
recentTruncateEvents: db.prepare("SELECT * FROM truncate_events WHERE created_at >= ? ORDER BY created_at DESC"),
|
|
323
|
+
countRecentTruncates: db.prepare("SELECT COUNT(*) as count FROM truncate_events WHERE created_at >= ? AND blocked = 1"),
|
|
324
|
+
// cli_sessions
|
|
325
|
+
getCliSession: db.prepare("SELECT * FROM cli_sessions WHERE session_id = ?"),
|
|
326
|
+
insertCliSession: db.prepare(`INSERT INTO cli_sessions (session_id, started_at, last_activity_at, prompt_count, cwd)
|
|
327
|
+
VALUES (?, ?, ?, 1, ?)
|
|
328
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
329
|
+
last_activity_at = excluded.last_activity_at,
|
|
330
|
+
prompt_count = cli_sessions.prompt_count + 1`),
|
|
331
|
+
tickCliSession: db.prepare(`UPDATE cli_sessions SET
|
|
332
|
+
last_activity_at = ?,
|
|
333
|
+
prompt_count = prompt_count + 1
|
|
334
|
+
WHERE session_id = ?`),
|
|
335
|
+
markCompactHint: db.prepare("UPDATE cli_sessions SET last_compact_hint_at = ? WHERE session_id = ?"),
|
|
336
|
+
markClearHint: db.prepare("UPDATE cli_sessions SET last_clear_hint_at = ? WHERE session_id = ?"),
|
|
337
|
+
markCompactEvent: db.prepare(`UPDATE cli_sessions SET
|
|
338
|
+
last_compact_event_at = ?,
|
|
339
|
+
compact_count = compact_count + 1,
|
|
340
|
+
prompt_count = 0,
|
|
341
|
+
last_compact_hint_at = NULL,
|
|
342
|
+
last_clear_hint_at = NULL
|
|
343
|
+
WHERE session_id = ?`),
|
|
344
|
+
recentCliSessions: db.prepare("SELECT * FROM cli_sessions ORDER BY last_activity_at DESC LIMIT ?"),
|
|
345
|
+
resetCliSessionCount: db.prepare(`UPDATE cli_sessions SET
|
|
346
|
+
prompt_count = 0,
|
|
347
|
+
last_compact_hint_at = NULL,
|
|
348
|
+
last_clear_hint_at = NULL
|
|
349
|
+
WHERE session_id = ?`),
|
|
264
350
|
};
|
|
265
351
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Tracker — emits hints to invoke /compact or /clear when a Claude Code
|
|
3
|
+
* session grows expensive. Driven by UserPromptSubmit and PreCompact hooks.
|
|
4
|
+
*
|
|
5
|
+
* Cost model: Anthropic prompt cache has a 5-minute TTL. Beyond that the next
|
|
6
|
+
* turn re-reads the entire transcript. Long sessions also pay per-turn linearly
|
|
7
|
+
* with transcript length even when warm. Two pressures, two hints:
|
|
8
|
+
*
|
|
9
|
+
* 1) prompt_count >= COMPACT_HINT_AT → suggest /compact (re-emitted every N).
|
|
10
|
+
* 2) prompt_count >= CLEAR_HINT_AT → suggest /clear (one-shot).
|
|
11
|
+
* 3) idle > CACHE_STALE_SECONDS → cache cold; warn next turn re-bills.
|
|
12
|
+
*
|
|
13
|
+
* Emitted hints are written to STDOUT so the UserPromptSubmit hook injects them
|
|
14
|
+
* into Claude's context (Claude Code convention). Empty stdout = no hint.
|
|
15
|
+
*/
|
|
16
|
+
import type { Statements } from "../database.js";
|
|
17
|
+
export interface SessionTickResult {
|
|
18
|
+
session_id: string;
|
|
19
|
+
prompt_count: number;
|
|
20
|
+
hints: string[];
|
|
21
|
+
cache_likely_cold: boolean;
|
|
22
|
+
idle_seconds: number;
|
|
23
|
+
}
|
|
24
|
+
/** Fired once per UserPromptSubmit. Returns hints to surface to Claude. */
|
|
25
|
+
export declare function tickSession(stmts: Statements, sessionId: string, cwd: string | null): SessionTickResult;
|
|
26
|
+
/** Fired by PreCompact hook — resets per-session counters. */
|
|
27
|
+
export declare function markCompactEvent(stmts: Statements, sessionId: string): void;
|
|
28
|
+
export declare const SESSION_TUNABLES: {
|
|
29
|
+
readonly COMPACT_HINT_AT: number;
|
|
30
|
+
readonly COMPACT_HINT_EVERY: number;
|
|
31
|
+
readonly CLEAR_HINT_AT: number;
|
|
32
|
+
readonly CACHE_STALE_SECONDS: number;
|
|
33
|
+
readonly IDLE_HINT_MIN_PROMPTS: 5;
|
|
34
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Tracker — emits hints to invoke /compact or /clear when a Claude Code
|
|
3
|
+
* session grows expensive. Driven by UserPromptSubmit and PreCompact hooks.
|
|
4
|
+
*
|
|
5
|
+
* Cost model: Anthropic prompt cache has a 5-minute TTL. Beyond that the next
|
|
6
|
+
* turn re-reads the entire transcript. Long sessions also pay per-turn linearly
|
|
7
|
+
* with transcript length even when warm. Two pressures, two hints:
|
|
8
|
+
*
|
|
9
|
+
* 1) prompt_count >= COMPACT_HINT_AT → suggest /compact (re-emitted every N).
|
|
10
|
+
* 2) prompt_count >= CLEAR_HINT_AT → suggest /clear (one-shot).
|
|
11
|
+
* 3) idle > CACHE_STALE_SECONDS → cache cold; warn next turn re-bills.
|
|
12
|
+
*
|
|
13
|
+
* Emitted hints are written to STDOUT so the UserPromptSubmit hook injects them
|
|
14
|
+
* into Claude's context (Claude Code convention). Empty stdout = no hint.
|
|
15
|
+
*/
|
|
16
|
+
const num = (envKey, fallback) => {
|
|
17
|
+
const v = process.env[envKey];
|
|
18
|
+
if (!v)
|
|
19
|
+
return fallback;
|
|
20
|
+
const n = Number(v);
|
|
21
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
22
|
+
};
|
|
23
|
+
const COMPACT_HINT_AT = num("LUCID_COMPACT_HINT_AT", 15);
|
|
24
|
+
const COMPACT_HINT_EVERY = num("LUCID_COMPACT_HINT_EVERY", 10);
|
|
25
|
+
const CLEAR_HINT_AT = num("LUCID_CLEAR_HINT_AT", 30);
|
|
26
|
+
const CACHE_STALE_SECONDS = num("LUCID_CACHE_STALE_SECONDS", 300);
|
|
27
|
+
/** Below this many prompts, idle gaps don't matter — short session, cheap. */
|
|
28
|
+
const IDLE_HINT_MIN_PROMPTS = 5;
|
|
29
|
+
/** Fired once per UserPromptSubmit. Returns hints to surface to Claude. */
|
|
30
|
+
export function tickSession(stmts, sessionId, cwd) {
|
|
31
|
+
if (process.env["LUCID_SESSION_HINTS_DISABLED"] === "1") {
|
|
32
|
+
return { session_id: sessionId, prompt_count: 0, hints: [], cache_likely_cold: false, idle_seconds: 0 };
|
|
33
|
+
}
|
|
34
|
+
const now = Math.floor(Date.now() / 1000);
|
|
35
|
+
const existing = stmts.getCliSession.get(sessionId);
|
|
36
|
+
if (!existing) {
|
|
37
|
+
stmts.insertCliSession.run(sessionId, now, now, cwd);
|
|
38
|
+
return { session_id: sessionId, prompt_count: 1, hints: [], cache_likely_cold: false, idle_seconds: 0 };
|
|
39
|
+
}
|
|
40
|
+
const idleSeconds = now - existing.last_activity_at;
|
|
41
|
+
const cacheCold = idleSeconds > CACHE_STALE_SECONDS;
|
|
42
|
+
stmts.tickCliSession.run(now, sessionId);
|
|
43
|
+
const newCount = existing.prompt_count + 1;
|
|
44
|
+
const hints = [];
|
|
45
|
+
// ── /compact hint: at threshold, then every N prompts after ────────────────
|
|
46
|
+
const compactDue = newCount === COMPACT_HINT_AT ||
|
|
47
|
+
(newCount > COMPACT_HINT_AT &&
|
|
48
|
+
(newCount - COMPACT_HINT_AT) % COMPACT_HINT_EVERY === 0);
|
|
49
|
+
if (compactDue) {
|
|
50
|
+
hints.push(formatCompactHint(newCount, existing.compact_count));
|
|
51
|
+
stmts.markCompactHint.run(now, sessionId);
|
|
52
|
+
}
|
|
53
|
+
// ── /clear hint: one-shot at CLEAR_HINT_AT ─────────────────────────────────
|
|
54
|
+
if (newCount >= CLEAR_HINT_AT && existing.last_clear_hint_at === null) {
|
|
55
|
+
hints.push(formatClearHint(newCount));
|
|
56
|
+
stmts.markClearHint.run(now, sessionId);
|
|
57
|
+
}
|
|
58
|
+
// ── Cache-cold hint: only meaningful past warmup ───────────────────────────
|
|
59
|
+
if (cacheCold && newCount >= IDLE_HINT_MIN_PROMPTS) {
|
|
60
|
+
hints.push(formatColdCacheHint(idleSeconds));
|
|
61
|
+
}
|
|
62
|
+
return { session_id: sessionId, prompt_count: newCount, hints, cache_likely_cold: cacheCold, idle_seconds: idleSeconds };
|
|
63
|
+
}
|
|
64
|
+
/** Fired by PreCompact hook — resets per-session counters. */
|
|
65
|
+
export function markCompactEvent(stmts, sessionId) {
|
|
66
|
+
const now = Math.floor(Date.now() / 1000);
|
|
67
|
+
// Insert a placeholder row if hook fires before any UserPromptSubmit
|
|
68
|
+
if (!stmts.getCliSession.get(sessionId)) {
|
|
69
|
+
stmts.insertCliSession.run(sessionId, now, now, null);
|
|
70
|
+
}
|
|
71
|
+
stmts.markCompactEvent.run(now, sessionId);
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Hint formatters — kept terse: every word costs tokens once injected.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
function formatCompactHint(promptCount, prevCompacts) {
|
|
77
|
+
const tail = prevCompacts > 0 ? ` (${prevCompacts} prior /compact in this session)` : "";
|
|
78
|
+
return [
|
|
79
|
+
`[Lucid · session-cost]`,
|
|
80
|
+
`Session is at ${promptCount} prompts${tail}. Per-turn cost grows with transcript length even when cached.`,
|
|
81
|
+
`→ Run /compact mid-task to summarize earlier turns and reduce input tokens for the next ~${COMPACT_HINT_EVERY} prompts.`,
|
|
82
|
+
].join(" ");
|
|
83
|
+
}
|
|
84
|
+
function formatClearHint(promptCount) {
|
|
85
|
+
return [
|
|
86
|
+
`[Lucid · session-cost]`,
|
|
87
|
+
`Session has reached ${promptCount} prompts.`,
|
|
88
|
+
`→ If you are switching to a new task with little overlap, prefer /clear over /compact — fresh context is cheaper than a summary.`,
|
|
89
|
+
].join(" ");
|
|
90
|
+
}
|
|
91
|
+
function formatColdCacheHint(idleSeconds) {
|
|
92
|
+
const mins = Math.round(idleSeconds / 60);
|
|
93
|
+
return [
|
|
94
|
+
`[Lucid · session-cost]`,
|
|
95
|
+
`${mins}m idle: prompt cache (5-min TTL) is cold. This turn rebuilds it from scratch (~3-4× the cached cost).`,
|
|
96
|
+
`→ For long pauses, run /compact before resuming so the rebuilt cache covers a smaller transcript.`,
|
|
97
|
+
].join(" ");
|
|
98
|
+
}
|
|
99
|
+
export const SESSION_TUNABLES = {
|
|
100
|
+
COMPACT_HINT_AT,
|
|
101
|
+
COMPACT_HINT_EVERY,
|
|
102
|
+
CLEAR_HINT_AT,
|
|
103
|
+
CACHE_STALE_SECONDS,
|
|
104
|
+
IDLE_HINT_MIN_PROMPTS,
|
|
105
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate Guard — prevents data-loss writes (file emptied, drastically shrunk)
|
|
3
|
+
* and detects truncate cascades (≥N truncate attempts within a short window),
|
|
4
|
+
* which signal a runaway loop in automode.
|
|
5
|
+
*
|
|
6
|
+
* Two layers:
|
|
7
|
+
* 1) Per-write check: empty content over non-empty file, or new size < 30% prev.
|
|
8
|
+
* 2) Cascade lock: ≥2 blocked truncates within 60s blocks ALL further Write/Edit
|
|
9
|
+
* until the user clears the lock (clear_truncate_lock tool or env override).
|
|
10
|
+
*/
|
|
11
|
+
import type { Statements } from "../database.js";
|
|
12
|
+
export type TruncateRule = "EMPTY_OVERWRITE" | "WHITESPACE_ONLY" | "MAJOR_SHRINK" | "CASCADE_LOCK";
|
|
13
|
+
export interface TruncateAssessment {
|
|
14
|
+
blocked: boolean;
|
|
15
|
+
rule?: TruncateRule;
|
|
16
|
+
prevSize: number;
|
|
17
|
+
newSize: number;
|
|
18
|
+
shrinkRatio: number;
|
|
19
|
+
reason?: string;
|
|
20
|
+
/** True when CASCADE_LOCK is the active reason. */
|
|
21
|
+
cascade: boolean;
|
|
22
|
+
/** Number of blocked truncates inside the cascade window. */
|
|
23
|
+
cascadeCount: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function isCascadeBlocked(stmts: Statements): {
|
|
26
|
+
blocked: boolean;
|
|
27
|
+
count: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Assess whether writing `newContent` to `path` is a destructive truncate.
|
|
31
|
+
* Pure function — does NOT mutate state. Caller decides whether to record.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assessTruncate(path: string, newContent: string | null, stmts: Statements): TruncateAssessment;
|
|
34
|
+
/** Persist a truncate event so cascade detection can see it on the next call. */
|
|
35
|
+
export declare function recordTruncateEvent(stmts: Statements, path: string, prevSize: number, newSize: number, blocked: boolean): void;
|
|
36
|
+
export interface BackupResult {
|
|
37
|
+
saved: boolean;
|
|
38
|
+
reason: string;
|
|
39
|
+
size?: number;
|
|
40
|
+
hash?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot the current on-disk file content into file_backups, then GC older
|
|
44
|
+
* versions beyond BACKUP_RETENTION. No-op if the file does not exist or is
|
|
45
|
+
* identical to the most recent backup.
|
|
46
|
+
*/
|
|
47
|
+
export declare function backupFile(stmts: Statements, path: string, snapshotReason?: string): BackupResult;
|
|
48
|
+
export declare const TUNABLES: {
|
|
49
|
+
readonly MIN_KEEP_RATIO: 0.3;
|
|
50
|
+
readonly SMALL_FILE_BYTES: 80;
|
|
51
|
+
readonly CASCADE_WINDOW_SECONDS: 60;
|
|
52
|
+
readonly CASCADE_THRESHOLD: 2;
|
|
53
|
+
readonly BACKUP_RETENTION: 10;
|
|
54
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate Guard — prevents data-loss writes (file emptied, drastically shrunk)
|
|
3
|
+
* and detects truncate cascades (≥N truncate attempts within a short window),
|
|
4
|
+
* which signal a runaway loop in automode.
|
|
5
|
+
*
|
|
6
|
+
* Two layers:
|
|
7
|
+
* 1) Per-write check: empty content over non-empty file, or new size < 30% prev.
|
|
8
|
+
* 2) Cascade lock: ≥2 blocked truncates within 60s blocks ALL further Write/Edit
|
|
9
|
+
* until the user clears the lock (clear_truncate_lock tool or env override).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
import { compress, sha256 } from "../store/content.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Tunables (kept local — no config knob until requested)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/** New content must keep ≥ this fraction of previous size, else flagged. */
|
|
18
|
+
const MIN_KEEP_RATIO = 0.30;
|
|
19
|
+
/** Files smaller than this are exempt — too small for ratio to be meaningful. */
|
|
20
|
+
const SMALL_FILE_BYTES = 80;
|
|
21
|
+
/** Cascade window: how far back we look for blocked truncate events. */
|
|
22
|
+
const CASCADE_WINDOW_SECONDS = 60;
|
|
23
|
+
/** Number of blocked truncates within the window that triggers a hard lock. */
|
|
24
|
+
const CASCADE_THRESHOLD = 2;
|
|
25
|
+
/** Backups kept per file (last N). */
|
|
26
|
+
const BACKUP_RETENTION = 10;
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Cascade detection
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export function isCascadeBlocked(stmts) {
|
|
31
|
+
if (process.env["LUCID_TRUNCATE_OVERRIDE"] === "1") {
|
|
32
|
+
return { blocked: false, count: 0 };
|
|
33
|
+
}
|
|
34
|
+
const since = Math.floor(Date.now() / 1000) - CASCADE_WINDOW_SECONDS;
|
|
35
|
+
const row = stmts.countRecentTruncates.get(since);
|
|
36
|
+
const count = row?.count ?? 0;
|
|
37
|
+
return { blocked: count >= CASCADE_THRESHOLD, count };
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Per-write assessment
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/**
|
|
43
|
+
* Assess whether writing `newContent` to `path` is a destructive truncate.
|
|
44
|
+
* Pure function — does NOT mutate state. Caller decides whether to record.
|
|
45
|
+
*/
|
|
46
|
+
export function assessTruncate(path, newContent, stmts) {
|
|
47
|
+
const cascade = isCascadeBlocked(stmts);
|
|
48
|
+
const absPath = resolve(path);
|
|
49
|
+
// Resolve previous size: filesystem first (source of truth), DB as fallback.
|
|
50
|
+
let prevSize = 0;
|
|
51
|
+
if (existsSync(absPath)) {
|
|
52
|
+
try {
|
|
53
|
+
prevSize = statSync(absPath).size;
|
|
54
|
+
}
|
|
55
|
+
catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
if (prevSize === 0) {
|
|
58
|
+
const dbRow = stmts.getFileByPath.get(absPath);
|
|
59
|
+
if (dbRow)
|
|
60
|
+
prevSize = dbRow.original_size;
|
|
61
|
+
}
|
|
62
|
+
const newSize = newContent === null
|
|
63
|
+
? -1 // unknown — only cascade lock can apply
|
|
64
|
+
: Buffer.byteLength(newContent, "utf-8");
|
|
65
|
+
const ratio = prevSize > 0 && newSize >= 0 ? newSize / prevSize : 1;
|
|
66
|
+
// Cascade lock takes precedence — even non-truncating writes are blocked.
|
|
67
|
+
if (cascade.blocked) {
|
|
68
|
+
return {
|
|
69
|
+
blocked: true,
|
|
70
|
+
rule: "CASCADE_LOCK",
|
|
71
|
+
prevSize, newSize, shrinkRatio: ratio,
|
|
72
|
+
cascade: true,
|
|
73
|
+
cascadeCount: cascade.count,
|
|
74
|
+
reason: `Cascade lock active: ${cascade.count} truncate attempts within ${CASCADE_WINDOW_SECONDS}s. ` +
|
|
75
|
+
`Run "lucid guard clear" or set LUCID_TRUNCATE_OVERRIDE=1 to release.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Nothing to compare against — first-time write, allow.
|
|
79
|
+
if (prevSize === 0 || prevSize <= SMALL_FILE_BYTES || newSize < 0) {
|
|
80
|
+
return { blocked: false, prevSize, newSize, shrinkRatio: ratio, cascade: false, cascadeCount: cascade.count };
|
|
81
|
+
}
|
|
82
|
+
if (newSize === 0) {
|
|
83
|
+
return rule("EMPTY_OVERWRITE", prevSize, newSize, ratio, cascade.count, `Refusing to overwrite ${prevSize}B file with empty content.`);
|
|
84
|
+
}
|
|
85
|
+
if (newContent !== null && newContent.trim().length === 0) {
|
|
86
|
+
return rule("WHITESPACE_ONLY", prevSize, newSize, ratio, cascade.count, `Refusing to overwrite ${prevSize}B file with whitespace-only content (${newSize}B).`);
|
|
87
|
+
}
|
|
88
|
+
if (ratio < MIN_KEEP_RATIO) {
|
|
89
|
+
return rule("MAJOR_SHRINK", prevSize, newSize, ratio, cascade.count, `New content keeps only ${Math.round(ratio * 100)}% of previous size ` +
|
|
90
|
+
`(${newSize}B vs ${prevSize}B). Threshold: ${Math.round(MIN_KEEP_RATIO * 100)}%.`);
|
|
91
|
+
}
|
|
92
|
+
return { blocked: false, prevSize, newSize, shrinkRatio: ratio, cascade: false, cascadeCount: cascade.count };
|
|
93
|
+
}
|
|
94
|
+
function rule(r, prev, next, ratio, cascadeCount, reason) {
|
|
95
|
+
return { blocked: true, rule: r, prevSize: prev, newSize: next,
|
|
96
|
+
shrinkRatio: ratio, cascade: false, cascadeCount, reason };
|
|
97
|
+
}
|
|
98
|
+
/** Persist a truncate event so cascade detection can see it on the next call. */
|
|
99
|
+
export function recordTruncateEvent(stmts, path, prevSize, newSize, blocked) {
|
|
100
|
+
const ratio = prevSize > 0 ? Math.max(0, newSize) / prevSize : 1;
|
|
101
|
+
stmts.insertTruncateEvent.run(resolve(path), prevSize, Math.max(0, newSize), ratio, blocked ? 1 : 0);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Snapshot the current on-disk file content into file_backups, then GC older
|
|
105
|
+
* versions beyond BACKUP_RETENTION. No-op if the file does not exist or is
|
|
106
|
+
* identical to the most recent backup.
|
|
107
|
+
*/
|
|
108
|
+
export function backupFile(stmts, path, snapshotReason = "manual") {
|
|
109
|
+
const absPath = resolve(path);
|
|
110
|
+
if (!existsSync(absPath))
|
|
111
|
+
return { saved: false, reason: `File not found: ${absPath}` };
|
|
112
|
+
let source;
|
|
113
|
+
try {
|
|
114
|
+
source = readFileSync(absPath, "utf-8");
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return { saved: false, reason: `Could not read file: ${e.message}` };
|
|
118
|
+
}
|
|
119
|
+
const hash = sha256(source);
|
|
120
|
+
const latest = stmts.getLatestBackup.get(absPath);
|
|
121
|
+
if (latest && latest.content_hash === hash) {
|
|
122
|
+
return { saved: false, reason: "Identical to latest backup — skipped", hash, size: latest.original_size };
|
|
123
|
+
}
|
|
124
|
+
const compressed = compress(source);
|
|
125
|
+
stmts.insertBackup.run(absPath, compressed, hash, Buffer.byteLength(source, "utf-8"), compressed.length, snapshotReason);
|
|
126
|
+
// Garbage-collect older versions beyond retention.
|
|
127
|
+
stmts.deleteOldBackups.run(absPath, absPath, BACKUP_RETENTION);
|
|
128
|
+
return { saved: true, reason: `Snapshot stored (${snapshotReason})`, hash, size: Buffer.byteLength(source, "utf-8") };
|
|
129
|
+
}
|
|
130
|
+
export const TUNABLES = {
|
|
131
|
+
MIN_KEEP_RATIO,
|
|
132
|
+
SMALL_FILE_BYTES,
|
|
133
|
+
CASCADE_WINDOW_SECONDS,
|
|
134
|
+
CASCADE_THRESHOLD,
|
|
135
|
+
BACKUP_RETENTION,
|
|
136
|
+
};
|