@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/identity.mjs
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure identity reconciliation for sessions-db.
|
|
3
|
+
*
|
|
4
|
+
* Maps a SessionStart hook signal set
|
|
5
|
+
* (claude_session_id, transcript metadata, git context, fingerprints, cwd) to
|
|
6
|
+
* a `stable_id` using a 3-priority lookup chain. No IO, no time, no
|
|
7
|
+
* randomness beyond the optional `mintStableId` callback (which the caller
|
|
8
|
+
* must supply — typically `generateSessionId` from `uuid.mjs`).
|
|
9
|
+
*
|
|
10
|
+
* Strategy (try in order, first match wins):
|
|
11
|
+
*
|
|
12
|
+
* 1. claude_session_id_index (confidence='exact')
|
|
13
|
+
* Scan `projection.sessions[*].claude_session_ids[]` for an exact match.
|
|
14
|
+
* Empty arrays are skipped so non-session_seen-created skeleton records
|
|
15
|
+
* cannot false-match (regression guard from P2 storage round-1 review).
|
|
16
|
+
*
|
|
17
|
+
* 2. transcript_lineage (confidence='high')
|
|
18
|
+
* If `transcriptMeta.firstParentUuid` matches some
|
|
19
|
+
* session.transcript_files[*].lastUuid, this csid is a resume / fork of
|
|
20
|
+
* that session. Structurally derived ID — high confidence, no
|
|
21
|
+
* corroborator needed.
|
|
22
|
+
*
|
|
23
|
+
* 3. fingerprint_corroborator (confidence='low')
|
|
24
|
+
* A fingerprint (first_human_prompt_v1 OR lineage_prefix_v1) match
|
|
25
|
+
* ALONE is too weak. We classify corroborators into two strengths:
|
|
26
|
+
*
|
|
27
|
+
* STRONG (location-anchored — uniquely identify a workspace slot):
|
|
28
|
+
* - same_cwd
|
|
29
|
+
* - same_worktree_realpath
|
|
30
|
+
*
|
|
31
|
+
* WEAK (signal-anchored — frequently shared by unrelated sessions):
|
|
32
|
+
* - same_branch_at_start (e.g. dozens of sessions on `main`)
|
|
33
|
+
* - within_time_window (e.g. dozens of sessions inside any 72h)
|
|
34
|
+
*
|
|
35
|
+
* Acceptance requires BOTH:
|
|
36
|
+
* (a) at least 1 STRONG corroborator, AND
|
|
37
|
+
* (b) total (strong + weak) >= `minCorroborators` (default 2)
|
|
38
|
+
*
|
|
39
|
+
* This blocks the false-merge "same branch + same window alone is enough"
|
|
40
|
+
* pattern that codex round-1 review flagged: two unrelated sessions on
|
|
41
|
+
* `main` within 72h would have weak=2, but strong=0 — must be rejected.
|
|
42
|
+
*
|
|
43
|
+
* Ambiguity rule: if MULTIPLE candidates are above the threshold, we
|
|
44
|
+
* cannot pick one safely. We MINT a fresh stable_id and surface ALL
|
|
45
|
+
* above-threshold candidates as parent_candidates so a human / future
|
|
46
|
+
* manual_link can disambiguate. (Old behavior silently picked the first
|
|
47
|
+
* projection-iteration entry — ordering bug.)
|
|
48
|
+
*
|
|
49
|
+
* Fingerprint matches without enough corroborators are surfaced as
|
|
50
|
+
* `parentCandidates[]` (hub-spoke derivation hints — the caller decides
|
|
51
|
+
* whether to promote them later).
|
|
52
|
+
*
|
|
53
|
+
* To bound payload size (events.jsonl uses MAX_EVENT_BYTES=4096 cap),
|
|
54
|
+
* `parentCandidates[]` is hard-capped at MAX_PARENT_CANDIDATES (default
|
|
55
|
+
* 16). Sort key: (strong corroborator count desc, recency desc). When
|
|
56
|
+
* truncated, `parentCandidatesOmittedCount` carries the omitted count
|
|
57
|
+
* so callers can surface "+ N more" in CLI / audit.
|
|
58
|
+
*
|
|
59
|
+
* If all three miss, the caller mints a fresh stable_id via `mintStableId()`
|
|
60
|
+
* and we return `source: 'minted'`, `confidence: 'minted'`.
|
|
61
|
+
*
|
|
62
|
+
* Priority is strict — P1 hit short-circuits and never queries P2/P3, etc.
|
|
63
|
+
*
|
|
64
|
+
* @typedef {{
|
|
65
|
+
* stable_id: string,
|
|
66
|
+
* claude_session_ids: string[],
|
|
67
|
+
* transcript_files: Array<{ path?: string, last_uuid?: string|null }>,
|
|
68
|
+
* fingerprints: { first_human_prompt_v1: string|null, lineage_prefix_v1: string|null },
|
|
69
|
+
* cwd?: string|null,
|
|
70
|
+
* worktree_realpath?: string|null,
|
|
71
|
+
* branch_at_start?: string|null,
|
|
72
|
+
* last_progress_at?: string|null,
|
|
73
|
+
* }} ProjectionSession
|
|
74
|
+
*
|
|
75
|
+
* @typedef {{
|
|
76
|
+
* _meta: object,
|
|
77
|
+
* sessions: Record<string, ProjectionSession>,
|
|
78
|
+
* }} Projection
|
|
79
|
+
*
|
|
80
|
+
* @typedef {{
|
|
81
|
+
* firstUuid?: string|null,
|
|
82
|
+
* lastUuid?: string|null,
|
|
83
|
+
* firstParentUuid?: string|null,
|
|
84
|
+
* }} TranscriptMetaInput
|
|
85
|
+
*
|
|
86
|
+
* @typedef {{
|
|
87
|
+
* worktreeRealpath?: string|null,
|
|
88
|
+
* worktreePath?: string|null,
|
|
89
|
+
* branch?: string|null,
|
|
90
|
+
* }} GitContextInput
|
|
91
|
+
*
|
|
92
|
+
* @typedef {{ first_human_prompt_v1?: string|null, lineage_prefix_v1?: string|null }} FingerprintInput
|
|
93
|
+
*
|
|
94
|
+
* @typedef {{
|
|
95
|
+
* stableId: string,
|
|
96
|
+
* source: 'claude_session_id_index' | 'transcript_lineage' | 'fingerprint_corroborator' | 'minted',
|
|
97
|
+
* confidence: 'exact' | 'high' | 'low' | 'minted',
|
|
98
|
+
* matched: object,
|
|
99
|
+
* parentCandidates: Array<{ stable_id: string, source: string, confidence: string, reason: object }>,
|
|
100
|
+
* parentCandidatesOmittedCount?: number,
|
|
101
|
+
* }} IdentityResult
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/** Default time window (hours) for the within_time_window corroborator. */
|
|
105
|
+
const DEFAULT_TIME_WINDOW_HOURS = 72;
|
|
106
|
+
const DEFAULT_MIN_CORROBORATORS = 2;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hard cap on `parentCandidates[]` length. Keeps event payloads safely under
|
|
110
|
+
* `storage.MAX_EVENT_BYTES` (4 KiB POSIX PIPE_BUF guarantee).
|
|
111
|
+
*
|
|
112
|
+
* Empirical sizing (after collectParentCandidates payload trim — see that
|
|
113
|
+
* function's bytes-on-disk note): each candidate serializes to ~241 bytes
|
|
114
|
+
* (stable_id ≈ 50 + reason summary ≈ 130 + JSON wrapping ≈ 60). The rest
|
|
115
|
+
* of a typical session_seen payload (csid + transcript_file + fingerprints
|
|
116
|
+
* + git context + identity_resolution audit) is ~500–600 bytes. Budget:
|
|
117
|
+
* 4096 - 600 baseline = 3496 bytes for candidates
|
|
118
|
+
* 3496 / 241 ≈ 14.5 candidates worst-case
|
|
119
|
+
* We cap at 10 to retain safety margin — 10 × 241 + 600 ≈ 3010 bytes,
|
|
120
|
+
* comfortably under the cap with headroom for transcript_file edge cases.
|
|
121
|
+
*
|
|
122
|
+
* Codex round-1 review suggested 16; we reduced to 10 after measuring real
|
|
123
|
+
* payload bytes (their suggestion did not include the per-candidate sizing
|
|
124
|
+
* calculation; 16 candidates would intermittently exceed MAX_EVENT_BYTES
|
|
125
|
+
* and recreate the very rejection the cap exists to prevent).
|
|
126
|
+
*
|
|
127
|
+
* Exported so callers / tests can reason about it. Override is intentionally
|
|
128
|
+
* NOT exposed via opts — keeping it a constant prevents callers from passing
|
|
129
|
+
* a number large enough to re-trip the MAX_EVENT_BYTES rejection.
|
|
130
|
+
*/
|
|
131
|
+
export const MAX_PARENT_CANDIDATES = 10;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Corroborator strength classification. Exported so storage.mjs / projection
|
|
135
|
+
* layers can reason about it consistently (e.g. CLI display, audit reports).
|
|
136
|
+
*
|
|
137
|
+
* STRONG: location-anchored — these uniquely identify a workspace slot.
|
|
138
|
+
* Two unrelated sessions cannot share `cwd` or `worktree_realpath`
|
|
139
|
+
* by accident.
|
|
140
|
+
* WEAK: signal-anchored — frequently shared by unrelated sessions.
|
|
141
|
+
* Many sessions live on `main` (same_branch_at_start) and inside
|
|
142
|
+
* any 72h window (within_time_window).
|
|
143
|
+
*/
|
|
144
|
+
export const STRONG_CORROBORATORS = Object.freeze([
|
|
145
|
+
'same_cwd',
|
|
146
|
+
'same_worktree_realpath',
|
|
147
|
+
]);
|
|
148
|
+
export const WEAK_CORROBORATORS = Object.freeze([
|
|
149
|
+
'same_branch_at_start',
|
|
150
|
+
'within_time_window',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compute strong / weak / total counts from a corroborator hit map.
|
|
155
|
+
* @param {{ same_cwd: boolean, same_worktree_realpath: boolean,
|
|
156
|
+
* same_branch_at_start: boolean, within_time_window: boolean }} hits
|
|
157
|
+
* @returns {{ strong: number, weak: number, total: number }}
|
|
158
|
+
*/
|
|
159
|
+
export function classifyCorroborators(hits) {
|
|
160
|
+
let strong = 0;
|
|
161
|
+
let weak = 0;
|
|
162
|
+
for (const k of STRONG_CORROBORATORS) if (hits && hits[k] === true) strong += 1;
|
|
163
|
+
for (const k of WEAK_CORROBORATORS) if (hits && hits[k] === true) weak += 1;
|
|
164
|
+
return { strong, weak, total: strong + weak };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Acceptance gate: meets fingerprint+corroborator threshold for accept-as-
|
|
169
|
+
* identity (vs surface-as-parent_candidate).
|
|
170
|
+
*
|
|
171
|
+
* Requires BOTH:
|
|
172
|
+
* - strong >= 1 (at least one location-anchored signal); AND
|
|
173
|
+
* - total >= minCorroborators (default 2).
|
|
174
|
+
*
|
|
175
|
+
* @param {{ strong: number, weak: number, total: number }} counts
|
|
176
|
+
* @param {{ minCorroborators?: number }} opts
|
|
177
|
+
*/
|
|
178
|
+
export function meetsThreshold(counts, opts = {}) {
|
|
179
|
+
if (!counts || typeof counts !== 'object') return false;
|
|
180
|
+
const min = typeof opts.minCorroborators === 'number'
|
|
181
|
+
? opts.minCorroborators
|
|
182
|
+
: DEFAULT_MIN_CORROBORATORS;
|
|
183
|
+
return counts.strong >= 1 && counts.total >= min;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Public entry: resolve identity from a hook signal set, OR mint a fresh one.
|
|
188
|
+
*
|
|
189
|
+
* @param {{
|
|
190
|
+
* projection: Projection,
|
|
191
|
+
* claudeSessionId: string,
|
|
192
|
+
* transcriptMeta?: TranscriptMetaInput | null,
|
|
193
|
+
* gitContext?: GitContextInput | null,
|
|
194
|
+
* cwd?: string | null,
|
|
195
|
+
* fingerprints?: FingerprintInput | null,
|
|
196
|
+
* now?: number,
|
|
197
|
+
* timeWindowHours?: number,
|
|
198
|
+
* minCorroborators?: number,
|
|
199
|
+
* mintStableId: () => string,
|
|
200
|
+
* }} input
|
|
201
|
+
* @returns {IdentityResult}
|
|
202
|
+
*/
|
|
203
|
+
export function resolveIdentity(input) {
|
|
204
|
+
if (!input || typeof input !== 'object') {
|
|
205
|
+
throw new TypeError('resolveIdentity: input required');
|
|
206
|
+
}
|
|
207
|
+
const {
|
|
208
|
+
projection,
|
|
209
|
+
claudeSessionId,
|
|
210
|
+
transcriptMeta = null,
|
|
211
|
+
gitContext = null,
|
|
212
|
+
cwd = null,
|
|
213
|
+
fingerprints = null,
|
|
214
|
+
now = Date.now(),
|
|
215
|
+
timeWindowHours = DEFAULT_TIME_WINDOW_HOURS,
|
|
216
|
+
minCorroborators = DEFAULT_MIN_CORROBORATORS,
|
|
217
|
+
mintStableId,
|
|
218
|
+
} = input;
|
|
219
|
+
|
|
220
|
+
if (typeof mintStableId !== 'function') {
|
|
221
|
+
throw new TypeError('resolveIdentity: mintStableId callback required');
|
|
222
|
+
}
|
|
223
|
+
if (typeof claudeSessionId !== 'string' || claudeSessionId.length === 0) {
|
|
224
|
+
throw new TypeError('resolveIdentity: claudeSessionId required');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Priority 1 — claude_session_id_index (exact).
|
|
228
|
+
const p1 = findByClaudeSessionId(projection, claudeSessionId);
|
|
229
|
+
if (p1 !== null) {
|
|
230
|
+
return {
|
|
231
|
+
stableId: p1,
|
|
232
|
+
source: 'claude_session_id_index',
|
|
233
|
+
confidence: 'exact',
|
|
234
|
+
matched: { claude_session_id: claudeSessionId },
|
|
235
|
+
// P1 hit — do NOT compute parentCandidates. The session is identified;
|
|
236
|
+
// hub-spoke parent surfacing is only meaningful when we cannot resolve
|
|
237
|
+
// the exact identity from a stable cross-session signal.
|
|
238
|
+
parentCandidates: [],
|
|
239
|
+
parentCandidatesOmittedCount: 0,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Priority 2 — transcript_lineage (high).
|
|
244
|
+
const p2 = findByTranscriptLineage(projection, transcriptMeta);
|
|
245
|
+
if (p2 !== null) {
|
|
246
|
+
return {
|
|
247
|
+
stableId: p2.stableId,
|
|
248
|
+
source: 'transcript_lineage',
|
|
249
|
+
confidence: 'high',
|
|
250
|
+
matched: {
|
|
251
|
+
first_parent_uuid: transcriptMeta?.firstParentUuid ?? null,
|
|
252
|
+
matched_transcript_path: p2.matchedPath,
|
|
253
|
+
matched_last_uuid: p2.matchedLastUuid,
|
|
254
|
+
},
|
|
255
|
+
parentCandidates: [],
|
|
256
|
+
parentCandidatesOmittedCount: 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Priority 3 — fingerprint + corroborator (low).
|
|
261
|
+
const corrCtx = {
|
|
262
|
+
cwd: typeof cwd === 'string' && cwd.length > 0 ? cwd : null,
|
|
263
|
+
worktreeRealpath: gitContext && typeof gitContext.worktreeRealpath === 'string' && gitContext.worktreeRealpath.length > 0
|
|
264
|
+
? gitContext.worktreeRealpath
|
|
265
|
+
: null,
|
|
266
|
+
branch: gitContext && typeof gitContext.branch === 'string' && gitContext.branch.length > 0
|
|
267
|
+
? gitContext.branch
|
|
268
|
+
: null,
|
|
269
|
+
now,
|
|
270
|
+
timeWindowHours,
|
|
271
|
+
};
|
|
272
|
+
const fpScan = scanFingerprintCandidates(projection, fingerprints, corrCtx);
|
|
273
|
+
|
|
274
|
+
// Partition by acceptance threshold. Acceptance requires >=1 STRONG
|
|
275
|
+
// corroborator AND total >= minCorroborators (see meetsThreshold).
|
|
276
|
+
const above = [];
|
|
277
|
+
const below = [];
|
|
278
|
+
for (const c of fpScan) {
|
|
279
|
+
if (meetsThreshold(c.strengthCounts, { minCorroborators })) above.push(c);
|
|
280
|
+
else below.push(c);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Exactly one above-threshold candidate → safe to accept as identity.
|
|
284
|
+
// Below-threshold candidates still surface as parent_candidates (hub-spoke
|
|
285
|
+
// hints; they share a fingerprint but lack enough corroborators).
|
|
286
|
+
if (above.length === 1) {
|
|
287
|
+
const accepted = above[0];
|
|
288
|
+
const { list, omitted } = capParentCandidates(
|
|
289
|
+
// Other above-threshold (none in this branch) + all below-threshold.
|
|
290
|
+
below.filter((c) => c.stableId !== accepted.stableId),
|
|
291
|
+
);
|
|
292
|
+
return {
|
|
293
|
+
stableId: accepted.stableId,
|
|
294
|
+
source: 'fingerprint_corroborator',
|
|
295
|
+
confidence: 'low',
|
|
296
|
+
matched: {
|
|
297
|
+
fingerprints_matched: accepted.fingerprintsMatched,
|
|
298
|
+
corroborators: accepted.corroborators,
|
|
299
|
+
corroborator_count: accepted.corroboratorCount,
|
|
300
|
+
strong_corroborator_count: accepted.strengthCounts.strong,
|
|
301
|
+
},
|
|
302
|
+
parentCandidates: list,
|
|
303
|
+
parentCandidatesOmittedCount: omitted,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Two cases reach here:
|
|
308
|
+
// 1. above.length === 0 → no acceptable match → mint
|
|
309
|
+
// 2. above.length >= 2 → AMBIGUOUS → mint + surface ALL as candidates
|
|
310
|
+
// (refuse to silently pick first projection-iteration entry)
|
|
311
|
+
const minted = mintStableId();
|
|
312
|
+
const matched = above.length >= 2
|
|
313
|
+
? { ambiguous: true, ambiguous_count: above.length }
|
|
314
|
+
: {};
|
|
315
|
+
// Order: above-threshold candidates first (stronger evidence), then below.
|
|
316
|
+
// capParentCandidates sorts internally by (strong desc, recency desc), but
|
|
317
|
+
// we surface above before below so the strongest evidence is never trimmed.
|
|
318
|
+
const { list, omitted } = capParentCandidates([...above, ...below]);
|
|
319
|
+
return {
|
|
320
|
+
stableId: minted,
|
|
321
|
+
source: 'minted',
|
|
322
|
+
confidence: 'minted',
|
|
323
|
+
matched,
|
|
324
|
+
parentCandidates: list,
|
|
325
|
+
parentCandidatesOmittedCount: omitted,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// P1: claude_session_id_index
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Scan `projection.sessions` for the first record whose `claude_session_ids[]`
|
|
335
|
+
* contains `csid`. Returns the matching `stable_id` or `null`.
|
|
336
|
+
*
|
|
337
|
+
* Empty `claude_session_ids[]` are skipped so a skeleton record produced by
|
|
338
|
+
* a non-session_seen op (e.g. `manual_link`) never false-matches a fresh
|
|
339
|
+
* incoming claude_session_id.
|
|
340
|
+
*
|
|
341
|
+
* Exported for direct testing.
|
|
342
|
+
*/
|
|
343
|
+
export function findByClaudeSessionId(projection, csid) {
|
|
344
|
+
if (!projection || !projection.sessions || typeof projection.sessions !== 'object') {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (typeof csid !== 'string' || csid.length === 0) return null;
|
|
348
|
+
for (const [stableId, session] of Object.entries(projection.sessions)) {
|
|
349
|
+
if (!session || !Array.isArray(session.claude_session_ids)) continue;
|
|
350
|
+
if (session.claude_session_ids.length === 0) continue;
|
|
351
|
+
if (session.claude_session_ids.includes(csid)) return stableId;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// P2: transcript_lineage
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Look for a session whose `transcript_files[*].last_uuid` equals
|
|
362
|
+
* `transcriptMeta.firstParentUuid`. That equality means our incoming
|
|
363
|
+
* transcript starts off the tail of an existing session's transcript — i.e.
|
|
364
|
+
* fork or resume.
|
|
365
|
+
*
|
|
366
|
+
* Returns `{ stableId, matchedPath, matchedLastUuid }` on hit, `null`
|
|
367
|
+
* otherwise. `transcriptMeta == null` (or missing firstParentUuid) returns
|
|
368
|
+
* `null` cleanly.
|
|
369
|
+
*
|
|
370
|
+
* Exported for direct testing.
|
|
371
|
+
*/
|
|
372
|
+
export function findByTranscriptLineage(projection, transcriptMeta) {
|
|
373
|
+
if (!transcriptMeta || typeof transcriptMeta !== 'object') return null;
|
|
374
|
+
const parent = typeof transcriptMeta.firstParentUuid === 'string'
|
|
375
|
+
? transcriptMeta.firstParentUuid
|
|
376
|
+
: null;
|
|
377
|
+
if (!parent || parent.length === 0) return null;
|
|
378
|
+
if (!projection || !projection.sessions || typeof projection.sessions !== 'object') {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
for (const [stableId, session] of Object.entries(projection.sessions)) {
|
|
382
|
+
if (!session || !Array.isArray(session.transcript_files)) continue;
|
|
383
|
+
for (const tf of session.transcript_files) {
|
|
384
|
+
if (!tf || typeof tf !== 'object') continue;
|
|
385
|
+
// The reducer stores the field as `last_uuid` (snake_case payload).
|
|
386
|
+
// Defensive read for both naming styles.
|
|
387
|
+
const lastUuid = typeof tf.last_uuid === 'string' && tf.last_uuid.length > 0
|
|
388
|
+
? tf.last_uuid
|
|
389
|
+
: (typeof tf.lastUuid === 'string' && tf.lastUuid.length > 0 ? tf.lastUuid : null);
|
|
390
|
+
if (lastUuid && lastUuid === parent) {
|
|
391
|
+
return {
|
|
392
|
+
stableId,
|
|
393
|
+
matchedPath: typeof tf.path === 'string' ? tf.path : null,
|
|
394
|
+
matchedLastUuid: lastUuid,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// P3: fingerprint + corroborator
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* For every session whose fingerprints (any v1) match the incoming ones,
|
|
408
|
+
* compute corroborator hits and return one entry per match — the caller
|
|
409
|
+
* picks acceptance vs parent-candidate based on `minCorroborators`.
|
|
410
|
+
*
|
|
411
|
+
* Exported for direct testing.
|
|
412
|
+
*
|
|
413
|
+
* @param {Projection} projection
|
|
414
|
+
* @param {FingerprintInput|null} fingerprints
|
|
415
|
+
* @param {{
|
|
416
|
+
* cwd: string|null,
|
|
417
|
+
* worktreeRealpath: string|null,
|
|
418
|
+
* branch: string|null,
|
|
419
|
+
* now: number,
|
|
420
|
+
* timeWindowHours: number,
|
|
421
|
+
* }} corrCtx
|
|
422
|
+
* @returns {Array<{
|
|
423
|
+
* stableId: string,
|
|
424
|
+
* fingerprintsMatched: string[],
|
|
425
|
+
* corroborators: { same_cwd: boolean, same_worktree_realpath: boolean,
|
|
426
|
+
* same_branch_at_start: boolean, within_time_window: boolean },
|
|
427
|
+
* corroboratorCount: number,
|
|
428
|
+
* strengthCounts: { strong: number, weak: number, total: number },
|
|
429
|
+
* sessionLastProgressAt: string|null,
|
|
430
|
+
* }>}
|
|
431
|
+
*/
|
|
432
|
+
export function scanFingerprintCandidates(projection, fingerprints, corrCtx) {
|
|
433
|
+
/** @type {ReturnType<typeof scanFingerprintCandidates>} */
|
|
434
|
+
const out = [];
|
|
435
|
+
if (!projection || !projection.sessions || typeof projection.sessions !== 'object') {
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
if (!fingerprints || typeof fingerprints !== 'object') return out;
|
|
439
|
+
|
|
440
|
+
const fpHuman = typeof fingerprints.first_human_prompt_v1 === 'string' && fingerprints.first_human_prompt_v1.length > 0
|
|
441
|
+
? fingerprints.first_human_prompt_v1
|
|
442
|
+
: null;
|
|
443
|
+
const fpLineage = typeof fingerprints.lineage_prefix_v1 === 'string' && fingerprints.lineage_prefix_v1.length > 0
|
|
444
|
+
? fingerprints.lineage_prefix_v1
|
|
445
|
+
: null;
|
|
446
|
+
|
|
447
|
+
if (fpHuman === null && fpLineage === null) return out;
|
|
448
|
+
|
|
449
|
+
const windowMs = (typeof corrCtx.timeWindowHours === 'number' && corrCtx.timeWindowHours >= 0
|
|
450
|
+
? corrCtx.timeWindowHours
|
|
451
|
+
: DEFAULT_TIME_WINDOW_HOURS) * 3600 * 1000;
|
|
452
|
+
|
|
453
|
+
for (const [stableId, session] of Object.entries(projection.sessions)) {
|
|
454
|
+
if (!session || !session.fingerprints || typeof session.fingerprints !== 'object') continue;
|
|
455
|
+
|
|
456
|
+
/** @type {string[]} */
|
|
457
|
+
const matched = [];
|
|
458
|
+
if (
|
|
459
|
+
fpHuman !== null &&
|
|
460
|
+
typeof session.fingerprints.first_human_prompt_v1 === 'string' &&
|
|
461
|
+
session.fingerprints.first_human_prompt_v1 === fpHuman
|
|
462
|
+
) {
|
|
463
|
+
matched.push('first_human_prompt_v1');
|
|
464
|
+
}
|
|
465
|
+
if (
|
|
466
|
+
fpLineage !== null &&
|
|
467
|
+
typeof session.fingerprints.lineage_prefix_v1 === 'string' &&
|
|
468
|
+
session.fingerprints.lineage_prefix_v1 === fpLineage
|
|
469
|
+
) {
|
|
470
|
+
matched.push('lineage_prefix_v1');
|
|
471
|
+
}
|
|
472
|
+
if (matched.length === 0) continue;
|
|
473
|
+
|
|
474
|
+
// Compute corroborators. Each corroborator reads a single comparable
|
|
475
|
+
// field; missing fields on either side count as "not corroborated".
|
|
476
|
+
const corroborators = {
|
|
477
|
+
same_cwd: corrCtx.cwd !== null
|
|
478
|
+
&& typeof session.cwd === 'string'
|
|
479
|
+
&& session.cwd.length > 0
|
|
480
|
+
&& session.cwd === corrCtx.cwd,
|
|
481
|
+
same_worktree_realpath: corrCtx.worktreeRealpath !== null
|
|
482
|
+
&& typeof session.worktree_realpath === 'string'
|
|
483
|
+
&& session.worktree_realpath.length > 0
|
|
484
|
+
&& session.worktree_realpath === corrCtx.worktreeRealpath,
|
|
485
|
+
same_branch_at_start: corrCtx.branch !== null
|
|
486
|
+
&& typeof session.branch_at_start === 'string'
|
|
487
|
+
&& session.branch_at_start.length > 0
|
|
488
|
+
&& session.branch_at_start === corrCtx.branch,
|
|
489
|
+
within_time_window: false,
|
|
490
|
+
};
|
|
491
|
+
if (typeof session.last_progress_at === 'string' && session.last_progress_at.length > 0) {
|
|
492
|
+
const lastMs = Date.parse(session.last_progress_at);
|
|
493
|
+
if (Number.isFinite(lastMs)) {
|
|
494
|
+
const diffMs = corrCtx.now - lastMs;
|
|
495
|
+
// Within window when delta is non-negative (last_progress not in
|
|
496
|
+
// future) and <= windowMs. Negative delta (clock skew / pre-dated
|
|
497
|
+
// events) is treated as outside the window — defensive.
|
|
498
|
+
corroborators.within_time_window = diffMs >= 0 && diffMs <= windowMs;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const corroboratorCount = Object.values(corroborators).filter(Boolean).length;
|
|
503
|
+
const strengthCounts = classifyCorroborators(corroborators);
|
|
504
|
+
out.push({
|
|
505
|
+
stableId,
|
|
506
|
+
fingerprintsMatched: matched,
|
|
507
|
+
corroborators,
|
|
508
|
+
corroboratorCount,
|
|
509
|
+
strengthCounts,
|
|
510
|
+
sessionLastProgressAt: typeof session.last_progress_at === 'string'
|
|
511
|
+
? session.last_progress_at
|
|
512
|
+
: null,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Convert raw fingerprint scan rows into stable parent candidate records,
|
|
520
|
+
* deduped by `stable_id`. The reason payload preserves which fingerprints
|
|
521
|
+
* matched + count summaries (strong + weak + total) so the caller can audit
|
|
522
|
+
* the surface later (e.g. CLI listing parent candidates with their evidence).
|
|
523
|
+
*
|
|
524
|
+
* Bytes-on-disk note: we deliberately do NOT include the per-corroborator
|
|
525
|
+
* boolean map (`{same_cwd, same_worktree_realpath, ...}`) here. That map
|
|
526
|
+
* costs ~120 bytes per candidate and pushes the cumulative payload past
|
|
527
|
+
* MAX_EVENT_BYTES (4 KiB POSIX PIPE_BUF) once a session accumulates ~10
|
|
528
|
+
* candidates. The summary counts are sufficient for "is this candidate
|
|
529
|
+
* strong evidence?" decisions; the exact corroborator vector is recoverable
|
|
530
|
+
* by re-scanning against the seed session when needed (CLI drill-down).
|
|
531
|
+
*
|
|
532
|
+
* @param {ReturnType<typeof scanFingerprintCandidates>} rows
|
|
533
|
+
* @returns {Array<{
|
|
534
|
+
* stable_id: string, source: 'fingerprint', confidence: 'low',
|
|
535
|
+
* reason: { fingerprints_matched: string[],
|
|
536
|
+
* corroborator_count: number,
|
|
537
|
+
* strong_corroborator_count: number,
|
|
538
|
+
* weak_corroborator_count: number },
|
|
539
|
+
* }>}
|
|
540
|
+
*/
|
|
541
|
+
export function collectParentCandidates(rows) {
|
|
542
|
+
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
543
|
+
/** @type {Map<string, ReturnType<typeof collectParentCandidates>[number]>} */
|
|
544
|
+
const seen = new Map();
|
|
545
|
+
for (const r of rows) {
|
|
546
|
+
if (!r || typeof r.stableId !== 'string') continue;
|
|
547
|
+
if (seen.has(r.stableId)) continue;
|
|
548
|
+
// strengthCounts may be absent if the row was hand-built (tests). Fall
|
|
549
|
+
// back to recomputing from the corroborator hit map so the reason is
|
|
550
|
+
// always self-describing.
|
|
551
|
+
const strength = r.strengthCounts ?? classifyCorroborators(r.corroborators);
|
|
552
|
+
seen.set(r.stableId, {
|
|
553
|
+
stable_id: r.stableId,
|
|
554
|
+
source: 'fingerprint',
|
|
555
|
+
confidence: 'low',
|
|
556
|
+
reason: {
|
|
557
|
+
fingerprints_matched: [...r.fingerprintsMatched],
|
|
558
|
+
corroborator_count: r.corroboratorCount,
|
|
559
|
+
strong_corroborator_count: strength.strong,
|
|
560
|
+
weak_corroborator_count: strength.weak,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return Array.from(seen.values());
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Cap fingerprint scan rows to MAX_PARENT_CANDIDATES, sorted by
|
|
569
|
+
* (strong corroborator count desc, last_progress recency desc, stable_id
|
|
570
|
+
* asc-tie-break). Returns the surface-able candidate list plus the count of
|
|
571
|
+
* candidates omitted due to the cap so the caller can inject
|
|
572
|
+
* `parent_candidates_omitted_count` into the event payload.
|
|
573
|
+
*
|
|
574
|
+
* The cap exists because the SSoT events.jsonl uses MAX_EVENT_BYTES=4096
|
|
575
|
+
* (POSIX PIPE_BUF guarantee for atomic O_APPEND); an unbounded
|
|
576
|
+
* parent_candidates list can blow that budget and force appendEvent to
|
|
577
|
+
* reject the entire session_seen, losing the audit trail.
|
|
578
|
+
*
|
|
579
|
+
* @param {ReturnType<typeof scanFingerprintCandidates>} rows
|
|
580
|
+
* @param {{ cap?: number }} [opts]
|
|
581
|
+
* @returns {{ list: ReturnType<typeof collectParentCandidates>, omitted: number }}
|
|
582
|
+
*/
|
|
583
|
+
export function capParentCandidates(rows, opts = {}) {
|
|
584
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
585
|
+
return { list: [], omitted: 0 };
|
|
586
|
+
}
|
|
587
|
+
const cap = typeof opts.cap === 'number' && opts.cap > 0
|
|
588
|
+
? opts.cap
|
|
589
|
+
: MAX_PARENT_CANDIDATES;
|
|
590
|
+
|
|
591
|
+
// Dedup BEFORE sorting + capping — the same stable_id can appear once per
|
|
592
|
+
// matching fingerprint, but we only count it once toward the cap.
|
|
593
|
+
/** @type {Map<string, ReturnType<typeof scanFingerprintCandidates>[number]>} */
|
|
594
|
+
const dedup = new Map();
|
|
595
|
+
for (const r of rows) {
|
|
596
|
+
if (!r || typeof r.stableId !== 'string') continue;
|
|
597
|
+
if (!dedup.has(r.stableId)) dedup.set(r.stableId, r);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Sort by strong corroborator count desc, then by recency desc.
|
|
601
|
+
// Recency is parsed lexically when both ISO strings are present; rows
|
|
602
|
+
// without a parseable last_progress sort to the end.
|
|
603
|
+
const sorted = Array.from(dedup.values()).sort((a, b) => {
|
|
604
|
+
const aStrong = (a.strengthCounts ?? classifyCorroborators(a.corroborators)).strong;
|
|
605
|
+
const bStrong = (b.strengthCounts ?? classifyCorroborators(b.corroborators)).strong;
|
|
606
|
+
if (bStrong !== aStrong) return bStrong - aStrong;
|
|
607
|
+
const aTs = a.sessionLastProgressAt ?? '';
|
|
608
|
+
const bTs = b.sessionLastProgressAt ?? '';
|
|
609
|
+
if (aTs !== bTs) return bTs.localeCompare(aTs);
|
|
610
|
+
return a.stableId.localeCompare(b.stableId);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const kept = sorted.slice(0, cap);
|
|
614
|
+
const omitted = Math.max(0, sorted.length - kept.length);
|
|
615
|
+
return { list: collectParentCandidates(kept), omitted };
|
|
616
|
+
}
|