@faithfulalabi/agent-lens 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/agent-lens.js +40 -0
  4. package/bin/package.json +4 -0
  5. package/dist/src/archive/cron-log.js +41 -0
  6. package/dist/src/archive/discover.js +89 -0
  7. package/dist/src/archive/index.js +7 -0
  8. package/dist/src/archive/lock.js +119 -0
  9. package/dist/src/archive/log.js +15 -0
  10. package/dist/src/archive/mirror.js +366 -0
  11. package/dist/src/archive/paths.js +159 -0
  12. package/dist/src/archive/read.js +133 -0
  13. package/dist/src/archive/report.js +272 -0
  14. package/dist/src/archive/seal.js +76 -0
  15. package/dist/src/archive/sidecar.js +39 -0
  16. package/dist/src/cli/args.js +47 -0
  17. package/dist/src/cli/commands/archive.js +65 -0
  18. package/dist/src/cli/commands/doctor.js +191 -0
  19. package/dist/src/cli/commands/prune.js +159 -0
  20. package/dist/src/cli/commands/rebuild.js +98 -0
  21. package/dist/src/cli/commands/schedule.js +325 -0
  22. package/dist/src/cli/commands/start.js +83 -0
  23. package/dist/src/cli/commands/warm.js +96 -0
  24. package/dist/src/cli/index.js +102 -0
  25. package/dist/src/content/resolve.js +163 -0
  26. package/dist/src/corpus/env.js +32 -0
  27. package/dist/src/corpus/paths.js +70 -0
  28. package/dist/src/corpus/scan.js +85 -0
  29. package/dist/src/corpus/watch.js +189 -0
  30. package/dist/src/db/freshness.js +82 -0
  31. package/dist/src/db/open.js +74 -0
  32. package/dist/src/db/read.js +266 -0
  33. package/dist/src/db/schema.js +312 -0
  34. package/dist/src/db/sidecars.js +216 -0
  35. package/dist/src/db/spill-index.js +68 -0
  36. package/dist/src/db/write.js +279 -0
  37. package/dist/src/project/pipeline.js +307 -0
  38. package/dist/src/project/subagents.js +41 -0
  39. package/dist/src/project/tools.js +94 -0
  40. package/dist/src/server/api.js +249 -0
  41. package/dist/src/server/app.js +28 -0
  42. package/dist/src/server/config.js +24 -0
  43. package/dist/src/server/drift-report.js +35 -0
  44. package/dist/src/server/index.js +1 -0
  45. package/dist/src/server/live.js +109 -0
  46. package/dist/src/server/middleware/host-guard.js +42 -0
  47. package/dist/src/server/middleware/token-auth.js +19 -0
  48. package/dist/src/server/start.js +150 -0
  49. package/dist/src/server/static-ui.js +97 -0
  50. package/dist/src/server/stream.js +50 -0
  51. package/dist/src/server/warm.js +48 -0
  52. package/dist/src/shared/api.js +1 -0
  53. package/dist/src/shared/entities.js +1 -0
  54. package/dist/src/shared/index.js +2 -0
  55. package/dist/src/shared/pricing.js +68 -0
  56. package/dist/src/shared/token.js +39 -0
  57. package/dist/src/transcript/accessors.js +28 -0
  58. package/dist/src/transcript/agents.js +44 -0
  59. package/dist/src/transcript/blocks.js +75 -0
  60. package/dist/src/transcript/drift.js +42 -0
  61. package/dist/src/transcript/human.js +65 -0
  62. package/dist/src/transcript/line.js +251 -0
  63. package/dist/src/transcript/raw-types.js +1 -0
  64. package/dist/src/transcript/spill.js +122 -0
  65. package/dist/src/transcript/usage.js +63 -0
  66. package/dist/src/transcript/version.js +1 -0
  67. package/package.json +70 -0
  68. package/ui/dist/assets/index-CKKoKUCq.js +254 -0
  69. package/ui/dist/assets/index-Chza4fL6.css +1 -0
  70. package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  71. package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  72. package/ui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  73. package/ui/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  74. package/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  75. package/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  76. package/ui/dist/index.html +21 -0
@@ -0,0 +1,312 @@
1
+ export const SCHEMA_VERSION = 2;
2
+ export const SCHEMA_DDL = `-- ~/.agent-lens/cache.db
3
+ --
4
+ -- THIS FILE IS A CACHE. Every byte is a pure function of ~/.agent-lens/archive/** --
5
+ -- the verbatim append-only mirror of the .jsonl transcripts, sibling agent-*.meta.json,
6
+ -- and tool-results/*.txt. Deleting it loses NOTHING — that is not a claim about care
7
+ -- taken, it is a claim about what is in here.
8
+ --
9
+ -- The ARCHIVE is a different matter: deleting THAT loses data permanently, because
10
+ -- Claude Code expires the originals (measured: nothing older than 41 days survives).
11
+ --
12
+ -- THERE IS NO MIGRATION RUNNER. A schema change bumps SCHEMA_VERSION; db/open.ts unlinks
13
+ -- cache.db (+ -wal/-shm) and re-sweeps (~190 ms for session files today). src/db/migrate.ts
14
+ -- (135 LOC) and src/db/migrations/** (178 LOC) are deleted.
15
+ --
16
+ -- TIER A (sessions rows, index columns) is built by SWEEPING and is persisted forever,
17
+ -- invalidated per file on (mtime,size).
18
+ -- TIER B (turns, events, events_fts) is PER SESSION, LAZY on first open, and DISPOSABLE.
19
+ -- Dropping one session's Tier B is 3 statements. Worst measured rebuild: 45 ms.
20
+
21
+ PRAGMA journal_mode = WAL;
22
+ PRAGMA synchronous = NORMAL;
23
+ PRAGMA foreign_keys = OFF; -- projections are dropped wholesale, per session, by hand
24
+ PRAGMA user_version = 2; -- SCHEMA_VERSION; mismatch => unlink + recreate
25
+
26
+ -- ===================================================================== sessions
27
+ -- The file index AND the session-list row AND the projection freshness header,
28
+ -- fused into one table. Sub-agent sidecars live here too (parent_session_id NOT NULL),
29
+ -- which is what makes "expand a sub-agent" a RECURSION into the same endpoint rather
30
+ -- than a second entity, a second projector and a second read path.
31
+ CREATE TABLE sessions (
32
+ id TEXT PRIMARY KEY, -- sessionId (filename stem), or agentId for a sidecar
33
+
34
+ -- SOURCE: ~/.claude/projects. Claude Code DELETES this (measured: dense 0-16 days, then
35
+ -- 1 file at 25, 5 at 30, 4 at 40, nothing beyond). Present-tense, not hypothetical.
36
+ source_path TEXT NOT NULL,
37
+ source_mtime_ms INTEGER, -- NULL once source_state='expired'
38
+ source_size INTEGER,
39
+ source_head_sha256 TEXT, -- first 4 KB; detects an in-place rewrite
40
+ source_state TEXT NOT NULL DEFAULT 'present', -- present | expired | diverged
41
+ -- MEASURED 2026-09-11 (293 sessions): every row
42
+ -- 'present'. 'expired' and 'diverged' are
43
+ -- UNREACHABLE BY CONSTRUCTION at the SQL level:
44
+ -- mirror.ts:363/:463 compute them, but neither
45
+ -- write.ts UPSERT carries this column — the same
46
+ -- shape as task 0.14's est_cost gap. Keep both
47
+ -- arms; see task 0.12.
48
+
49
+ -- ARCHIVE: ~/.agent-lens/archive. THE SYSTEM OF RECORD. Byte-identical, append-only.
50
+ -- archive_size is ALWAYS derived from statSync at read time, never trusted from here --
51
+ -- there is no counter to desynchronise, so there is no offset-commit-ordering problem.
52
+ -- line_ref offsets in \`events\` are ARCHIVE-relative, never source-relative: source-relative
53
+ -- refs break the moment Claude Code expires the file.
54
+ archive_path TEXT NOT NULL,
55
+ archive_size INTEGER NOT NULL DEFAULT 0, -- last observed; advisory only
56
+ archive_sha256 TEXT, -- full-content hash, set at seal time
57
+ archive_state TEXT NOT NULL DEFAULT 'hot', -- hot (raw) | sealed (.zst)
58
+ -- MEASURED 2026-09-11 (293 sessions): every row
59
+ -- 'hot'. 'sealed' is UNREACHABLE BY CONSTRUCTION
60
+ -- at the SQL level: mirror.ts:380 computes it,
61
+ -- but neither write.ts UPSERT carries this
62
+ -- column. Keep the arm; see task 0.12.
63
+ archived_at TEXT,
64
+ sealed_at TEXT,
65
+
66
+ -- The Tier-B invalidation key. Compared against the ARCHIVE, because that is what the
67
+ -- projector reads. Must ALSO fold max(child mtime) + sum(child size) across sidecars:
68
+ -- measured, a parent went 1,814 s without a write while 11 sidecars grew by 3.76 MB, and
69
+ -- parent-only invalidation froze every Agent row for 30 minutes.
70
+ file_mtime_ms INTEGER NOT NULL, -- \\
71
+ file_size INTEGER NOT NULL, -- / the invalidation key (archive-derived)
72
+
73
+ project_path TEXT NOT NULL, -- cwd; the list groups and filters on it
74
+ git_branch TEXT,
75
+ model TEXT, -- the model named on the most lines; NOT the last
76
+ harness_version TEXT, -- \`version\`; transcript-only, groups the drift report
77
+ title TEXT, -- LAST type:'ai-title' line
78
+ preview TEXT, -- first human prompt, 200 chars
79
+
80
+ started_at TEXT NOT NULL,
81
+ last_activity_at TEXT NOT NULL, -- last TOP-LEVEL timestamp; NOT the last nested one
82
+ -- (trailing file-history-snapshot records carry nested
83
+ -- timestamps up to 8 days later — measured)
84
+
85
+ -- OWN rollups: this file only
86
+ turn_count INTEGER NOT NULL DEFAULT 0, -- HUMAN prompts only (101 of 1,934 user lines)
87
+ tool_call_count INTEGER NOT NULL DEFAULT 0,
88
+ error_count INTEGER NOT NULL DEFAULT 0, -- is_error === true, never !is_error
89
+ tokens_in INTEGER NOT NULL DEFAULT 0,
90
+ tokens_out INTEGER NOT NULL DEFAULT 0, -- per-requestId LAST-line fold
91
+ tokens_cache_read INTEGER NOT NULL DEFAULT 0,
92
+ tokens_cache_write INTEGER NOT NULL DEFAULT 0,
93
+ est_cost REAL, -- NULL = unpriceable model, NEVER 0
94
+
95
+ -- SUB-AGENT rollups: SUM over child rows. 65% of all transcript bytes are sidecars, so a
96
+ -- session's cost is 2-6x wrong without these. Filled by the background sweep wave.
97
+ agent_count INTEGER NOT NULL DEFAULT 0,
98
+ sub_tool_call_count INTEGER NOT NULL DEFAULT 0,
99
+ sub_error_count INTEGER NOT NULL DEFAULT 0,
100
+ sub_tokens_in INTEGER NOT NULL DEFAULT 0,
101
+ sub_tokens_out INTEGER NOT NULL DEFAULT 0,
102
+ sub_tokens_cache_read INTEGER NOT NULL DEFAULT 0,
103
+ sub_tokens_cache_write INTEGER NOT NULL DEFAULT 0,
104
+ sub_est_cost REAL,
105
+ rollup_state TEXT NOT NULL DEFAULT 'own', -- 'own' | 'complete'
106
+
107
+ -- sidecar linkage (NULL on a top-level session)
108
+ parent_session_id TEXT,
109
+ spawned_by_event_id TEXT, -- the Agent tool_call event id (meta.json toolUseId)
110
+ agent_type TEXT, -- 'approach-critic' | 'Explore' | ...
111
+ agent_description TEXT,
112
+ spawn_depth INTEGER,
113
+
114
+ -- Tier B freshness. ALL THREE must match to count as a cache hit.
115
+ projected_mtime_ms INTEGER, -- NULL => never projected
116
+ projected_size INTEGER,
117
+ projector_version INTEGER,
118
+ projected_at TEXT,
119
+ projection_state TEXT NOT NULL DEFAULT 'none', -- none | ready | failed | empty
120
+ -- 'empty' is the tombstone: the file
121
+ -- projected no header, so it has no turns,
122
+ -- events or FTS rows and must not be re-read
123
+ -- MEASURED 2026-09-11 (293 sessions): every row
124
+ -- 'ready'. 'none', 'failed' and 'empty' have 0
125
+ -- rows, REACHABLE BUT UNOBSERVED — 'none' is
126
+ -- this DEFAULT itself, its only writer;
127
+ -- 'failed' write.ts:344; 'empty' write.ts:280.
128
+ -- Keep all arms; see task 0.12.
129
+ projection_error TEXT,
130
+ drift_json TEXT NOT NULL DEFAULT '{}'
131
+ );
132
+
133
+ -- The partial predicate keeps sidecars — 65% of the corpus by bytes — out of every list scan.
134
+ -- The \`, id DESC\` tail makes the ORDER BY total and single-pass, fixing the documented
135
+ -- "no tiebreaker for same-millisecond sessions" wart in today's db/reads.ts.
136
+ CREATE INDEX idx_sessions_recent ON sessions(last_activity_at DESC, id DESC)
137
+ WHERE parent_session_id IS NULL;
138
+ CREATE INDEX idx_sessions_project ON sessions(project_path, last_activity_at DESC, id DESC)
139
+ WHERE parent_session_id IS NULL;
140
+ CREATE INDEX idx_sessions_parent ON sessions(parent_session_id)
141
+ WHERE parent_session_id IS NOT NULL;
142
+
143
+ -- ======================================================================== turns
144
+ -- One row per promptId group. The collapsible header on screen 2.
145
+ CREATE TABLE turns (
146
+ id TEXT PRIMARY KEY, -- '<session_id>:<seq>'
147
+ session_id TEXT NOT NULL,
148
+ seq INTEGER NOT NULL,
149
+ kind TEXT NOT NULL, -- human | task_notification | slash_command
150
+ -- | compaction | system | unknown
151
+ -- MEASURED 2026-09-11 (647 turns): 'compaction'
152
+ -- and 'system' have 0 rows, REACHABLE BUT
153
+ -- UNOBSERVED in this corpus — writer
154
+ -- pipeline.ts:329-330, every arm pinned by the
155
+ -- turn-kinds.jsonl fixture
156
+ -- (pipeline.test.ts:227). Keep the arms; see
157
+ -- task 0.12.
158
+ parent_event_id TEXT, -- BLOCKING FIX #3. When kind='task_notification',
159
+ -- the Agent tool_call event that spawned it (join on
160
+ -- <tool-use-id>), so machinery FOLDS under its Agent
161
+ -- row instead of rendering as a top-level turn.
162
+ -- Measured without it: 15 of 19 turns in one session
163
+ -- and 17 of 28 in another render as raw XML headers.
164
+ title TEXT NOT NULL, -- prompt text, 200 chars
165
+ started_at TEXT NOT NULL,
166
+ ended_at TEXT,
167
+ duration_ms INTEGER,
168
+ duration_source TEXT, -- 'turn_duration' (system line) | 'derived'
169
+ tokens_in INTEGER NOT NULL DEFAULT 0,
170
+ tokens_out INTEGER NOT NULL DEFAULT 0,
171
+ tokens_cache_read INTEGER NOT NULL DEFAULT 0,
172
+ tokens_cache_write INTEGER NOT NULL DEFAULT 0,
173
+ est_cost REAL,
174
+ tool_call_count INTEGER NOT NULL DEFAULT 0,
175
+ error_count INTEGER NOT NULL DEFAULT 0,
176
+ first_seq INTEGER NOT NULL, -- the event window this turn owns; also the live cursor
177
+ last_seq INTEGER NOT NULL
178
+ );
179
+ CREATE UNIQUE INDEX idx_turns_session_seq ON turns(session_id, seq);
180
+
181
+ -- ======================================================================= events
182
+ -- THE table. One row per thing the UI draws, in file order. A tool_use and its
183
+ -- tool_result are ONE row, not two — that fold is what deletes both \`spans\` and
184
+ -- \`messages\` from the old model. Content is INLINE up to 64 KB (measured p99 = 31.7 KB),
185
+ -- so the thread renders with ZERO follow-up requests; above that we store an 8 KB head
186
+ -- preview plus a (offset,len,block) REF back into the .jsonl, which honours
187
+ -- "never copy the transcript" and drops the measured 13.0 ms of 15.1 ms payload write.
188
+ CREATE TABLE events (
189
+ id TEXT PRIMARY KEY, -- 'toolu_...' for a tool_call, else '<uuid>:<blockIndex>'
190
+ -- (0 duplicate uuids across 23,109 corpus-wide)
191
+ session_id TEXT NOT NULL,
192
+ turn_id TEXT NOT NULL,
193
+ seq INTEGER NOT NULL, -- thread order; deterministic from the file bytes
194
+ kind TEXT NOT NULL, -- prompt | text | thinking | tool_call
195
+ -- | error | compaction | unknown
196
+ -- MEASURED 2026-09-11 (30,286 events): 'error'
197
+ -- has 0 rows, REACHABLE BUT UNOBSERVED — writer
198
+ -- pipeline.ts:306, gated on the harness line
199
+ -- subtype 'api_error'. Keep the arm; see
200
+ -- task 0.12.
201
+ ts TEXT NOT NULL,
202
+ request_id TEXT, -- groups the N lines of ONE assistant response
203
+ -- (1:1 with message.id; 13,629/13,630 lines = 1 block)
204
+ block_index INTEGER,
205
+
206
+ name TEXT, -- tool name, for kind='tool_call'
207
+ status TEXT, -- ok | error | denied | running
208
+ duration_ms INTEGER,
209
+ duration_source TEXT, -- 'elapsed' | 'sidecar_span' | 'reported'
210
+ -- NEVER labelled "execution": a 61 ms Bash reads
211
+ -- 8,063 ms elapsed when a human sat on the approval
212
+ -- MEASURED 2026-09-11 (293 sessions, 30,286
213
+ -- events): 'reported' is UNREACHABLE BY
214
+ -- CONSTRUCTION — pipeline.ts:130 types this
215
+ -- field 'elapsed' | 'sidecar_span' | undefined.
216
+ -- Keep the arm; see task 0.12.
217
+
218
+ input TEXT, -- tool_use.input as JSON text (full, or 8 KB preview)
219
+ input_bytes INTEGER, -- TRUE logical size
220
+ input_storage TEXT, -- inline | line_ref | absent
221
+ -- MEASURED 2026-09-11 (30,286 events): 'absent'
222
+ -- has 0 rows, REACHABLE BUT UNOBSERVED — writer
223
+ -- tools.ts:118, unwitnessed by its own comment.
224
+ -- 'line_ref' is live at exactly 1 row. Keep the
225
+ -- arms; see task 0.12.
226
+ text TEXT, -- prose / thinking / prompt / tool output
227
+ text_bytes INTEGER,
228
+ output_storage TEXT, -- inline | line_ref | spill | missing | absent
229
+ -- MEASURED 2026-09-11 (30,286 events):
230
+ -- 'line_ref' has 0 rows, REACHABLE BUT RARE —
231
+ -- tools.ts:166 fires above INLINE_MAX (64 KiB),
232
+ -- and the input sibling arm has fired once.
233
+ -- Keep the arm; see task 0.12.
234
+ spill_path TEXT, -- resolved <session-dir>/tool-results/*.txt
235
+ spill_bytes INTEGER,
236
+
237
+ -- provenance + the line_ref resolver's coordinates. File path is derivable from
238
+ -- sessions.file_path, because a sidecar IS a sessions row.
239
+ src_offset INTEGER NOT NULL, -- byte offset of the emitting line
240
+ src_len INTEGER NOT NULL,
241
+ result_offset INTEGER, -- byte offset of the tool_result line
242
+ result_len INTEGER,
243
+ result_block INTEGER,
244
+
245
+ model TEXT, -- \\ stamped on the FIRST event of each requestId
246
+ tokens_in INTEGER, -- } group ONLY. Naive summing is 1.51x HIGH,
247
+ tokens_out INTEGER, -- } first-line-only is 2.6x LOW; correct is the
248
+ tokens_cache_read INTEGER, -- } LAST line's output_tokens with input/cache
249
+ tokens_cache_write INTEGER, -- } taken once per requestId.
250
+ est_cost REAL, -- /
251
+
252
+ child_session_id TEXT, -- Agent tool_call -> the sidecar's sessions row
253
+ agent_type TEXT,
254
+ agent_status TEXT, -- completed | failed | killed | running
255
+ -- MEASURED 2026-09-11 (30,286 events): 'killed'
256
+ -- has 0 rows, REACHABLE BUT UNOBSERVED — writer
257
+ -- tools.ts:203 copies the notification status
258
+ -- verbatim; parser proof agents.test.ts:104.
259
+ -- Keep the arm; see task 0.12.
260
+
261
+ raw_type TEXT NOT NULL, -- the harness's own \`type\`, VERBATIM
262
+ raw_subtype TEXT,
263
+ attrs TEXT NOT NULL DEFAULT '{}' -- ALLOWLIST ONLY: attributionSkill,
264
+ -- attributionAgent, effort, permissionMode,
265
+ -- compactMetadata, toolDenialKind, unknown_fields[]
266
+ );
267
+ CREATE UNIQUE INDEX idx_events_session_seq ON events(session_id, seq);
268
+ CREATE INDEX idx_events_turn ON events(turn_id, seq);
269
+ CREATE INDEX idx_events_slow ON events(session_id, kind, duration_ms DESC);
270
+ CREATE INDEX idx_events_child ON events(child_session_id)
271
+ WHERE child_session_id IS NOT NULL;
272
+
273
+ -- =================================================================== full text
274
+ -- External-content FTS5 over events(text, input): in-session search on day one and
275
+ -- cross-session search over every projected session, from ONE table. This is the
276
+ -- concrete replacement for today's payloads_fts, which migration 001 creates and
277
+ -- NOTHING populates. Dropping a session's projection MUST run the 'delete' idiom
278
+ -- first; db/write.ts:deleteSessionProjection() is the only place that does it, and a
279
+ -- test asserts INSERT INTO events_fts(events_fts, rank) VALUES('integrity-check', 1)
280
+ -- passes after 3 drop/reproject cycles, and that the inverted order throws
281
+ -- 'database disk image is malformed' on the FIRST cycle.
282
+ -- CORRECTED 2026-08-19 by measurement: this used to ask for fts row count == events
283
+ -- row count, which is vacuous here. count(*) on an external-content table delegates
284
+ -- to the content table, so it reads equal in the inverted order too, and bare
285
+ -- integrity-check answers ok in every broken state. Only the rank=1 form discriminates.
286
+ CREATE VIRTUAL TABLE events_fts USING fts5(
287
+ text,
288
+ input,
289
+ content='events',
290
+ content_rowid='rowid',
291
+ tokenize='unicode61 remove_diacritics 2'
292
+ );
293
+
294
+ -- ======================================================================== meta
295
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
296
+ -- seeded: schema_version, projector_version, projects_root, index_built_at
297
+
298
+ -- =================================================================== spill_fts
299
+ -- Spilled tool-output bodies, which events_fts cannot see (a spill row keeps text
300
+ -- NULL). PLAIN FTS5, so a plain DELETE is legal here, unlike events_fts. NOT dropped
301
+ -- by deleteSessionProjection: rows are written and reconciled by db/spill-index.ts
302
+ -- alone. input is always NULL; it exists so an input: filter parses on the UNION ALL.
303
+ CREATE VIRTUAL TABLE spill_fts USING fts5(
304
+ event_id UNINDEXED,
305
+ session_id UNINDEXED,
306
+ spill_path UNINDEXED,
307
+ text,
308
+ input UNINDEXED,
309
+ tokenize='unicode61 remove_diacritics 2'
310
+ );
311
+ -- The drain's candidate scan: O(spills) at 1 Hz, not a scan of every event.
312
+ CREATE INDEX idx_events_spill ON events(session_id) WHERE output_storage = 'spill';`;
@@ -0,0 +1,216 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createArchiveReader } from '../archive/read.js';
4
+ import { parseAgentMeta } from '../transcript/agents.js';
5
+ import { DriftCounter } from '../transcript/drift.js';
6
+ import { classifyLine, foldSessionEnvelope } from '../transcript/line.js';
7
+ import { foldArchive } from './freshness.js';
8
+ const SUBAGENTS_DIR = 'subagents';
9
+ const TRANSCRIPT_EXT = '.jsonl';
10
+ const META_EXT = '.meta.json';
11
+ const AGENT_PREFIX = 'agent-';
12
+ const SEALED_SUFFIX = '.zst';
13
+ const NEWLINE = 0x0a;
14
+ const CARRIAGE_RETURN = 0x0d;
15
+ const START_WINDOW = 16 * 1024;
16
+ const MAX_WINDOW = 1024 * 1024;
17
+ export function enclosingSubagentsDir(path) {
18
+ const marker = `${SUBAGENTS_DIR}/`;
19
+ const at = path.split('\\').join('/').lastIndexOf(`/${marker}`);
20
+ if (at !== -1)
21
+ return path.slice(0, at + marker.length);
22
+ if (!path.endsWith(TRANSCRIPT_EXT))
23
+ return undefined;
24
+ return join(path.slice(0, -TRANSCRIPT_EXT.length), SUBAGENTS_DIR);
25
+ }
26
+ function metaEntries(dir) {
27
+ let names;
28
+ try {
29
+ names = readdirSync(dir, { recursive: true, encoding: 'utf8' });
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ const logical = new Set();
35
+ for (const raw of names) {
36
+ const name = raw.split('\\').join('/');
37
+ const stripped = name.endsWith(SEALED_SUFFIX) ? name.slice(0, -SEALED_SUFFIX.length) : name;
38
+ const leaf = stripped.slice(stripped.lastIndexOf('/') + 1);
39
+ if (leaf.startsWith(AGENT_PREFIX) && leaf.endsWith(META_EXT))
40
+ logical.add(stripped);
41
+ }
42
+ return [...logical].sort();
43
+ }
44
+ function firstLine(buf, whole) {
45
+ const at = buf.indexOf(NEWLINE);
46
+ if (at !== -1)
47
+ return { text: buf.subarray(0, at).toString('utf8'), byteOffset: 0 };
48
+ return whole ? { text: buf.toString('utf8'), byteOffset: 0 } : undefined;
49
+ }
50
+ function lastLine(buf, base, whole) {
51
+ let end = buf.length;
52
+ while (end > 0 && (buf[end - 1] === NEWLINE || buf[end - 1] === CARRIAGE_RETURN))
53
+ end -= 1;
54
+ const at = buf.subarray(0, end).lastIndexOf(NEWLINE);
55
+ if (at !== -1) {
56
+ return { text: buf.subarray(at + 1, end).toString('utf8'), byteOffset: base + at + 1 };
57
+ }
58
+ return whole ? { text: buf.subarray(0, end).toString('utf8'), byteOffset: base } : undefined;
59
+ }
60
+ function windows(reader, path, size, window) {
61
+ const span = window >= size ? size : window;
62
+ const base = size - span;
63
+ return {
64
+ head: reader.read(path, 0, span),
65
+ tail: reader.read(path, base, span),
66
+ base,
67
+ whole: span === size,
68
+ };
69
+ }
70
+ function headTail(reader, path, size) {
71
+ if (size === 0)
72
+ return undefined;
73
+ for (let window = START_WINDOW;; window *= 2) {
74
+ const at = windows(reader, path, size, window);
75
+ const head = firstLine(at.head, at.whole);
76
+ const tail = lastLine(at.tail, at.base, at.whole);
77
+ if (head !== undefined && tail !== undefined)
78
+ return [head, tail];
79
+ if (at.whole || window >= MAX_WINDOW)
80
+ return undefined;
81
+ }
82
+ }
83
+ function wholeLines(buf, base, atEof) {
84
+ const lines = [];
85
+ const push = (from, to) => {
86
+ let end = to;
87
+ while (end > from && (buf[end - 1] === NEWLINE || buf[end - 1] === CARRIAGE_RETURN))
88
+ end -= 1;
89
+ if (end > from) {
90
+ lines.push({ text: buf.subarray(from, end).toString('utf8'), byteOffset: base + from });
91
+ }
92
+ };
93
+ let start = 0;
94
+ if (base > 0) {
95
+ const at = buf.indexOf(NEWLINE);
96
+ if (at === -1)
97
+ return lines;
98
+ start = at + 1;
99
+ }
100
+ for (;;) {
101
+ const at = buf.indexOf(NEWLINE, start);
102
+ if (at === -1)
103
+ break;
104
+ push(start, at);
105
+ start = at + 1;
106
+ }
107
+ if (atEof)
108
+ push(start, buf.length);
109
+ return lines;
110
+ }
111
+ export function readSessionEnvelope(reader, path, projectPathSeed) {
112
+ try {
113
+ const size = reader.size(path);
114
+ if (size === 0)
115
+ return undefined;
116
+ for (let window = START_WINDOW;; window *= 2) {
117
+ const at = windows(reader, path, size, window);
118
+ const lines = at.whole
119
+ ? wholeLines(at.head, 0, true)
120
+ : [...wholeLines(at.head, 0, false), ...wholeLines(at.tail, at.base, true)];
121
+ const { project_path, started_at, last_activity_at } = foldSessionEnvelope(parseWindow(lines));
122
+ const atCap = at.whole || window >= MAX_WINDOW;
123
+ if (started_at !== undefined && last_activity_at !== undefined) {
124
+ if (project_path !== undefined)
125
+ return { project_path, started_at, last_activity_at };
126
+ if (atCap && projectPathSeed !== undefined) {
127
+ return { project_path: projectPathSeed, started_at, last_activity_at };
128
+ }
129
+ }
130
+ if (atCap)
131
+ return undefined;
132
+ }
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ function parseWindow(lines) {
139
+ const drift = new DriftCounter();
140
+ const parsed = [];
141
+ for (const line of lines) {
142
+ try {
143
+ parsed.push(classifyLine(JSON.parse(line.text), {
144
+ byteOffset: line.byteOffset,
145
+ byteLength: Buffer.byteLength(line.text, 'utf8'),
146
+ drift,
147
+ }));
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ }
153
+ return parsed;
154
+ }
155
+ function readEnvelope(reader, path) {
156
+ try {
157
+ const ends = headTail(reader, path, reader.size(path));
158
+ if (ends === undefined)
159
+ return undefined;
160
+ const drift = new DriftCounter();
161
+ const envelope = foldSessionEnvelope(ends.map((end) => classifyLine(JSON.parse(end.text), {
162
+ byteOffset: end.byteOffset,
163
+ byteLength: Buffer.byteLength(end.text, 'utf8'),
164
+ drift,
165
+ })));
166
+ const { project_path, started_at, last_activity_at } = envelope;
167
+ if (project_path === undefined)
168
+ return undefined;
169
+ if (started_at === undefined || last_activity_at === undefined)
170
+ return undefined;
171
+ return { project_path, started_at, last_activity_at };
172
+ }
173
+ catch {
174
+ return undefined;
175
+ }
176
+ }
177
+ export function readSidecars(archivePath, sourcePath, toolUseIds, reader = createArchiveReader()) {
178
+ if (toolUseIds.size === 0)
179
+ return [];
180
+ const archiveDir = enclosingSubagentsDir(archivePath);
181
+ const sourceDir = enclosingSubagentsDir(sourcePath);
182
+ if (archiveDir === undefined || sourceDir === undefined)
183
+ return [];
184
+ const descriptors = [];
185
+ for (const rel of metaEntries(archiveDir)) {
186
+ const metaPath = join(archiveDir, rel);
187
+ let meta;
188
+ try {
189
+ meta = parseAgentMeta(JSON.parse(reader.read(metaPath, 0, reader.size(metaPath)).toString()));
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ if (meta?.toolUseId === undefined || !toolUseIds.has(meta.toolUseId))
195
+ continue;
196
+ const stem = rel.slice(0, -META_EXT.length);
197
+ const transcript = `${stem}${TRANSCRIPT_EXT}`;
198
+ const childArchivePath = join(archiveDir, transcript);
199
+ const fold = foldArchive(childArchivePath);
200
+ if (fold === undefined)
201
+ continue;
202
+ const envelope = readEnvelope(reader, childArchivePath);
203
+ if (envelope === undefined)
204
+ continue;
205
+ descriptors.push({
206
+ agent_id: stem.slice(stem.lastIndexOf('/') + 1 + AGENT_PREFIX.length),
207
+ archive_path: childArchivePath,
208
+ source_path: join(sourceDir, transcript),
209
+ mtime_ms: fold.mtime_ms,
210
+ size: fold.size,
211
+ meta,
212
+ envelope,
213
+ });
214
+ }
215
+ return descriptors;
216
+ }
@@ -0,0 +1,68 @@
1
+ import { EVENT_CONTENT_COLUMNS } from './read.js';
2
+ export const SPILL_INDEX_MAX_BYTES = 8 * 1024 * 1024;
3
+ const INDEXED_SQL = `SELECT f.rowid AS rowid, f.event_id AS event_id, f.spill_path AS spill_path,
4
+ e.output_storage AS current_storage, e.spill_path AS current_path
5
+ FROM spill_fts f LEFT JOIN events e ON e.id = f.event_id
6
+ ORDER BY f.rowid`;
7
+ const CANDIDATES_SQL = `SELECT ${EVENT_CONTENT_COLUMNS} FROM events
8
+ WHERE output_storage = 'spill' AND spill_path IS NOT NULL
9
+ ORDER BY session_id, seq`;
10
+ const CONTENT_ROW_SQL = `SELECT ${EVENT_CONTENT_COLUMNS} FROM events WHERE id = ?`;
11
+ const DELETE_SQL = `DELETE FROM spill_fts WHERE rowid = ?`;
12
+ const INSERT_SQL = `INSERT INTO spill_fts(event_id, session_id, spill_path, text, input)
13
+ VALUES (?, ?, ?, ?, NULL)`;
14
+ const keyOf = (event_id, spill_path) => `${event_id}\u0000${spill_path}`;
15
+ export function indexSpills(db, env, options = {}) {
16
+ const report = { indexed: 0, removed: 0, skipped: [] };
17
+ const indexed = new Set();
18
+ const remove = db.prepare(DELETE_SQL);
19
+ const contentRow = db.prepare(CONTENT_ROW_SQL);
20
+ for (const row of db.prepare(INDEXED_SQL).all()) {
21
+ const content = row.current_storage === 'spill' && row.current_path === row.spill_path
22
+ ? contentRow.get(row.event_id)
23
+ : undefined;
24
+ if (content === undefined || env.locate(content) === undefined) {
25
+ remove.run(row.rowid);
26
+ report.removed += 1;
27
+ }
28
+ else {
29
+ indexed.add(keyOf(row.event_id, row.spill_path));
30
+ }
31
+ }
32
+ const { now, startedAt, deadlineMs } = options;
33
+ const budgeted = now !== undefined && startedAt !== undefined && deadlineMs !== undefined;
34
+ const insert = db.prepare(INSERT_SQL);
35
+ let bodies = 0;
36
+ for (const row of db.prepare(CANDIDATES_SQL).all()) {
37
+ const path = row.spill_path;
38
+ if (indexed.has(keyOf(row.id, path)))
39
+ continue;
40
+ if (budgeted && bodies > 0 && now() - startedAt >= deadlineMs)
41
+ break;
42
+ if (row.spill_bytes !== null && row.spill_bytes > SPILL_INDEX_MAX_BYTES) {
43
+ report.skipped.push(path);
44
+ continue;
45
+ }
46
+ let body;
47
+ try {
48
+ body = env.resolve(row, 'text');
49
+ }
50
+ catch {
51
+ report.skipped.push(path);
52
+ continue;
53
+ }
54
+ if (body.storage !== 'spill') {
55
+ report.skipped.push(path);
56
+ continue;
57
+ }
58
+ bodies += 1;
59
+ if (body.byte_size > SPILL_INDEX_MAX_BYTES) {
60
+ report.skipped.push(path);
61
+ continue;
62
+ }
63
+ insert.run(row.id, row.session_id, path, body.content);
64
+ indexed.add(keyOf(row.id, path));
65
+ report.indexed += 1;
66
+ }
67
+ return report;
68
+ }