@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +10 -0
  4. package/README.md +250 -0
  5. package/cli/_write-helpers.mjs +99 -0
  6. package/cli/alias.mjs +115 -0
  7. package/cli/argparse.mjs +296 -0
  8. package/cli/close.mjs +116 -0
  9. package/cli/find.mjs +185 -0
  10. package/cli/format.mjs +277 -0
  11. package/cli/link-parent.mjs +133 -0
  12. package/cli/link.mjs +132 -0
  13. package/cli/rebuild.mjs +98 -0
  14. package/cli/sessions-db-session-start-main.mjs +454 -0
  15. package/cli/sessions-db-session-start.mjs +56 -0
  16. package/cli/sessions-db.mjs +119 -0
  17. package/cli/sweep.mjs +171 -0
  18. package/cli/tree.mjs +127 -0
  19. package/lib/git-context.mjs +479 -0
  20. package/lib/identity.mjs +616 -0
  21. package/lib/index.mjs +145 -0
  22. package/lib/init.mjs +185 -0
  23. package/lib/lock.mjs +86 -0
  24. package/lib/operations.mjs +490 -0
  25. package/lib/paths.mjs +199 -0
  26. package/lib/projection.mjs +496 -0
  27. package/lib/sanitize.mjs +131 -0
  28. package/lib/storage.mjs +759 -0
  29. package/lib/sweep.mjs +209 -0
  30. package/lib/transcript.mjs +230 -0
  31. package/lib/types.mjs +276 -0
  32. package/lib/uuid.mjs +116 -0
  33. package/lib/watch.mjs +217 -0
  34. package/package.json +53 -0
  35. package/types/git-context.d.mts +98 -0
  36. package/types/identity.d.mts +658 -0
  37. package/types/index.d.mts +10 -0
  38. package/types/index.d.ts +127 -0
  39. package/types/init.d.mts +53 -0
  40. package/types/lock.d.mts +18 -0
  41. package/types/operations.d.mts +204 -0
  42. package/types/paths.d.mts +54 -0
  43. package/types/projection.d.mts +79 -0
  44. package/types/sanitize.d.mts +39 -0
  45. package/types/storage.d.mts +276 -0
  46. package/types/sweep.d.mts +58 -0
  47. package/types/transcript.d.mts +59 -0
  48. package/types/types.d.mts +255 -0
  49. package/types/uuid.d.mts +17 -0
  50. 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
+ };
@@ -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;