@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/agent-lens.js +40 -0
- package/bin/package.json +4 -0
- package/dist/src/archive/cron-log.js +41 -0
- package/dist/src/archive/discover.js +89 -0
- package/dist/src/archive/index.js +7 -0
- package/dist/src/archive/lock.js +119 -0
- package/dist/src/archive/log.js +15 -0
- package/dist/src/archive/mirror.js +366 -0
- package/dist/src/archive/paths.js +159 -0
- package/dist/src/archive/read.js +133 -0
- package/dist/src/archive/report.js +272 -0
- package/dist/src/archive/seal.js +76 -0
- package/dist/src/archive/sidecar.js +39 -0
- package/dist/src/cli/args.js +47 -0
- package/dist/src/cli/commands/archive.js +65 -0
- package/dist/src/cli/commands/doctor.js +191 -0
- package/dist/src/cli/commands/prune.js +159 -0
- package/dist/src/cli/commands/rebuild.js +98 -0
- package/dist/src/cli/commands/schedule.js +325 -0
- package/dist/src/cli/commands/start.js +83 -0
- package/dist/src/cli/commands/warm.js +96 -0
- package/dist/src/cli/index.js +102 -0
- package/dist/src/content/resolve.js +163 -0
- package/dist/src/corpus/env.js +32 -0
- package/dist/src/corpus/paths.js +70 -0
- package/dist/src/corpus/scan.js +85 -0
- package/dist/src/corpus/watch.js +189 -0
- package/dist/src/db/freshness.js +82 -0
- package/dist/src/db/open.js +74 -0
- package/dist/src/db/read.js +266 -0
- package/dist/src/db/schema.js +312 -0
- package/dist/src/db/sidecars.js +216 -0
- package/dist/src/db/spill-index.js +68 -0
- package/dist/src/db/write.js +279 -0
- package/dist/src/project/pipeline.js +307 -0
- package/dist/src/project/subagents.js +41 -0
- package/dist/src/project/tools.js +94 -0
- package/dist/src/server/api.js +249 -0
- package/dist/src/server/app.js +28 -0
- package/dist/src/server/config.js +24 -0
- package/dist/src/server/drift-report.js +35 -0
- package/dist/src/server/index.js +1 -0
- package/dist/src/server/live.js +109 -0
- package/dist/src/server/middleware/host-guard.js +42 -0
- package/dist/src/server/middleware/token-auth.js +19 -0
- package/dist/src/server/start.js +150 -0
- package/dist/src/server/static-ui.js +97 -0
- package/dist/src/server/stream.js +50 -0
- package/dist/src/server/warm.js +48 -0
- package/dist/src/shared/api.js +1 -0
- package/dist/src/shared/entities.js +1 -0
- package/dist/src/shared/index.js +2 -0
- package/dist/src/shared/pricing.js +68 -0
- package/dist/src/shared/token.js +39 -0
- package/dist/src/transcript/accessors.js +28 -0
- package/dist/src/transcript/agents.js +44 -0
- package/dist/src/transcript/blocks.js +75 -0
- package/dist/src/transcript/drift.js +42 -0
- package/dist/src/transcript/human.js +65 -0
- package/dist/src/transcript/line.js +251 -0
- package/dist/src/transcript/raw-types.js +1 -0
- package/dist/src/transcript/spill.js +122 -0
- package/dist/src/transcript/usage.js +63 -0
- package/dist/src/transcript/version.js +1 -0
- package/package.json +70 -0
- package/ui/dist/assets/index-CKKoKUCq.js +254 -0
- package/ui/dist/assets/index-Chza4fL6.css +1 -0
- package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- 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
|
+
}
|