@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
package/lib/types.mjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `@typedef` declarations for `@druumen/sessions-db`.
|
|
3
|
+
*
|
|
4
|
+
* This file is the single source of truth for the public type vocabulary —
|
|
5
|
+
* other `.mjs` files reference these typedefs by name in their JSDoc rather
|
|
6
|
+
* than re-declaring the same shape locally. `tsc --emitDeclarationOnly`
|
|
7
|
+
* lifts the typedefs from this module into `types/types.d.ts`, and the
|
|
8
|
+
* curated `types/index.d.ts` re-exports the public subset.
|
|
9
|
+
*
|
|
10
|
+
* Conventions:
|
|
11
|
+
* - Branded scalar types (SessionStableId, ClaudeSessionId, EventId, Iso8601)
|
|
12
|
+
* are kept as plain `string` aliases so JS callers do not need cast helpers.
|
|
13
|
+
* Cockpit / TS consumers get nominal-ish naming via the alias name in
|
|
14
|
+
* function signatures (`(stableId: SessionStableId)` reads better than
|
|
15
|
+
* `(stableId: string)`); the runtime check stays a string check.
|
|
16
|
+
* - Enums (ActivityState, Outcome, IdentitySource, IdentityConfidence,
|
|
17
|
+
* EventOp) are union-of-string-literals so misspellings are caught at
|
|
18
|
+
* type-check time.
|
|
19
|
+
* - The projection schema mirrors `lib/projection.mjs` `emptySession()` 1:1.
|
|
20
|
+
* When that function gains a field, this typedef MUST be updated in the
|
|
21
|
+
* same commit.
|
|
22
|
+
*
|
|
23
|
+
* This module exports nothing at runtime — it is types-only. The `export {}`
|
|
24
|
+
* keeps it a valid ES module so tsc/Node both treat it as a module rather
|
|
25
|
+
* than a script.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {string} SessionStableId
|
|
30
|
+
* Sessions-db stable identifier — `sess_<uuidv7-with-dashes>`.
|
|
31
|
+
* See `uuid.mjs` `generateSessionId()` / `isSessionId()`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {string} ClaudeSessionId
|
|
36
|
+
* Claude Code's per-process session UUID (canonical 8-4-4-4-12, v4).
|
|
37
|
+
* Read from the SessionStart hook payload's `session_id` field.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {string} EventId
|
|
42
|
+
* events.jsonl per-row identifier — `evt_<uuidv7-with-dashes>`.
|
|
43
|
+
* Same generator as SessionStableId, different prefix (so visual scans of
|
|
44
|
+
* the jsonl tail can tell event ids from session ids).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {string} Iso8601
|
|
49
|
+
* ISO 8601 timestamp string (UTC `Z` suffix preferred, but offset forms
|
|
50
|
+
* are accepted on input — sweep + projection consumers parse via
|
|
51
|
+
* `Date.parse` not lex compare). All sessions-db writers emit `Z`.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Activity state machine (sweep-driven).
|
|
56
|
+
*
|
|
57
|
+
* active — fresh session OR within idle threshold (default 14d)
|
|
58
|
+
* idle — past idle threshold but within archive threshold (default 30d)
|
|
59
|
+
* archived — past archive threshold (terminal — sweep never re-promotes)
|
|
60
|
+
*
|
|
61
|
+
* @typedef {'active' | 'idle' | 'archived'} ActivityState
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Operator-driven outcome (set by `close` op).
|
|
66
|
+
*
|
|
67
|
+
* open — default; session has not been explicitly closed
|
|
68
|
+
* done — work completed successfully
|
|
69
|
+
* blocked — paused on external dependency
|
|
70
|
+
* abandoned — won't continue
|
|
71
|
+
* merged — folded into another session (manual_link target)
|
|
72
|
+
* superseded — replaced by a newer session
|
|
73
|
+
*
|
|
74
|
+
* @typedef {'open' | 'done' | 'blocked' | 'abandoned' | 'merged' | 'superseded'} Outcome
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Identity resolution source. Reflects which priority chain step assigned
|
|
79
|
+
* the session's stable_id during the most recent `session_seen`.
|
|
80
|
+
*
|
|
81
|
+
* claude_session_id_index — P1: exact csid hit in projection
|
|
82
|
+
* transcript_lineage — P2: incoming firstParentUuid matches an existing
|
|
83
|
+
* transcript_files[*].last_uuid (resume / fork)
|
|
84
|
+
* fingerprint_corroborator — P3: fingerprint match + sufficient corroborators
|
|
85
|
+
* minted — none of the above; fresh stable_id
|
|
86
|
+
*
|
|
87
|
+
* @typedef {'claude_session_id_index' | 'transcript_lineage' | 'fingerprint_corroborator' | 'minted'} IdentitySource
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Confidence label co-emitted with `IdentitySource`.
|
|
92
|
+
*
|
|
93
|
+
* exact — P1 hit (csid is unique per session)
|
|
94
|
+
* high — P2 hit (lineage chain is structurally derived)
|
|
95
|
+
* low — P3 hit (fingerprint + corroborators is heuristic)
|
|
96
|
+
* minted — no resolution path matched (fresh id)
|
|
97
|
+
*
|
|
98
|
+
* @typedef {'exact' | 'high' | 'low' | 'minted'} IdentityConfidence
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* events.jsonl op label. Each op has its own reducer in
|
|
103
|
+
* `lib/projection.mjs` (`reduceSessionSeen`, `reduceSessionLink`, …).
|
|
104
|
+
*
|
|
105
|
+
* session_seen — primary observation (created + every SessionStart)
|
|
106
|
+
* session_link — additive: attach tasks/projects to a session
|
|
107
|
+
* session_unlink — set-based filter: detach tasks/projects (P5)
|
|
108
|
+
* alias_set — set or clear human-readable alias
|
|
109
|
+
* parent_set — set or clear parent_session_id
|
|
110
|
+
* close — set outcome + closed_at + closed_reason
|
|
111
|
+
* sweep — synthetic: activity_state transition (active → idle / archived)
|
|
112
|
+
* manual_link — operator-supplied parent_candidate_ids merge
|
|
113
|
+
*
|
|
114
|
+
* @typedef {'session_seen' | 'session_link' | 'session_unlink' | 'alias_set' | 'parent_set' | 'close' | 'sweep' | 'manual_link'} EventOp
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One transcript file (`~/.claude/projects/<workspace-hash>/<uuid>.jsonl`)
|
|
119
|
+
* as captured in a session's `transcript_files[]`.
|
|
120
|
+
*
|
|
121
|
+
* `first_uuid` and `last_uuid` are the lineage anchors used by the P2
|
|
122
|
+
* `transcript_lineage` resolution; `status` reflects the parser outcome
|
|
123
|
+
* (`'ok' | 'corrupted' | 'too_large'`, see `lib/transcript.mjs`).
|
|
124
|
+
*
|
|
125
|
+
* @typedef {Object} TranscriptFile
|
|
126
|
+
* @property {string} path Absolute path on disk
|
|
127
|
+
* @property {(string|null)} first_uuid First record uuid (lineage start)
|
|
128
|
+
* @property {(string|null)} last_uuid Last record uuid (lineage tail)
|
|
129
|
+
* @property {number} size File size in bytes
|
|
130
|
+
* @property {Iso8601} mtime fs mtime (ISO string)
|
|
131
|
+
* @property {('ok' | 'corrupted' | 'too_large')} status
|
|
132
|
+
* Parser outcome — `corrupted` => unrecoverable, `too_large` =>
|
|
133
|
+
* skipped (`> maxSizeMb`)
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Audit trail attached to each `session_seen` event payload (and mirrored
|
|
138
|
+
* to `KnownSession.identity_resolution` — last-write-wins).
|
|
139
|
+
*
|
|
140
|
+
* `matched` is op-specific and kept loose (`Record<string, unknown>`)
|
|
141
|
+
* because each `IdentitySource` populates a different shape:
|
|
142
|
+
* - claude_session_id_index → `{ claude_session_id }`
|
|
143
|
+
* - transcript_lineage → `{ first_parent_uuid, matched_transcript_path, matched_last_uuid }`
|
|
144
|
+
* - fingerprint_corroborator → `{ fingerprints_matched, corroborators, corroborator_count, strong_corroborator_count }`
|
|
145
|
+
* - minted → `{}` or `{ ambiguous: true, ambiguous_count }`
|
|
146
|
+
*
|
|
147
|
+
* @typedef {Object} IdentityResolution
|
|
148
|
+
* @property {IdentitySource} source
|
|
149
|
+
* @property {IdentityConfidence} confidence
|
|
150
|
+
* @property {Record<string, unknown>} matched
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Hub-spoke parent hint surfaced when fingerprint evidence exists but does
|
|
155
|
+
* not meet the corroborator threshold (or when multiple candidates tie).
|
|
156
|
+
*
|
|
157
|
+
* The `reason.confidence` carried inside the candidate object is a
|
|
158
|
+
* categorical label (currently always `'low'` from `collectParentCandidates`).
|
|
159
|
+
* The numeric `confidence` field at the top of the typedef is reserved for
|
|
160
|
+
* future scoring (0..1) — current writers leave it as a category-derived
|
|
161
|
+
* string in tests, so we type it loosely.
|
|
162
|
+
*
|
|
163
|
+
* @typedef {Object} ParentCandidate
|
|
164
|
+
* @property {SessionStableId} candidate
|
|
165
|
+
* Stable id of the candidate parent session
|
|
166
|
+
* @property {(number|string)} confidence
|
|
167
|
+
* 0..1 numeric score OR category label (`'low'`); current writers
|
|
168
|
+
* emit the categorical form
|
|
169
|
+
* @property {Object} reason
|
|
170
|
+
* @property {string[]} reason.fingerprints_matched
|
|
171
|
+
* Fingerprint version names that matched (e.g.
|
|
172
|
+
* `['first_human_prompt_v1']`)
|
|
173
|
+
* @property {number} reason.corroborator_count
|
|
174
|
+
* Total corroborators (strong + weak)
|
|
175
|
+
* @property {number} reason.strong_corroborator_count
|
|
176
|
+
* Location-anchored corroborators (cwd / worktree_realpath)
|
|
177
|
+
* @property {number} reason.weak_corroborator_count
|
|
178
|
+
* Signal-anchored corroborators (branch / time-window)
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Per-session record in `Projection.sessions[stable_id]`.
|
|
183
|
+
*
|
|
184
|
+
* Every field is populated lazily by the per-op reducers in
|
|
185
|
+
* `lib/projection.mjs`. Rules of thumb:
|
|
186
|
+
* - `claude_session_ids[]` and `transcript_files[]` are append+dedup
|
|
187
|
+
* (later observations augment, never overwrite, the lineage history).
|
|
188
|
+
* - `worktree_*`, `branch_current`, `head_last_seen`, `identity_resolution`,
|
|
189
|
+
* `parent_candidates_omitted_count` are last-write-wins (recency
|
|
190
|
+
* matters more than first observation).
|
|
191
|
+
* - `branch_at_start`, `head_at_start`, `first_prompt_preview` are
|
|
192
|
+
* first-write-wins (initial observation captures these and we refuse
|
|
193
|
+
* to overwrite to preserve history).
|
|
194
|
+
* - `tasks[]` and `projects[]` are set-mutated by `session_link` (add) /
|
|
195
|
+
* `session_unlink` (remove).
|
|
196
|
+
* - `activity_state` is sweep-driven; `outcome` / `closed_at` /
|
|
197
|
+
* `closed_reason` are operator-driven via `close`.
|
|
198
|
+
*
|
|
199
|
+
* @typedef {Object} KnownSession
|
|
200
|
+
* @property {SessionStableId} stable_id
|
|
201
|
+
* @property {(string|null)} alias
|
|
202
|
+
* @property {ClaudeSessionId[]} claude_session_ids
|
|
203
|
+
* @property {TranscriptFile[]} transcript_files
|
|
204
|
+
* @property {Object} fingerprints
|
|
205
|
+
* @property {(string|null)} fingerprints.first_human_prompt_v1
|
|
206
|
+
* @property {(string|null)} fingerprints.lineage_prefix_v1
|
|
207
|
+
* @property {(SessionStableId|null)} parent_session_id
|
|
208
|
+
* @property {ParentCandidate[]} parent_candidate_ids
|
|
209
|
+
* @property {number} parent_candidates_omitted_count
|
|
210
|
+
* Number of parent candidates dropped by the
|
|
211
|
+
* `MAX_PARENT_CANDIDATES` cap on the most recent session_seen
|
|
212
|
+
* @property {(IdentityResolution|null)} identity_resolution
|
|
213
|
+
* @property {(string|null)} worktree_path_observed
|
|
214
|
+
* @property {(string|null)} worktree_realpath
|
|
215
|
+
* @property {(string|null)} worktree_registry_name
|
|
216
|
+
* @property {(string|null)} git_common_dir
|
|
217
|
+
* @property {(string|null)} branch_at_start
|
|
218
|
+
* @property {(string|null)} branch_current
|
|
219
|
+
* @property {(string|null)} head_at_start
|
|
220
|
+
* @property {(string|null)} head_last_seen
|
|
221
|
+
* @property {string[]} tasks
|
|
222
|
+
* @property {string[]} projects
|
|
223
|
+
* @property {ActivityState} activity_state
|
|
224
|
+
* @property {Outcome} outcome
|
|
225
|
+
* @property {(Iso8601|null)} closed_at
|
|
226
|
+
* @property {(string|null)} closed_reason
|
|
227
|
+
* @property {Iso8601} created_at
|
|
228
|
+
* @property {Iso8601} last_progress_at
|
|
229
|
+
* @property {(string|null)} first_prompt_preview
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Cache file `_meta` block.
|
|
234
|
+
*
|
|
235
|
+
* @typedef {Object} ProjectionMeta
|
|
236
|
+
* @property {2} schema_version
|
|
237
|
+
* Pinned to `2` — bump when reducer semantics change
|
|
238
|
+
* @property {string[]} fingerprint_versions
|
|
239
|
+
* Names of the fingerprint algorithms the writer emits
|
|
240
|
+
* (e.g. `['first_human_prompt_v1', 'lineage_prefix_v1']`)
|
|
241
|
+
* @property {(Iso8601|null)} updated
|
|
242
|
+
* Last write timestamp (saveProjection bumps to now)
|
|
243
|
+
* @property {number} event_count
|
|
244
|
+
* Total events folded into this projection
|
|
245
|
+
* @property {(EventId|null)} last_event_id
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* On-disk projection cache shape (`tickets/_logs/sessions-db.json`).
|
|
250
|
+
* Result of folding `events.jsonl` from the empty projection.
|
|
251
|
+
*
|
|
252
|
+
* @typedef {Object} Projection
|
|
253
|
+
* @property {ProjectionMeta} _meta
|
|
254
|
+
* @property {Record<SessionStableId, KnownSession>} sessions
|
|
255
|
+
*/
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One row in `events.jsonl` (the SSoT). `payload` is op-specific — each
|
|
259
|
+
* op's reducer reads only the fields it understands; unknown fields are
|
|
260
|
+
* preserved by the storage layer but ignored by the reducer.
|
|
261
|
+
*
|
|
262
|
+
* Tightening `payload` to a per-op union of payload shapes is intentionally
|
|
263
|
+
* deferred — current writers (CLI + hook) treat payloads as
|
|
264
|
+
* `Record<string, unknown>` and rely on runtime defensive reads. A future
|
|
265
|
+
* type-tightening pass can add `SessionSeenPayload`, `SessionLinkPayload`,
|
|
266
|
+
* etc. without breaking consumers.
|
|
267
|
+
*
|
|
268
|
+
* @typedef {Object} SessionEvent
|
|
269
|
+
* @property {Iso8601} ts
|
|
270
|
+
* @property {EventId} event_id
|
|
271
|
+
* @property {EventOp} op
|
|
272
|
+
* @property {SessionStableId} stable_id
|
|
273
|
+
* @property {Record<string, unknown>} payload
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
export {};
|
package/lib/uuid.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UUIDv7 generator for sessions-db stable IDs.
|
|
3
|
+
*
|
|
4
|
+
* Spec: RFC 9562 §5.7 — UUIDv7 (Unix Epoch time-ordered).
|
|
5
|
+
* Layout (128 bits, big-endian):
|
|
6
|
+
* - bits 0..47 : 48-bit unix timestamp in milliseconds
|
|
7
|
+
* - bits 48..51 : 4-bit version (= 0b0111 = 7)
|
|
8
|
+
* - bits 52..63 : 12 bits of random "rand_a"
|
|
9
|
+
* - bits 64..65 : 2-bit IETF variant (= 0b10)
|
|
10
|
+
* - bits 66..127 : 62 bits of random "rand_b"
|
|
11
|
+
*
|
|
12
|
+
* Output format: `sess_<uuidv7-with-dashes>`
|
|
13
|
+
*
|
|
14
|
+
* Notes:
|
|
15
|
+
* - Node 22's `crypto.randomUUID` always emits v4; the `{version:7}` option
|
|
16
|
+
* is silently ignored. We implement the bit layout ourselves with
|
|
17
|
+
* `crypto.randomFillSync` (16 bytes, then patch the version + variant
|
|
18
|
+
* nibbles, then write the timestamp big-endian into the first 6 bytes).
|
|
19
|
+
* - To preserve monotonic ordering when multiple IDs are generated within
|
|
20
|
+
* the same millisecond we keep the previous timestamp around and bump
|
|
21
|
+
* the 12-bit `rand_a` counter when a collision is detected. Across
|
|
22
|
+
* millisecond boundaries `rand_a` is fully random.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomFillSync } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
const PREFIX = 'sess_';
|
|
28
|
+
// Matches the canonical 8-4-4-4-12 hex shape with version nibble forced to 7
|
|
29
|
+
// and variant nibble in {8,9,a,b}.
|
|
30
|
+
const UUIDV7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
31
|
+
const SESSION_ID_RE = new RegExp(`^${PREFIX}${UUIDV7_RE.source.slice(1, -1)}$`);
|
|
32
|
+
|
|
33
|
+
let lastTimestampMs = -1;
|
|
34
|
+
let lastRandA = 0;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate a fresh `sess_<uuidv7>` string.
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function generateSessionId() {
|
|
41
|
+
const bytes = Buffer.alloc(16);
|
|
42
|
+
randomFillSync(bytes);
|
|
43
|
+
|
|
44
|
+
const nowMs = Date.now();
|
|
45
|
+
let timestampMs = nowMs;
|
|
46
|
+
let randA;
|
|
47
|
+
|
|
48
|
+
if (nowMs <= lastTimestampMs) {
|
|
49
|
+
// Same-or-earlier millisecond: reuse the previous timestamp and bump
|
|
50
|
+
// rand_a so successive IDs remain strictly monotonic.
|
|
51
|
+
timestampMs = lastTimestampMs;
|
|
52
|
+
randA = (lastRandA + 1) & 0xfff;
|
|
53
|
+
if (randA === 0) {
|
|
54
|
+
// Overflowed the 12-bit counter — advance the timestamp by 1ms so we
|
|
55
|
+
// do not collide with the prior ID.
|
|
56
|
+
timestampMs += 1;
|
|
57
|
+
// randA stays 0 after overflow; rand_b is still random so we keep
|
|
58
|
+
// collision probability negligible.
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
// Use 12 random bits from the buffer for rand_a.
|
|
62
|
+
randA = ((bytes[6] & 0x0f) << 8) | bytes[7];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Write the 48-bit timestamp big-endian into bytes[0..5].
|
|
66
|
+
// (Buffer.writeUIntBE supports up to 6 bytes, exactly 48 bits.)
|
|
67
|
+
bytes.writeUIntBE(timestampMs, 0, 6);
|
|
68
|
+
|
|
69
|
+
// bytes[6]: top nibble = version (0x70), bottom nibble = high 4 bits of randA.
|
|
70
|
+
bytes[6] = 0x70 | ((randA >>> 8) & 0x0f);
|
|
71
|
+
// bytes[7]: low 8 bits of randA.
|
|
72
|
+
bytes[7] = randA & 0xff;
|
|
73
|
+
// bytes[8]: top 2 bits = variant 0b10, remaining 6 bits stay random.
|
|
74
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
75
|
+
|
|
76
|
+
lastTimestampMs = timestampMs;
|
|
77
|
+
lastRandA = randA;
|
|
78
|
+
|
|
79
|
+
const hex = bytes.toString('hex');
|
|
80
|
+
const uuid =
|
|
81
|
+
hex.slice(0, 8) +
|
|
82
|
+
'-' +
|
|
83
|
+
hex.slice(8, 12) +
|
|
84
|
+
'-' +
|
|
85
|
+
hex.slice(12, 16) +
|
|
86
|
+
'-' +
|
|
87
|
+
hex.slice(16, 20) +
|
|
88
|
+
'-' +
|
|
89
|
+
hex.slice(20, 32);
|
|
90
|
+
|
|
91
|
+
return PREFIX + uuid;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a sessions-db session id.
|
|
96
|
+
* @param {unknown} s
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
export function isSessionId(s) {
|
|
100
|
+
return typeof s === 'string' && SESSION_ID_RE.test(s);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract the embedded unix-ms timestamp from a UUIDv7 session id.
|
|
105
|
+
* @param {string} sessionId
|
|
106
|
+
* @returns {number} unix ms
|
|
107
|
+
*/
|
|
108
|
+
export function extractTimestamp(sessionId) {
|
|
109
|
+
if (!isSessionId(sessionId)) {
|
|
110
|
+
throw new TypeError(`extractTimestamp: not a sessions-db id: ${sessionId}`);
|
|
111
|
+
}
|
|
112
|
+
// Strip prefix, then strip dashes from the first three groups (12 hex chars
|
|
113
|
+
// = 48 bits) and parseInt as base-16. Number is safe since 48 bits < 53.
|
|
114
|
+
const hex = sessionId.slice(PREFIX.length).replace(/-/g, '').slice(0, 12);
|
|
115
|
+
return Number.parseInt(hex, 16);
|
|
116
|
+
}
|
package/lib/watch.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection-file watcher for sessions-db.
|
|
3
|
+
*
|
|
4
|
+
* `watchProjection(rootPath, listener)` invokes `listener` whenever the
|
|
5
|
+
* projection cache (`tickets/_logs/sessions-db.json` by default) changes.
|
|
6
|
+
* Returns a Disposable (`{ dispose }`) so callers can unsubscribe — used
|
|
7
|
+
* by cockpit to feed the live UI without re-polling the on-disk JSON.
|
|
8
|
+
*
|
|
9
|
+
* Two redundant signals are wired up:
|
|
10
|
+
*
|
|
11
|
+
* 1. **fs.watch** — the OS-level inotify / FSEvents subscription. Fast
|
|
12
|
+
* (sub-100ms latency on typical hardware) but unreliable under some
|
|
13
|
+
* conditions:
|
|
14
|
+
* - editors that "atomic save" (write to tmp + rename) deliver a
|
|
15
|
+
* `rename` event but the watcher dies on the inode swap on Linux
|
|
16
|
+
* - networked filesystems may drop events
|
|
17
|
+
* - macOS FSEvents coalesces aggressively under load
|
|
18
|
+
*
|
|
19
|
+
* 2. **1s polling fallback** — `setInterval` reads `mtimeMs` and fires
|
|
20
|
+
* the listener if it changed since the last poll. Bounded latency
|
|
21
|
+
* regardless of watcher health. Cheap (a single stat per second).
|
|
22
|
+
*
|
|
23
|
+
* Both paths funnel through a single **80ms debounce** window so a flurry
|
|
24
|
+
* of events (sweep applying multiple transitions, or fs.watch + poll both
|
|
25
|
+
* detecting the same write) collapses into one listener call. The window
|
|
26
|
+
* is internal — callers do not need to debounce themselves.
|
|
27
|
+
*
|
|
28
|
+
* Why 80ms? Long enough to coalesce a write-then-rename (atomic save) and
|
|
29
|
+
* a fs.watch+poll race (typically < 50ms apart), short enough that the UI
|
|
30
|
+
* still feels live. Tunable via `opts.debounceMs` for tests.
|
|
31
|
+
*
|
|
32
|
+
* Listener contract:
|
|
33
|
+
* - Called as `listener({ type, path })` where `type` is one of
|
|
34
|
+
* `'change' | 'rename' | 'poll'` (whichever signal fired) and `path`
|
|
35
|
+
* is the absolute path to projection.json.
|
|
36
|
+
* - Synchronous; if your handler is async, swallow promise rejections
|
|
37
|
+
* yourself — the watcher does NOT await.
|
|
38
|
+
* - Errors thrown by the listener are caught and silently ignored so a
|
|
39
|
+
* buggy consumer doesn't crash the watcher loop.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { existsSync, statSync, watch } from 'node:fs';
|
|
43
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
44
|
+
|
|
45
|
+
import { resolveStoragePaths } from './paths.mjs';
|
|
46
|
+
import { PATHS } from './storage.mjs';
|
|
47
|
+
|
|
48
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
49
|
+
const DEFAULT_DEBOUNCE_MS = 80;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Watch the projection file at `rootPath` and invoke `listener` on change.
|
|
53
|
+
*
|
|
54
|
+
* Path resolution (Day 4 — symmetric with storage.mjs):
|
|
55
|
+
*
|
|
56
|
+
* - If `opts.paths.projectionJson` is provided, that's the file watched
|
|
57
|
+
* (anchored on `rootPath` if relative). Backward-compat with Day 1-3.
|
|
58
|
+
*
|
|
59
|
+
* - Otherwise `rootPath` is treated as a Day 4 storage root and we
|
|
60
|
+
* delegate to `resolveStoragePaths({ rootPath })` so the canonical
|
|
61
|
+
* filename + cross-platform path normalization apply uniformly.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} rootPath
|
|
64
|
+
* @param {(event: { type: 'change' | 'rename' | 'poll', path: string }) => void} listener
|
|
65
|
+
* @param {{
|
|
66
|
+
* paths?: { projectionJson?: string },
|
|
67
|
+
* pollIntervalMs?: number,
|
|
68
|
+
* debounceMs?: number,
|
|
69
|
+
* }} [opts]
|
|
70
|
+
* @returns {{ dispose: () => void }}
|
|
71
|
+
*/
|
|
72
|
+
export function watchProjection(rootPath, listener, opts = {}) {
|
|
73
|
+
if (typeof rootPath !== 'string' || rootPath.length === 0) {
|
|
74
|
+
throw new TypeError('watchProjection: rootPath required');
|
|
75
|
+
}
|
|
76
|
+
if (typeof listener !== 'function') {
|
|
77
|
+
throw new TypeError('watchProjection: listener function required');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let projectionPath;
|
|
81
|
+
if (opts.paths && opts.paths.projectionJson) {
|
|
82
|
+
// Legacy form: explicit override anchored at rootPath. Preserves the
|
|
83
|
+
// pre-Day-4 layout (rootPath = monorepo root, paths embed
|
|
84
|
+
// `tickets/_logs/`).
|
|
85
|
+
const projectionRel = opts.paths.projectionJson;
|
|
86
|
+
projectionPath = isAbsolute(projectionRel)
|
|
87
|
+
? projectionRel
|
|
88
|
+
: resolve(rootPath, projectionRel);
|
|
89
|
+
} else {
|
|
90
|
+
// Day 4 form: rootPath IS the storage dir. Delegate so canonical
|
|
91
|
+
// filenames + path-normalization stay in lib/paths.mjs.
|
|
92
|
+
const r = resolveStoragePaths({ rootPath });
|
|
93
|
+
projectionPath = r.projectionJson;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pollIntervalMs = typeof opts.pollIntervalMs === 'number' && opts.pollIntervalMs > 0
|
|
97
|
+
? opts.pollIntervalMs
|
|
98
|
+
: DEFAULT_POLL_INTERVAL_MS;
|
|
99
|
+
const debounceMs = typeof opts.debounceMs === 'number' && opts.debounceMs >= 0
|
|
100
|
+
? opts.debounceMs
|
|
101
|
+
: DEFAULT_DEBOUNCE_MS;
|
|
102
|
+
|
|
103
|
+
// Debounce coalesces multiple events within `debounceMs` into one listener
|
|
104
|
+
// call. We capture the LAST fired event's `type` so the consumer sees the
|
|
105
|
+
// most recent signal source ("rename" wins over earlier "change" within the
|
|
106
|
+
// same window — useful for atomic-save detection).
|
|
107
|
+
let pendingTimer = null;
|
|
108
|
+
let pendingType = null;
|
|
109
|
+
const fireSoon = (type) => {
|
|
110
|
+
pendingType = type;
|
|
111
|
+
if (pendingTimer !== null) return;
|
|
112
|
+
pendingTimer = setTimeout(() => {
|
|
113
|
+
const t = pendingType;
|
|
114
|
+
pendingTimer = null;
|
|
115
|
+
pendingType = null;
|
|
116
|
+
try {
|
|
117
|
+
listener({ type: t, path: projectionPath });
|
|
118
|
+
} catch {
|
|
119
|
+
// Swallow listener errors — a buggy consumer must not kill the
|
|
120
|
+
// watcher. If they need observability, they should add their own
|
|
121
|
+
// try/catch.
|
|
122
|
+
}
|
|
123
|
+
}, debounceMs);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
// fs.watch — primary signal. May fail to attach (file doesn't exist yet)
|
|
128
|
+
// or die mid-way (atomic-save inode swap). Both are tolerated; the poll
|
|
129
|
+
// fallback covers gaps.
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
let fsWatcher = null;
|
|
132
|
+
const tryAttachWatcher = () => {
|
|
133
|
+
if (!existsSync(projectionPath)) return;
|
|
134
|
+
if (fsWatcher) return;
|
|
135
|
+
try {
|
|
136
|
+
fsWatcher = watch(projectionPath, { persistent: false }, (eventType) => {
|
|
137
|
+
// eventType is 'change' | 'rename'. Atomic save fires 'rename'
|
|
138
|
+
// and may then invalidate this watcher; the next poll will
|
|
139
|
+
// detect it and re-attach.
|
|
140
|
+
if (eventType === 'rename') {
|
|
141
|
+
// Tear down the watcher — the underlying inode is gone.
|
|
142
|
+
try { fsWatcher && fsWatcher.close(); } catch { /* noop */ }
|
|
143
|
+
fsWatcher = null;
|
|
144
|
+
}
|
|
145
|
+
fireSoon(eventType === 'rename' ? 'rename' : 'change');
|
|
146
|
+
});
|
|
147
|
+
fsWatcher.on('error', () => {
|
|
148
|
+
// Swallow watcher errors and rely on poll fallback. The watcher
|
|
149
|
+
// will be re-attached on the next poll once the file is back.
|
|
150
|
+
try { fsWatcher && fsWatcher.close(); } catch { /* noop */ }
|
|
151
|
+
fsWatcher = null;
|
|
152
|
+
});
|
|
153
|
+
} catch {
|
|
154
|
+
fsWatcher = null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
tryAttachWatcher();
|
|
158
|
+
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
// Polling — runs every `pollIntervalMs` regardless of watcher health.
|
|
161
|
+
// Tracks the last seen `mtimeMs` and fires the listener on change. Also
|
|
162
|
+
// re-attaches fs.watch if it died (atomic save dropped the inode).
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
let lastMtimeMs = readMtimeSafe(projectionPath);
|
|
165
|
+
let pollTimer = setInterval(() => {
|
|
166
|
+
// Re-attach watcher first so any subsequent fs.watch deliveries can
|
|
167
|
+
// pre-empt this poll's debounce window cleanly.
|
|
168
|
+
if (!fsWatcher) tryAttachWatcher();
|
|
169
|
+
|
|
170
|
+
const current = readMtimeSafe(projectionPath);
|
|
171
|
+
if (current === null) {
|
|
172
|
+
// File doesn't exist (yet). Reset baseline so the first appearance
|
|
173
|
+
// counts as a change.
|
|
174
|
+
if (lastMtimeMs !== null) lastMtimeMs = null;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (lastMtimeMs === null || current !== lastMtimeMs) {
|
|
178
|
+
lastMtimeMs = current;
|
|
179
|
+
fireSoon('poll');
|
|
180
|
+
}
|
|
181
|
+
}, pollIntervalMs);
|
|
182
|
+
// Don't keep the event loop alive on the watcher's behalf — the consumer
|
|
183
|
+
// owns liveness via the Disposable.
|
|
184
|
+
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
dispose() {
|
|
188
|
+
if (pendingTimer !== null) {
|
|
189
|
+
clearTimeout(pendingTimer);
|
|
190
|
+
pendingTimer = null;
|
|
191
|
+
pendingType = null;
|
|
192
|
+
}
|
|
193
|
+
if (pollTimer !== null) {
|
|
194
|
+
clearInterval(pollTimer);
|
|
195
|
+
pollTimer = null;
|
|
196
|
+
}
|
|
197
|
+
if (fsWatcher) {
|
|
198
|
+
try { fsWatcher.close(); } catch { /* noop */ }
|
|
199
|
+
fsWatcher = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read the file's `mtimeMs` and return it, or null if the file is missing
|
|
207
|
+
* or unreadable. Never throws — this is the polling-loop hot path and any
|
|
208
|
+
* error must degrade to "no change" rather than crashing the timer.
|
|
209
|
+
*/
|
|
210
|
+
function readMtimeSafe(path) {
|
|
211
|
+
try {
|
|
212
|
+
if (!existsSync(path)) return null;
|
|
213
|
+
return statSync(path).mtimeMs;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@druumen/sessions-db",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-session traceability for Claude Code — events.jsonl SSoT + JSON projection cache + 3-priority identity reconciliation",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./lib/index.mjs",
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"sessions-db": "./cli/sessions-db.mjs",
|
|
11
|
+
"sessions-db-session-start": "./cli/sessions-db-session-start.mjs"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./lib/index.mjs",
|
|
15
|
+
"./cli": "./cli/sessions-db.mjs",
|
|
16
|
+
"./hook": "./cli/sessions-db-session-start.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/",
|
|
20
|
+
"cli/",
|
|
21
|
+
"types/",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"NOTICE",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+ssh://git@gitlab.tinfant.org:8922/druumen/sessions-db.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"email": "security@druumen.com"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/druumen/sessions-db#readme",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"claude-code",
|
|
40
|
+
"session-tracking",
|
|
41
|
+
"cli",
|
|
42
|
+
"developer-tools"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "find __tests__ -name '*.test.mjs' -type f -print0 | xargs -0 node --test",
|
|
46
|
+
"build:types": "tsc -p tsconfig.json",
|
|
47
|
+
"check:types-smoke": "tsc --noEmit -p __tests__/types-smoke/tsconfig.json",
|
|
48
|
+
"prepublishOnly": "npm run build:types"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"typescript": "^5.9.3"
|
|
52
|
+
}
|
|
53
|
+
}
|