@druumen/sessions-db 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/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- package/types/watch.d.mts +33 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a canonical event. Auto-fills `ts` (ISO ms) and `event_id`
|
|
3
|
+
* (UUIDv7 with `evt_` prefix — same generator as session IDs but with a
|
|
4
|
+
* different prefix so naive lookups can distinguish them).
|
|
5
|
+
*
|
|
6
|
+
* @param {{ op: string, stable_id: string, payload?: object,
|
|
7
|
+
* ts?: string, event_id?: string }} input
|
|
8
|
+
*/
|
|
9
|
+
export function newEvent({ op, stable_id, payload, ts, event_id }: {
|
|
10
|
+
op: string;
|
|
11
|
+
stable_id: string;
|
|
12
|
+
payload?: object;
|
|
13
|
+
ts?: string;
|
|
14
|
+
event_id?: string;
|
|
15
|
+
}): {
|
|
16
|
+
ts: string;
|
|
17
|
+
event_id: string;
|
|
18
|
+
op: string;
|
|
19
|
+
stable_id: string;
|
|
20
|
+
payload: any;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Append an event to events.jsonl. **No lock** — relies on POSIX O_APPEND
|
|
24
|
+
* atomicity for concurrent multi-process append safety, which is only
|
|
25
|
+
* guaranteed for writes ≤ PIPE_BUF (4 KiB). We enforce that bound via
|
|
26
|
+
* MAX_EVENT_BYTES and reject oversized events instead of silently risking
|
|
27
|
+
* interleave.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} event
|
|
30
|
+
* @param {{ paths?: typeof PATHS, root?: string }} [opts]
|
|
31
|
+
* @throws {Error} when the serialized line exceeds MAX_EVENT_BYTES — caller
|
|
32
|
+
* must reduce payload size or split into multiple smaller events.
|
|
33
|
+
*/
|
|
34
|
+
export function appendEvent(event: object, opts?: {
|
|
35
|
+
paths?: typeof PATHS;
|
|
36
|
+
root?: string;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Read events.jsonl into structured `{ events, corruptions }` output.
|
|
40
|
+
*
|
|
41
|
+
* Distinguishes two corruption modes:
|
|
42
|
+
* - tail_partial: malformed line is the last non-empty line of the file —
|
|
43
|
+
* almost always a write-in-progress (writer crashed or we read mid-write).
|
|
44
|
+
* Tolerated; surfaced in `corruptions` for diagnostics but does not block
|
|
45
|
+
* rebuild.
|
|
46
|
+
* - middle_corruption: malformed line has at least one valid line after it
|
|
47
|
+
* in the file. This implies real data damage (filesystem error, partial
|
|
48
|
+
* overwrite, manual edit). Surfaced as a fatal corruption that callers
|
|
49
|
+
* (rebuildProjection) escalate to an exception.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ paths?: typeof PATHS, root?: string }} [opts]
|
|
52
|
+
* @returns {{ events: Array<object>, corruptions: Array<{
|
|
53
|
+
* lineNumber: number, kind: 'tail_partial'|'middle_corruption',
|
|
54
|
+
* tolerated: boolean, excerpt: string, error: string }> }}
|
|
55
|
+
*/
|
|
56
|
+
export function readAllEvents(opts?: {
|
|
57
|
+
paths?: typeof PATHS;
|
|
58
|
+
root?: string;
|
|
59
|
+
}): {
|
|
60
|
+
events: Array<object>;
|
|
61
|
+
corruptions: Array<{
|
|
62
|
+
lineNumber: number;
|
|
63
|
+
kind: "tail_partial" | "middle_corruption";
|
|
64
|
+
tolerated: boolean;
|
|
65
|
+
excerpt: string;
|
|
66
|
+
error: string;
|
|
67
|
+
}>;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Load the projection cache from disk. On missing/corrupt file, falls back
|
|
71
|
+
* to a full rebuild from events.jsonl.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ paths?: typeof PATHS, root?: string }} [opts]
|
|
74
|
+
* @returns {Promise<object>}
|
|
75
|
+
*/
|
|
76
|
+
export function loadProjection(opts?: {
|
|
77
|
+
paths?: typeof PATHS;
|
|
78
|
+
root?: string;
|
|
79
|
+
}): Promise<object>;
|
|
80
|
+
/**
|
|
81
|
+
* Atomically write the projection cache.
|
|
82
|
+
*
|
|
83
|
+
* Default behavior acquires the file lock around the entire write. Callers
|
|
84
|
+
* that already hold the lock (e.g. `tryUpdateProjection`'s read-modify-write
|
|
85
|
+
* cycle) pass `withLock: false` to avoid double-acquire deadlock.
|
|
86
|
+
*
|
|
87
|
+
* Steps (with lock):
|
|
88
|
+
* 1. Acquire the file lock
|
|
89
|
+
* 2. Write to `<projection>.tmp.<pid>`
|
|
90
|
+
* 3. fsync the tmp file
|
|
91
|
+
* 4. rename tmp → real (atomic on POSIX)
|
|
92
|
+
* 5. Release the lock
|
|
93
|
+
*
|
|
94
|
+
* On any error, attempts to clean up the tmp file before propagating.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} projection
|
|
97
|
+
* @param {{ paths?: typeof PATHS, root?: string,
|
|
98
|
+
* lockTimeoutMs?: number, lockRetryMs?: number,
|
|
99
|
+
* withLock?: boolean }} [opts]
|
|
100
|
+
*/
|
|
101
|
+
export function saveProjection(projection: object, opts?: {
|
|
102
|
+
paths?: typeof PATHS;
|
|
103
|
+
root?: string;
|
|
104
|
+
lockTimeoutMs?: number;
|
|
105
|
+
lockRetryMs?: number;
|
|
106
|
+
withLock?: boolean;
|
|
107
|
+
}): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* Full rebuild: scan events.jsonl, fold into a fresh projection, persist.
|
|
110
|
+
*
|
|
111
|
+
* Returns `toleratedCorruptions` so callers can surface diagnostics
|
|
112
|
+
* (tail-partial lines from interrupted writes are common during heavy
|
|
113
|
+
* concurrent load and worth observing). Middle-line corruption escalates
|
|
114
|
+
* to a thrown error from `readAllEventsOrThrow` and never reaches here.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ paths?: typeof PATHS, root?: string,
|
|
117
|
+
* lockTimeoutMs?: number, lockRetryMs?: number }} [opts]
|
|
118
|
+
* @returns {Promise<{ sessionCount: number, eventCount: number,
|
|
119
|
+
* toleratedCorruptions: number }>}
|
|
120
|
+
*/
|
|
121
|
+
export function rebuildProjection(opts?: {
|
|
122
|
+
paths?: typeof PATHS;
|
|
123
|
+
root?: string;
|
|
124
|
+
lockTimeoutMs?: number;
|
|
125
|
+
lockRetryMs?: number;
|
|
126
|
+
}): Promise<{
|
|
127
|
+
sessionCount: number;
|
|
128
|
+
eventCount: number;
|
|
129
|
+
toleratedCorruptions: number;
|
|
130
|
+
}>;
|
|
131
|
+
/**
|
|
132
|
+
* Best-effort incremental update for hook callers.
|
|
133
|
+
*
|
|
134
|
+
* Pattern (from Phase 1 ticket §"Hook caller pattern"):
|
|
135
|
+
* 1. Build event with newEvent()
|
|
136
|
+
* 2. Append it to events.jsonl FIRST via O_APPEND (race-safe SSoT)
|
|
137
|
+
* 3. Acquire the projection lock and run the full read-modify-write under
|
|
138
|
+
* the lock so concurrent hooks cannot read the same baseline projection
|
|
139
|
+
* and clobber each other's derived state.
|
|
140
|
+
* 4. If anything fails, return `{ ok: false, error }` — the SSoT is
|
|
141
|
+
* already consistent, so the next rebuild reconciles the projection.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} event
|
|
144
|
+
* @param {{ paths?: typeof PATHS, root?: string,
|
|
145
|
+
* lockTimeoutMs?: number, lockRetryMs?: number }} [opts]
|
|
146
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
147
|
+
*/
|
|
148
|
+
export function tryUpdateProjection(event: object, opts?: {
|
|
149
|
+
paths?: typeof PATHS;
|
|
150
|
+
root?: string;
|
|
151
|
+
lockTimeoutMs?: number;
|
|
152
|
+
lockRetryMs?: number;
|
|
153
|
+
}): Promise<{
|
|
154
|
+
ok: boolean;
|
|
155
|
+
error?: string;
|
|
156
|
+
}>;
|
|
157
|
+
/**
|
|
158
|
+
* Atomic transaction for `session_seen` events.
|
|
159
|
+
*
|
|
160
|
+
* Why a dedicated entry point instead of "lookup → mint → tryUpdateProjection"?
|
|
161
|
+
*
|
|
162
|
+
* The hook's old flow ran `loadProjection` outside the lock to look up an
|
|
163
|
+
* existing stable_id by claude_session_id, minted a fresh one on miss, built
|
|
164
|
+
* the event with that stable_id, then handed the event to
|
|
165
|
+
* `tryUpdateProjection`. With two concurrent hooks for the SAME
|
|
166
|
+
* claude_session_id, both would observe an empty projection during the
|
|
167
|
+
* unlocked lookup, both mint different stable_ids, both append events under
|
|
168
|
+
* different stable_ids, and the projection would split into two records
|
|
169
|
+
* for the same logical session.
|
|
170
|
+
*
|
|
171
|
+
* P3 (this phase) extends the resolution from "claude_session_id only" to
|
|
172
|
+
* the full 3-priority chain implemented in `identity.mjs`:
|
|
173
|
+
*
|
|
174
|
+
* 1. claude_session_id_index (exact) — baseline P2 behavior
|
|
175
|
+
* 2. transcript_lineage (high) — covers fork/resume
|
|
176
|
+
* 3. fingerprint_corroborator (low) — soft cross-session match
|
|
177
|
+
*
|
|
178
|
+
* On any miss → mint via uuidv7. Fingerprint matches without enough
|
|
179
|
+
* corroborators are surfaced as `parent_candidate_ids[]` (hub-spoke hints —
|
|
180
|
+
* NOT auto-promoted to parent_session_id).
|
|
181
|
+
*
|
|
182
|
+
* Critical-section flow (held under projection lock end-to-end):
|
|
183
|
+
*
|
|
184
|
+
* 1. Acquire projection lock
|
|
185
|
+
* 2. Load projection inside lock
|
|
186
|
+
* 3. Run resolveIdentity() against the baseline projection (P1→P2→P3→mint)
|
|
187
|
+
* 4. Call `payloadBuilder(stableId, identityResolution)` for the payload
|
|
188
|
+
* 5. Auto-inject `identity_resolution` + merged `parent_candidate_ids`
|
|
189
|
+
* into the payload so the audit trail is always present
|
|
190
|
+
* 6. Build canonical event via `newEvent`
|
|
191
|
+
* 7. Append event to events.jsonl
|
|
192
|
+
* 8. Apply event to in-memory projection
|
|
193
|
+
* 9. Save projection (under same lock — pass `withLock: false`)
|
|
194
|
+
* 10. Release lock
|
|
195
|
+
*
|
|
196
|
+
* The `payloadBuilder` callback receives both `stableId` and the full
|
|
197
|
+
* `identityResolution` object; callers may inspect/override the audit
|
|
198
|
+
* fields, but if they leave `payload.identity_resolution` undefined we
|
|
199
|
+
* inject it ourselves. `parent_candidate_ids` is merged additively so a
|
|
200
|
+
* caller-supplied list (rare; mostly the projection's own derivation) is
|
|
201
|
+
* preserved.
|
|
202
|
+
*
|
|
203
|
+
* Privacy: pass `opts.storeFirstPrompt: false` to clear the
|
|
204
|
+
* `first_prompt_preview` field on the persisted payload (whatever the
|
|
205
|
+
* payloadBuilder returned is overwritten with `null`). Default `true`
|
|
206
|
+
* preserves the pre-0.1.0 behavior. Sanitization, fingerprints, and
|
|
207
|
+
* transcript_files meta are NOT affected — only the human-readable preview
|
|
208
|
+
* is stripped, so identity reconciliation still works for opt-out users.
|
|
209
|
+
*
|
|
210
|
+
* @param {{
|
|
211
|
+
* claudeSessionId: string,
|
|
212
|
+
* payloadBuilder: (stableId: string, identityResolution?: object) => object,
|
|
213
|
+
* transcriptMeta?: object|null,
|
|
214
|
+
* gitContext?: object|null,
|
|
215
|
+
* cwd?: string|null,
|
|
216
|
+
* fingerprints?: { first_human_prompt_v1?: string|null, lineage_prefix_v1?: string|null }|null,
|
|
217
|
+
* now?: number,
|
|
218
|
+
* timeWindowHours?: number,
|
|
219
|
+
* minCorroborators?: number,
|
|
220
|
+
* storeFirstPrompt?: boolean,
|
|
221
|
+
* paths?: typeof PATHS,
|
|
222
|
+
* root?: string,
|
|
223
|
+
* lockTimeoutMs?: number,
|
|
224
|
+
* lockRetryMs?: number,
|
|
225
|
+
* }} opts
|
|
226
|
+
* @returns {Promise<{ ok: boolean, stableId?: string, eventId?: string,
|
|
227
|
+
* minted?: boolean, identityResolution?: object, error?: string }>}
|
|
228
|
+
*/
|
|
229
|
+
export function recordSessionSeen(opts: {
|
|
230
|
+
claudeSessionId: string;
|
|
231
|
+
payloadBuilder: (stableId: string, identityResolution?: object) => object;
|
|
232
|
+
transcriptMeta?: object | null;
|
|
233
|
+
gitContext?: object | null;
|
|
234
|
+
cwd?: string | null;
|
|
235
|
+
fingerprints?: {
|
|
236
|
+
first_human_prompt_v1?: string | null;
|
|
237
|
+
lineage_prefix_v1?: string | null;
|
|
238
|
+
} | null;
|
|
239
|
+
now?: number;
|
|
240
|
+
timeWindowHours?: number;
|
|
241
|
+
minCorroborators?: number;
|
|
242
|
+
storeFirstPrompt?: boolean;
|
|
243
|
+
paths?: typeof PATHS;
|
|
244
|
+
root?: string;
|
|
245
|
+
lockTimeoutMs?: number;
|
|
246
|
+
lockRetryMs?: number;
|
|
247
|
+
}): Promise<{
|
|
248
|
+
ok: boolean;
|
|
249
|
+
stableId?: string;
|
|
250
|
+
eventId?: string;
|
|
251
|
+
minted?: boolean;
|
|
252
|
+
identityResolution?: object;
|
|
253
|
+
error?: string;
|
|
254
|
+
}>;
|
|
255
|
+
/**
|
|
256
|
+
* Hard cap on a single event's serialized size (line bytes including the
|
|
257
|
+
* trailing newline). Set to 4 KiB — the conservative POSIX `PIPE_BUF` lower
|
|
258
|
+
* bound that guarantees `O_APPEND + write(2)` is atomic on regular files
|
|
259
|
+
* across concurrent writers. Larger payloads risk write interleave on some
|
|
260
|
+
* filesystems even with O_APPEND, so we reject them up front and force the
|
|
261
|
+
* caller to chunk or trim instead of corrupting events.jsonl.
|
|
262
|
+
*
|
|
263
|
+
* Exported so callers (sanitize layer, transcript reader, hook composers)
|
|
264
|
+
* can pre-check before constructing events.
|
|
265
|
+
*/
|
|
266
|
+
export const MAX_EVENT_BYTES: 4096;
|
|
267
|
+
/**
|
|
268
|
+
* Default on-disk paths. Resolved against `process.cwd()` so callers that
|
|
269
|
+
* run from the workspace root see the canonical layout. Tests pass an
|
|
270
|
+
* `opts.paths` override to write to a tmpdir.
|
|
271
|
+
*/
|
|
272
|
+
export const PATHS: Readonly<{
|
|
273
|
+
eventsJsonl: "tickets/_logs/sessions-db-events.jsonl";
|
|
274
|
+
projectionJson: "tickets/_logs/sessions-db.json";
|
|
275
|
+
lockFile: "tickets/_logs/sessions-db.lock";
|
|
276
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute desired activity_state transitions for all sessions in a projection.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} projection
|
|
5
|
+
* @param {{
|
|
6
|
+
* now?: number,
|
|
7
|
+
* idleThresholdDays?: number,
|
|
8
|
+
* archiveThresholdDays?: number,
|
|
9
|
+
* }} [opts]
|
|
10
|
+
* @returns {Array<{
|
|
11
|
+
* stable_id: string,
|
|
12
|
+
* from_state: string,
|
|
13
|
+
* to_state: string,
|
|
14
|
+
* effective_last_progress: string,
|
|
15
|
+
* age_days: number,
|
|
16
|
+
* }>}
|
|
17
|
+
*/
|
|
18
|
+
export function computeSweepTransitions(projection: object, opts?: {
|
|
19
|
+
now?: number;
|
|
20
|
+
idleThresholdDays?: number;
|
|
21
|
+
archiveThresholdDays?: number;
|
|
22
|
+
}): Array<{
|
|
23
|
+
stable_id: string;
|
|
24
|
+
from_state: string;
|
|
25
|
+
to_state: string;
|
|
26
|
+
effective_last_progress: string;
|
|
27
|
+
age_days: number;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Compute the effective "last progress" timestamp for a session — the max
|
|
31
|
+
* (latest) ISO 8601 timestamp across:
|
|
32
|
+
* - session.last_progress_at
|
|
33
|
+
* - session.transcript_files[*].mtime
|
|
34
|
+
* - session.hive_watcher_last_seen (future hive-watcher integration)
|
|
35
|
+
*
|
|
36
|
+
* Returns the epoch ISO string when no candidate is parseable.
|
|
37
|
+
*
|
|
38
|
+
* Implementation note (codex P5 round-1 fix): we MUST parse each candidate
|
|
39
|
+
* to epoch ms and compare numerically, then re-emit a normalized ISO 8601
|
|
40
|
+
* (Z) string. A naive lexicographic `candidates.sort().pop()` only works
|
|
41
|
+
* when every candidate is uniformly Z-suffixed with identical fractional
|
|
42
|
+
* precision — and that invariant is fragile in practice:
|
|
43
|
+
* - transcript_files[*].mtime is sourced from the local fs `Stats.mtime`
|
|
44
|
+
* and gets ISO-stringified at write time; on a host with non-UTC
|
|
45
|
+
* TZ env the stringifier may emit `+02:00` offsets.
|
|
46
|
+
* - hive_watcher_last_seen comes from a different writer with its own
|
|
47
|
+
* formatter (sub-millisecond precision possible).
|
|
48
|
+
* - operator-supplied --json fixtures may carry mixed precisions.
|
|
49
|
+
* Lex-sorting `2026-05-09T05:00:00+02:00` against `2026-05-09T04:00:00.000Z`
|
|
50
|
+
* picks the wrong winner; lex-sorting `...100Z` against `...100.500Z`
|
|
51
|
+
* picks the SHORTER string as larger because `0` < `5` at position 23 once
|
|
52
|
+
* the lengths diverge. Both are silent miscategorization → wrong sweep
|
|
53
|
+
* verdict. Date.parse() canonicalizes everything to a single epoch axis.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} session
|
|
56
|
+
* @returns {string} ISO 8601 timestamp (always Z, normalized)
|
|
57
|
+
*/
|
|
58
|
+
export function computeEffectiveLastProgress(session: object): string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{
|
|
3
|
+
* sessionId: string|null,
|
|
4
|
+
* firstUuid: string|null,
|
|
5
|
+
* lastUuid: string|null,
|
|
6
|
+
* firstParentUuid: string|null,
|
|
7
|
+
* recordCount: number,
|
|
8
|
+
* firstHumanPromptRaw: string|null,
|
|
9
|
+
* cwd: string|null,
|
|
10
|
+
* gitBranch: string|null,
|
|
11
|
+
* size: number,
|
|
12
|
+
* mtime: Date,
|
|
13
|
+
* status: 'ok' | 'corrupted' | 'too_large',
|
|
14
|
+
* }} TranscriptMeta
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Convert an absolute filesystem path to the dash-encoded workspace hash that
|
|
18
|
+
* Claude Code uses for the `~/.claude/projects/<hash>/` directory name. The
|
|
19
|
+
* encoding replaces every path separator and dot with a dash and keeps the
|
|
20
|
+
* leading dash that Claude Code itself prepends.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} cwd absolute path
|
|
23
|
+
* @returns {string} e.g. `-Users-zm-leng-Documents-...-drummen-com-cn`
|
|
24
|
+
*/
|
|
25
|
+
export function workspaceHashFromCwd(cwd: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* List every `.jsonl` transcript in a workspace's Claude Code directory,
|
|
28
|
+
* sorted by mtime descending (newest first). Returns absolute paths.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} workspaceHash dash-encoded hash, OR an absolute path that
|
|
31
|
+
* we will hash for you.
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
export function listTranscriptFiles(workspaceHash: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Parse a single Claude Code transcript jsonl file and return its identity +
|
|
37
|
+
* lineage metadata. Streams the file line-by-line; never loads the whole
|
|
38
|
+
* thing into memory.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} path absolute path to the jsonl file
|
|
41
|
+
* @param {{ maxSizeMb?: number }} [opts]
|
|
42
|
+
* @returns {Promise<TranscriptMeta>}
|
|
43
|
+
*/
|
|
44
|
+
export function parseTranscriptFile(path: string, opts?: {
|
|
45
|
+
maxSizeMb?: number;
|
|
46
|
+
}): Promise<TranscriptMeta>;
|
|
47
|
+
export type TranscriptMeta = {
|
|
48
|
+
sessionId: string | null;
|
|
49
|
+
firstUuid: string | null;
|
|
50
|
+
lastUuid: string | null;
|
|
51
|
+
firstParentUuid: string | null;
|
|
52
|
+
recordCount: number;
|
|
53
|
+
firstHumanPromptRaw: string | null;
|
|
54
|
+
cwd: string | null;
|
|
55
|
+
gitBranch: string | null;
|
|
56
|
+
size: number;
|
|
57
|
+
mtime: Date;
|
|
58
|
+
status: "ok" | "corrupted" | "too_large";
|
|
59
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions-db stable identifier — `sess_<uuidv7-with-dashes>`.
|
|
3
|
+
* See `uuid.mjs` `generateSessionId()` / `isSessionId()`.
|
|
4
|
+
*/
|
|
5
|
+
export type SessionStableId = string;
|
|
6
|
+
/**
|
|
7
|
+
* Claude Code's per-process session UUID (canonical 8-4-4-4-12, v4).
|
|
8
|
+
* Read from the SessionStart hook payload's `session_id` field.
|
|
9
|
+
*/
|
|
10
|
+
export type ClaudeSessionId = string;
|
|
11
|
+
/**
|
|
12
|
+
* events.jsonl per-row identifier — `evt_<uuidv7-with-dashes>`.
|
|
13
|
+
* Same generator as SessionStableId, different prefix (so visual scans of
|
|
14
|
+
* the jsonl tail can tell event ids from session ids).
|
|
15
|
+
*/
|
|
16
|
+
export type EventId = string;
|
|
17
|
+
/**
|
|
18
|
+
* ISO 8601 timestamp string (UTC `Z` suffix preferred, but offset forms
|
|
19
|
+
* are accepted on input — sweep + projection consumers parse via
|
|
20
|
+
* `Date.parse` not lex compare). All sessions-db writers emit `Z`.
|
|
21
|
+
*/
|
|
22
|
+
export type Iso8601 = string;
|
|
23
|
+
/**
|
|
24
|
+
* Activity state machine (sweep-driven).
|
|
25
|
+
*
|
|
26
|
+
* active — fresh session OR within idle threshold (default 14d)
|
|
27
|
+
* idle — past idle threshold but within archive threshold (default 30d)
|
|
28
|
+
* archived — past archive threshold (terminal — sweep never re-promotes)
|
|
29
|
+
*/
|
|
30
|
+
export type ActivityState = "active" | "idle" | "archived";
|
|
31
|
+
/**
|
|
32
|
+
* Operator-driven outcome (set by `close` op).
|
|
33
|
+
*
|
|
34
|
+
* open — default; session has not been explicitly closed
|
|
35
|
+
* done — work completed successfully
|
|
36
|
+
* blocked — paused on external dependency
|
|
37
|
+
* abandoned — won't continue
|
|
38
|
+
* merged — folded into another session (manual_link target)
|
|
39
|
+
* superseded — replaced by a newer session
|
|
40
|
+
*/
|
|
41
|
+
export type Outcome = "open" | "done" | "blocked" | "abandoned" | "merged" | "superseded";
|
|
42
|
+
/**
|
|
43
|
+
* Identity resolution source. Reflects which priority chain step assigned
|
|
44
|
+
* the session's stable_id during the most recent `session_seen`.
|
|
45
|
+
*
|
|
46
|
+
* claude_session_id_index — P1: exact csid hit in projection
|
|
47
|
+
* transcript_lineage — P2: incoming firstParentUuid matches an existing
|
|
48
|
+
* transcript_files[*].last_uuid (resume / fork)
|
|
49
|
+
* fingerprint_corroborator — P3: fingerprint match + sufficient corroborators
|
|
50
|
+
* minted — none of the above; fresh stable_id
|
|
51
|
+
*/
|
|
52
|
+
export type IdentitySource = "claude_session_id_index" | "transcript_lineage" | "fingerprint_corroborator" | "minted";
|
|
53
|
+
/**
|
|
54
|
+
* Confidence label co-emitted with `IdentitySource`.
|
|
55
|
+
*
|
|
56
|
+
* exact — P1 hit (csid is unique per session)
|
|
57
|
+
* high — P2 hit (lineage chain is structurally derived)
|
|
58
|
+
* low — P3 hit (fingerprint + corroborators is heuristic)
|
|
59
|
+
* minted — no resolution path matched (fresh id)
|
|
60
|
+
*/
|
|
61
|
+
export type IdentityConfidence = "exact" | "high" | "low" | "minted";
|
|
62
|
+
/**
|
|
63
|
+
* events.jsonl op label. Each op has its own reducer in
|
|
64
|
+
* `lib/projection.mjs` (`reduceSessionSeen`, `reduceSessionLink`, …).
|
|
65
|
+
*
|
|
66
|
+
* session_seen — primary observation (created + every SessionStart)
|
|
67
|
+
* session_link — additive: attach tasks/projects to a session
|
|
68
|
+
* session_unlink — set-based filter: detach tasks/projects (P5)
|
|
69
|
+
* alias_set — set or clear human-readable alias
|
|
70
|
+
* parent_set — set or clear parent_session_id
|
|
71
|
+
* close — set outcome + closed_at + closed_reason
|
|
72
|
+
* sweep — synthetic: activity_state transition (active → idle / archived)
|
|
73
|
+
* manual_link — operator-supplied parent_candidate_ids merge
|
|
74
|
+
*/
|
|
75
|
+
export type EventOp = "session_seen" | "session_link" | "session_unlink" | "alias_set" | "parent_set" | "close" | "sweep" | "manual_link";
|
|
76
|
+
/**
|
|
77
|
+
* One transcript file (`~/.claude/projects/<workspace-hash>/<uuid>.jsonl`)
|
|
78
|
+
* as captured in a session's `transcript_files[]`.
|
|
79
|
+
*
|
|
80
|
+
* `first_uuid` and `last_uuid` are the lineage anchors used by the P2
|
|
81
|
+
* `transcript_lineage` resolution; `status` reflects the parser outcome
|
|
82
|
+
* (`'ok' | 'corrupted' | 'too_large'`, see `lib/transcript.mjs`).
|
|
83
|
+
*/
|
|
84
|
+
export type TranscriptFile = {
|
|
85
|
+
/**
|
|
86
|
+
* Absolute path on disk
|
|
87
|
+
*/
|
|
88
|
+
path: string;
|
|
89
|
+
/**
|
|
90
|
+
* First record uuid (lineage start)
|
|
91
|
+
*/
|
|
92
|
+
first_uuid: (string | null);
|
|
93
|
+
/**
|
|
94
|
+
* Last record uuid (lineage tail)
|
|
95
|
+
*/
|
|
96
|
+
last_uuid: (string | null);
|
|
97
|
+
/**
|
|
98
|
+
* File size in bytes
|
|
99
|
+
*/
|
|
100
|
+
size: number;
|
|
101
|
+
/**
|
|
102
|
+
* fs mtime (ISO string)
|
|
103
|
+
*/
|
|
104
|
+
mtime: Iso8601;
|
|
105
|
+
/**
|
|
106
|
+
* Parser outcome — `corrupted` => unrecoverable, `too_large` =>
|
|
107
|
+
* skipped (`> maxSizeMb`)
|
|
108
|
+
*/
|
|
109
|
+
status: ("ok" | "corrupted" | "too_large");
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Audit trail attached to each `session_seen` event payload (and mirrored
|
|
113
|
+
* to `KnownSession.identity_resolution` — last-write-wins).
|
|
114
|
+
*
|
|
115
|
+
* `matched` is op-specific and kept loose (`Record<string, unknown>`)
|
|
116
|
+
* because each `IdentitySource` populates a different shape:
|
|
117
|
+
* - claude_session_id_index → `{ claude_session_id }`
|
|
118
|
+
* - transcript_lineage → `{ first_parent_uuid, matched_transcript_path, matched_last_uuid }`
|
|
119
|
+
* - fingerprint_corroborator → `{ fingerprints_matched, corroborators, corroborator_count, strong_corroborator_count }`
|
|
120
|
+
* - minted → `{}` or `{ ambiguous: true, ambiguous_count }`
|
|
121
|
+
*/
|
|
122
|
+
export type IdentityResolution = {
|
|
123
|
+
source: IdentitySource;
|
|
124
|
+
confidence: IdentityConfidence;
|
|
125
|
+
matched: Record<string, unknown>;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Hub-spoke parent hint surfaced when fingerprint evidence exists but does
|
|
129
|
+
* not meet the corroborator threshold (or when multiple candidates tie).
|
|
130
|
+
*
|
|
131
|
+
* The `reason.confidence` carried inside the candidate object is a
|
|
132
|
+
* categorical label (currently always `'low'` from `collectParentCandidates`).
|
|
133
|
+
* The numeric `confidence` field at the top of the typedef is reserved for
|
|
134
|
+
* future scoring (0..1) — current writers leave it as a category-derived
|
|
135
|
+
* string in tests, so we type it loosely.
|
|
136
|
+
*/
|
|
137
|
+
export type ParentCandidate = {
|
|
138
|
+
/**
|
|
139
|
+
* Stable id of the candidate parent session
|
|
140
|
+
*/
|
|
141
|
+
candidate: SessionStableId;
|
|
142
|
+
/**
|
|
143
|
+
* 0..1 numeric score OR category label (`'low'`); current writers
|
|
144
|
+
* emit the categorical form
|
|
145
|
+
*/
|
|
146
|
+
confidence: (number | string);
|
|
147
|
+
reason: {
|
|
148
|
+
fingerprints_matched: string[];
|
|
149
|
+
corroborator_count: number;
|
|
150
|
+
strong_corroborator_count: number;
|
|
151
|
+
weak_corroborator_count: number;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Per-session record in `Projection.sessions[stable_id]`.
|
|
156
|
+
*
|
|
157
|
+
* Every field is populated lazily by the per-op reducers in
|
|
158
|
+
* `lib/projection.mjs`. Rules of thumb:
|
|
159
|
+
* - `claude_session_ids[]` and `transcript_files[]` are append+dedup
|
|
160
|
+
* (later observations augment, never overwrite, the lineage history).
|
|
161
|
+
* - `worktree_*`, `branch_current`, `head_last_seen`, `identity_resolution`,
|
|
162
|
+
* `parent_candidates_omitted_count` are last-write-wins (recency
|
|
163
|
+
* matters more than first observation).
|
|
164
|
+
* - `branch_at_start`, `head_at_start`, `first_prompt_preview` are
|
|
165
|
+
* first-write-wins (initial observation captures these and we refuse
|
|
166
|
+
* to overwrite to preserve history).
|
|
167
|
+
* - `tasks[]` and `projects[]` are set-mutated by `session_link` (add) /
|
|
168
|
+
* `session_unlink` (remove).
|
|
169
|
+
* - `activity_state` is sweep-driven; `outcome` / `closed_at` /
|
|
170
|
+
* `closed_reason` are operator-driven via `close`.
|
|
171
|
+
*/
|
|
172
|
+
export type KnownSession = {
|
|
173
|
+
stable_id: SessionStableId;
|
|
174
|
+
alias: (string | null);
|
|
175
|
+
claude_session_ids: ClaudeSessionId[];
|
|
176
|
+
transcript_files: TranscriptFile[];
|
|
177
|
+
fingerprints: {
|
|
178
|
+
first_human_prompt_v1: (string | null);
|
|
179
|
+
lineage_prefix_v1: (string | null);
|
|
180
|
+
};
|
|
181
|
+
parent_session_id: (SessionStableId | null);
|
|
182
|
+
parent_candidate_ids: ParentCandidate[];
|
|
183
|
+
/**
|
|
184
|
+
* Number of parent candidates dropped by the
|
|
185
|
+
* `MAX_PARENT_CANDIDATES` cap on the most recent session_seen
|
|
186
|
+
*/
|
|
187
|
+
parent_candidates_omitted_count: number;
|
|
188
|
+
identity_resolution: (IdentityResolution | null);
|
|
189
|
+
worktree_path_observed: (string | null);
|
|
190
|
+
worktree_realpath: (string | null);
|
|
191
|
+
worktree_registry_name: (string | null);
|
|
192
|
+
git_common_dir: (string | null);
|
|
193
|
+
branch_at_start: (string | null);
|
|
194
|
+
branch_current: (string | null);
|
|
195
|
+
head_at_start: (string | null);
|
|
196
|
+
head_last_seen: (string | null);
|
|
197
|
+
tasks: string[];
|
|
198
|
+
projects: string[];
|
|
199
|
+
activity_state: ActivityState;
|
|
200
|
+
outcome: Outcome;
|
|
201
|
+
closed_at: (Iso8601 | null);
|
|
202
|
+
closed_reason: (string | null);
|
|
203
|
+
created_at: Iso8601;
|
|
204
|
+
last_progress_at: Iso8601;
|
|
205
|
+
first_prompt_preview: (string | null);
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Cache file `_meta` block.
|
|
209
|
+
*/
|
|
210
|
+
export type ProjectionMeta = {
|
|
211
|
+
/**
|
|
212
|
+
* Pinned to `2` — bump when reducer semantics change
|
|
213
|
+
*/
|
|
214
|
+
schema_version: 2;
|
|
215
|
+
/**
|
|
216
|
+
* Names of the fingerprint algorithms the writer emits
|
|
217
|
+
* (e.g. `['first_human_prompt_v1', 'lineage_prefix_v1']`)
|
|
218
|
+
*/
|
|
219
|
+
fingerprint_versions: string[];
|
|
220
|
+
/**
|
|
221
|
+
* Last write timestamp (saveProjection bumps to now)
|
|
222
|
+
*/
|
|
223
|
+
updated: (Iso8601 | null);
|
|
224
|
+
/**
|
|
225
|
+
* Total events folded into this projection
|
|
226
|
+
*/
|
|
227
|
+
event_count: number;
|
|
228
|
+
last_event_id: (EventId | null);
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* On-disk projection cache shape (`tickets/_logs/sessions-db.json`).
|
|
232
|
+
* Result of folding `events.jsonl` from the empty projection.
|
|
233
|
+
*/
|
|
234
|
+
export type Projection = {
|
|
235
|
+
_meta: ProjectionMeta;
|
|
236
|
+
sessions: Record<SessionStableId, KnownSession>;
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* One row in `events.jsonl` (the SSoT). `payload` is op-specific — each
|
|
240
|
+
* op's reducer reads only the fields it understands; unknown fields are
|
|
241
|
+
* preserved by the storage layer but ignored by the reducer.
|
|
242
|
+
*
|
|
243
|
+
* Tightening `payload` to a per-op union of payload shapes is intentionally
|
|
244
|
+
* deferred — current writers (CLI + hook) treat payloads as
|
|
245
|
+
* `Record<string, unknown>` and rely on runtime defensive reads. A future
|
|
246
|
+
* type-tightening pass can add `SessionSeenPayload`, `SessionLinkPayload`,
|
|
247
|
+
* etc. without breaking consumers.
|
|
248
|
+
*/
|
|
249
|
+
export type SessionEvent = {
|
|
250
|
+
ts: Iso8601;
|
|
251
|
+
event_id: EventId;
|
|
252
|
+
op: EventOp;
|
|
253
|
+
stable_id: SessionStableId;
|
|
254
|
+
payload: Record<string, unknown>;
|
|
255
|
+
};
|
package/types/uuid.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a fresh `sess_<uuidv7>` string.
|
|
3
|
+
* @returns {string}
|
|
4
|
+
*/
|
|
5
|
+
export function generateSessionId(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Validate a sessions-db session id.
|
|
8
|
+
* @param {unknown} s
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isSessionId(s: unknown): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Extract the embedded unix-ms timestamp from a UUIDv7 session id.
|
|
14
|
+
* @param {string} sessionId
|
|
15
|
+
* @returns {number} unix ms
|
|
16
|
+
*/
|
|
17
|
+
export function extractTimestamp(sessionId: string): number;
|