throughline 0.6.2 → 0.7.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 (72) hide show
  1. package/CHANGELOG.md +79 -4
  2. package/README.md +77 -25
  3. package/bin/throughline.mjs +14 -0
  4. package/docs/00_overview.md +12 -0
  5. package/docs/02_clear_auto_handoff_plan.md +39 -13
  6. package/docs/04_public_release_plan.md +2 -1
  7. package/docs/13_native_factory_diagnostics_plan.md +4 -2
  8. package/docs/14_observer_completed_turn_feed_plan.md +290 -0
  9. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
  10. package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
  11. package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
  12. package/docs/adr/0004-observer-db-pair-projection.md +71 -0
  13. package/docs/adr/0005-observer-read-pagination.md +51 -0
  14. package/docs/adr/0006-observer-page-offset-proof.md +33 -0
  15. package/docs/adr/0007-observer-read-cli-contract.md +61 -0
  16. package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
  17. package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
  18. package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
  19. package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
  20. package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
  21. package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
  22. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
  23. package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
  24. package/package.json +1 -1
  25. package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -0
  26. package/rag/INDEX.md +4 -0
  27. package/src/auditor-context.mjs +92 -11
  28. package/src/auditor-context.test.mjs +116 -1
  29. package/src/baton.mjs +27 -7
  30. package/src/baton.test.mjs +44 -0
  31. package/src/body-digest.mjs +9 -0
  32. package/src/cli/auditor-context.test.mjs +1 -1
  33. package/src/cli/factory-diagnostics.mjs +5 -2
  34. package/src/cli/factory-diagnostics.test.mjs +31 -2
  35. package/src/cli/observer-read.mjs +73 -0
  36. package/src/cli/observer-read.test.mjs +93 -0
  37. package/src/cli/observer-wait.mjs +123 -0
  38. package/src/cli/observer-wait.test.mjs +167 -0
  39. package/src/codex-rollout-memory.mjs +13 -0
  40. package/src/codex-rollout-memory.test.mjs +27 -0
  41. package/src/codex-thread-index.mjs +1 -1
  42. package/src/codex-thread-index.test.mjs +18 -0
  43. package/src/completed-turn-receipts.mjs +373 -0
  44. package/src/completed-turn-receipts.test.mjs +186 -0
  45. package/src/db-schema.test.mjs +9 -2
  46. package/src/db.mjs +20 -1
  47. package/src/decision-log.mjs +24 -0
  48. package/src/factory-diagnostics.mjs +0 -1
  49. package/src/factory-diagnostics.test.mjs +18 -0
  50. package/src/haiku-summarizer.mjs +93 -16
  51. package/src/haiku-summarizer.test.mjs +118 -9
  52. package/src/handoff-executor.mjs +159 -0
  53. package/src/hook-entrypoints.test.mjs +192 -12
  54. package/src/observer-codex-projection.test.mjs +49 -0
  55. package/src/observer-turn-feed.mjs +392 -0
  56. package/src/observer-turn-feed.test.mjs +339 -0
  57. package/src/observer-turn-wait.mjs +102 -0
  58. package/src/observer-turn-wait.test.mjs +122 -0
  59. package/src/pending-handoff.mjs +96 -0
  60. package/src/pending-handoff.test.mjs +107 -0
  61. package/src/prompt-submit.mjs +46 -1
  62. package/src/resume-context.mjs +226 -62
  63. package/src/resume-context.test.mjs +134 -1
  64. package/src/runtime-error-store.mjs +74 -32
  65. package/src/runtime-error-store.test.mjs +51 -3
  66. package/src/session-start.mjs +70 -233
  67. package/src/transcript-reader.mjs +32 -0
  68. package/src/turn-backfill.mjs +3 -2
  69. package/src/turn-backfill.test.mjs +10 -4
  70. package/src/turn-processor.mjs +90 -1
  71. package/src/turn-processor.test.mjs +141 -0
  72. package/src/windows-acl-test-helper.mjs +29 -0
@@ -0,0 +1,392 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, realpathSync, statSync } from 'node:fs';
3
+ import { isAbsolute } from 'node:path';
4
+ import { listCodexThreadCandidates } from './codex-thread-index.mjs';
5
+ import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
6
+ import { hashAuditorBody } from './body-digest.mjs';
7
+ import { buildBodyRowsFromActiveTurns, buildCodexThroughlineSessionId } from './codex-capture.mjs';
8
+ import { readCompletedTurnReceiptSnapshot } from './completed-turn-receipts.mjs';
9
+ import { readCompletedPairProjection } from './auditor-context.mjs';
10
+
11
+ export const OBSERVER_CURSOR_SCHEMA = 'throughline.observer_cursor.v1';
12
+ export const OBSERVER_READ_SCHEMA = 'throughline.observer_read.v1';
13
+ export const OBSERVER_PAGE_SCHEMA = 'throughline.observer_page.v1';
14
+ const CURSOR_PREFIX = 'tlc1.';
15
+ const PAGE_PREFIX = 'tlp1.';
16
+ const DEFAULT_PAGE_LIMIT = 10;
17
+ const MAX_PAGE_LIMIT = 100;
18
+
19
+ /**
20
+ * Resolves the latest completed-only parent and validates an optional opaque cursor.
21
+ * This core intentionally returns identities only as SHA-256 values; body projection is later work.
22
+ */
23
+ export function resolveObserverTurnFeed({ projectPath, cursor = null, codexHome, receiptOptions, dbPath, maxBodyChars, maxTotalChars } = {}) {
24
+ const project = canonicalExistingProject(projectPath);
25
+ const projectSha256 = sha256(project);
26
+ const claude = claudeCandidates(project, receiptOptions);
27
+ const codex = codexCandidates(project, codexHome);
28
+ const candidates = [...claude, ...codex];
29
+ if (cursor === null || cursor === undefined) {
30
+ const selected = selectLatest(candidates);
31
+ if (selected?.ambiguous) return { schema: OBSERVER_CURSOR_SCHEMA, status: 'ambiguous_parent', cursor: null };
32
+ const current = selected ?? emptyCandidate();
33
+ const throughCursor = encodeObserverCursor(cursorShape(current, projectSha256));
34
+ return withProjection(publicResult('snapshot', null, throughCursor, current), current, project, { dbPath, maxBodyChars, maxTotalChars });
35
+ }
36
+
37
+ let prior;
38
+ try { prior = decodeObserverCursor(cursor); } catch { return { schema: OBSERVER_CURSOR_SCHEMA, status: 'resync_required', afterCursor: cursor, throughCursor: null }; }
39
+ if (prior.schema !== OBSERVER_CURSOR_SCHEMA || prior.project_sha256 !== projectSha256) {
40
+ return { schema: OBSERVER_CURSOR_SCHEMA, status: 'resync_required', afterCursor: cursor, throughCursor: null };
41
+ }
42
+ const priorCandidate = prior.host === null
43
+ ? emptyCandidate()
44
+ : candidates.find((item) => item.host === prior.host && item.threadHash === prior.thread_sha256);
45
+ if (!priorCandidate || prior.host !== null &&
46
+ (prior.history_floor < priorCandidate.historyFloor || prior.length > priorCandidate.chain.length ||
47
+ prefixDigest(priorCandidate.chain.slice(0, prior.length)) !== prior.prefix_sha256)) {
48
+ return { schema: OBSERVER_CURSOR_SCHEMA, status: 'resync_required', afterCursor: cursor, throughCursor: null };
49
+ }
50
+ const selected = selectLatest(candidates);
51
+ if (selected?.ambiguous) return { schema: OBSERVER_CURSOR_SCHEMA, status: 'ambiguous_parent', cursor: null };
52
+ const current = selected ?? emptyCandidate();
53
+ const throughCursor = encodeObserverCursor(cursorShape(current, projectSha256));
54
+ const status = prior.host === null && current.host !== null ? 'append'
55
+ : prior.host !== null && current.host === null ? 'resync_required'
56
+ : prior.host !== current.host ? 'host_switched'
57
+ : prior.thread_sha256 !== current.threadHash ? 'thread_switched'
58
+ : prior.length === current.chain.length ? 'unchanged' : 'append';
59
+ return withProjection(publicResult(status, cursor, throughCursor, current), current, project, { dbPath, maxBodyChars, maxTotalChars });
60
+ }
61
+
62
+ /**
63
+ * Reads one fixed Observer page. A continuation is bound to the exact project,
64
+ * after cursor, through cursor, and verified logical-series prefix.
65
+ */
66
+ export function readObserverTurnPage({
67
+ projectPath, afterCursor = null, throughCursor = null, pageToken = null,
68
+ limit = DEFAULT_PAGE_LIMIT, codexHome, receiptOptions, dbPath, maxBodyChars, maxTotalChars,
69
+ } = {}) {
70
+ assertPageLimit(limit);
71
+ const project = canonicalExistingProject(projectPath);
72
+ const projectSha256 = sha256(project);
73
+ const candidates = [...claudeCandidates(project, receiptOptions), ...codexCandidates(project, codexHome)];
74
+ const normalizedAfter = afterCursor ?? null;
75
+ const normalizedThrough = throughCursor ?? null;
76
+ const normalizedPageToken = pageToken ?? null;
77
+ let decodedPageToken = null;
78
+ if (normalizedPageToken !== null && (normalizedAfter === null || normalizedThrough === null)) {
79
+ throw new TypeError('observer page token requires afterCursor and throughCursor');
80
+ }
81
+ if (normalizedPageToken !== null) {
82
+ decodedPageToken = decodeObserverPageToken(normalizedPageToken);
83
+ validatePageTokenBinding(decodedPageToken, {
84
+ projectSha256, afterCursor: normalizedAfter, throughCursor: normalizedThrough,
85
+ });
86
+ }
87
+
88
+ let fixedThrough;
89
+ let fixedThroughCursor;
90
+ if (normalizedThrough === null) {
91
+ const selected = selectLatest(candidates);
92
+ if (selected?.ambiguous) return emptyReadResult('ambiguous_parent', normalizedAfter);
93
+ fixedThrough = selected ?? emptyCandidate();
94
+ fixedThroughCursor = encodeObserverCursor(cursorShape(fixedThrough, projectSha256));
95
+ } else {
96
+ const decodedThrough = decodeCursorForRead(normalizedThrough, projectSha256);
97
+ if (!decodedThrough) return emptyReadResult('resync_required', normalizedAfter);
98
+ const throughCandidate = validateCursorCandidate(decodedThrough, candidates);
99
+ if (!throughCandidate) return emptyReadResult('resync_required', normalizedAfter);
100
+ fixedThrough = fixedCandidate(throughCandidate, decodedThrough.length);
101
+ fixedThroughCursor = normalizedThrough;
102
+ }
103
+
104
+ if (normalizedAfter === null) {
105
+ if (normalizedPageToken !== null) throw new TypeError('snapshot does not accept a page token');
106
+ const historyTruncated = fixedThrough.chain.length > limit;
107
+ const pageEntries = fixedThrough.chain.slice(-limit);
108
+ return projectReadPage({
109
+ status: 'snapshot', afterCursor: null, throughCursor: fixedThroughCursor,
110
+ current: fixedThrough, pageEntries, project, dbPath, maxBodyChars, maxTotalChars,
111
+ historyTruncated, complete: true, nextToken: null,
112
+ });
113
+ }
114
+
115
+ const decodedAfter = decodeCursorForRead(normalizedAfter, projectSha256);
116
+ if (!decodedAfter) return emptyReadResult('resync_required', normalizedAfter);
117
+ const afterCandidate = validateCursorCandidate(decodedAfter, candidates);
118
+ if (!afterCandidate || fixedThrough.host === null && decodedAfter.host !== null) {
119
+ return emptyReadResult('resync_required', normalizedAfter);
120
+ }
121
+
122
+ const series = logicalDeltaSeries(decodedAfter, fixedThrough);
123
+ if (!series) return emptyReadResult('resync_required', normalizedAfter);
124
+ const { status, entries } = series;
125
+ let offset = 0;
126
+ if (decodedPageToken !== null) {
127
+ validatePageTokenPrefix(decodedPageToken, entries);
128
+ offset = decodedPageToken.offset;
129
+ }
130
+ const pageEntries = entries.slice(offset, offset + limit);
131
+ const nextOffset = offset + pageEntries.length;
132
+ const complete = nextOffset >= entries.length;
133
+ const nextToken = complete ? null : encodeObserverPageToken(pageTokenShape({
134
+ projectSha256, afterCursor: normalizedAfter, throughCursor: fixedThroughCursor,
135
+ offset: nextOffset, entries,
136
+ }));
137
+ return projectReadPage({
138
+ status, afterCursor: normalizedAfter, throughCursor: fixedThroughCursor,
139
+ current: fixedThrough, pageEntries, project, dbPath, maxBodyChars, maxTotalChars,
140
+ historyTruncated: false, complete, nextToken,
141
+ });
142
+ }
143
+
144
+ export function encodeObserverCursor(value) {
145
+ validateCursorShape(value);
146
+ return `${CURSOR_PREFIX}${Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')}`;
147
+ }
148
+
149
+ export function decodeObserverCursor(token) {
150
+ if (typeof token !== 'string' || token.length > 4096 || !token.startsWith(CURSOR_PREFIX)) {
151
+ throw new TypeError('observer cursor invalid');
152
+ }
153
+ let value;
154
+ try { value = JSON.parse(Buffer.from(token.slice(CURSOR_PREFIX.length), 'base64url').toString('utf8')); } catch { throw new TypeError('observer cursor invalid'); }
155
+ validateCursorShape(value);
156
+ return value;
157
+ }
158
+
159
+ function claudeCandidates(projectPath, receiptOptions) {
160
+ const snapshot = readCompletedTurnReceiptSnapshot({ projectPath, ...(receiptOptions ?? {}) });
161
+ const grouped = new Map();
162
+ for (const receipt of snapshot.receipts) {
163
+ const threadHash = sha256(receipt.target_session_id);
164
+ const chain = grouped.get(threadHash) ?? [];
165
+ chain.push({
166
+ host: 'claude', thread_sha256: threadHash, origin_sha256: sha256(receipt.origin_session_id),
167
+ user_sha256: receipt.user_sha256, assistant_sha256: receipt.assistant_sha256,
168
+ completed_at: receipt.completed_at, source_sha256: sha256(`claude:${receipt.sequence}`), _sessionId: receipt.target_session_id,
169
+ });
170
+ grouped.set(threadHash, chain);
171
+ }
172
+ return [...grouped].map(([threadHash, chain]) => candidate('claude', threadHash, chain, snapshot.history_floor, chain.at(-1)?._sessionId ?? null));
173
+ }
174
+
175
+ function codexCandidates(projectPath, codexHome) {
176
+ return listCodexThreadCandidates({ codexHome, projectPath, limit: Number.MAX_SAFE_INTEGER })
177
+ .map((item) => {
178
+ const parsed = parseCodexRolloutFile(item.rolloutPath);
179
+ const chain = parsed.activeTurns.flatMap((turn) => codexTurnEntry(turn, item.id, item.rolloutPath));
180
+ return candidate('codex', sha256(item.id), chain, 1, buildCodexThroughlineSessionId(item.id));
181
+ })
182
+ .filter((item) => item.chain.length > 0);
183
+ }
184
+
185
+ function codexTurnEntry(turn, threadId, rolloutPath) {
186
+ if (!Number.isSafeInteger(turn.completedAt) || turn.completedAt < 0) return [];
187
+ const sessionId = buildCodexThroughlineSessionId(threadId);
188
+ const rows = buildBodyRowsFromActiveTurns([turn], { sessionId, now: turn.completedAt });
189
+ const user = rows.find((row) => row.role === 'user');
190
+ const assistant = rows.find((row) => row.role === 'assistant');
191
+ if (!user || !assistant) return [];
192
+ return [{
193
+ host: 'codex', thread_sha256: sha256(threadId), origin_sha256: hashAuditorBody(sessionId),
194
+ user_sha256: hashAuditorBody(user.text), assistant_sha256: hashAuditorBody(assistant.text),
195
+ completed_at: turn.completedAt, source_sha256: sha256(`${rolloutPath}\0${turn.number}`),
196
+ }];
197
+ }
198
+
199
+ function candidate(host, threadHash, chain, historyFloor, sessionId) {
200
+ return { host, threadHash, chain, historyFloor, latestAt: chain.at(-1)?.completed_at ?? -1,
201
+ sourceHash: chain.at(-1)?.source_sha256 ?? sha256(`${host}:${threadHash}`), sessionId };
202
+ }
203
+
204
+ function emptyCandidate() { return { host: null, threadHash: null, chain: [], historyFloor: 1, latestAt: -1, sourceHash: null, sessionId: null }; }
205
+
206
+ function selectLatest(candidates) {
207
+ if (candidates.length === 0) return null;
208
+ const latestAt = Math.max(...candidates.map((item) => item.latestAt));
209
+ const latest = candidates.filter((item) => item.latestAt === latestAt);
210
+ if (new Set(latest.map((item) => item.host)).size > 1) return { ambiguous: true };
211
+ return latest.sort((a, b) => b.threadHash.localeCompare(a.threadHash) || b.sourceHash.localeCompare(a.sourceHash))[0];
212
+ }
213
+
214
+ function cursorShape(current, projectSha256) {
215
+ return {
216
+ schema: OBSERVER_CURSOR_SCHEMA, project_sha256: projectSha256, host: current.host,
217
+ thread_sha256: current.threadHash, history_floor: current.historyFloor,
218
+ length: current.chain.length, prefix_sha256: prefixDigest(current.chain),
219
+ };
220
+ }
221
+
222
+ function publicResult(status, afterCursor, throughCursor, current) {
223
+ return {
224
+ schema: OBSERVER_CURSOR_SCHEMA, status, afterCursor, throughCursor,
225
+ host: current.host, thread_sha256: current.threadHash,
226
+ chain: current.chain.map(({ _sessionId: _sessionId, ...entry }) => ({ ...entry })),
227
+ };
228
+ }
229
+
230
+ function withProjection(result, current, projectPath, { dbPath, maxBodyChars, maxTotalChars }) {
231
+ if (!dbPath || current.chain.length === 0 || result.status === 'resync_required') return result;
232
+ const projection = readCompletedPairProjection({
233
+ dbPath, sessionId: current.sessionId, projectRoot: projectPath,
234
+ expectedPairs: current.chain.map(({ origin_sha256, user_sha256, assistant_sha256 }) => ({ origin_sha256, user_sha256, assistant_sha256 })),
235
+ ...(maxBodyChars === undefined ? {} : { maxBodyChars }),
236
+ ...(maxTotalChars === undefined ? {} : { maxTotalChars }),
237
+ });
238
+ if (projection.status === 'pending') return { ...result, status: 'projection_pending', throughCursor: null, turns: [] };
239
+ return { ...result, turns: projection.turns };
240
+ }
241
+
242
+ function decodeCursorForRead(token, projectSha256) {
243
+ let decoded;
244
+ try { decoded = decodeObserverCursor(token); } catch { return null; }
245
+ return decoded.project_sha256 === projectSha256 ? decoded : null;
246
+ }
247
+
248
+ function validateCursorCandidate(cursor, candidates) {
249
+ if (cursor.host === null) return emptyCandidate();
250
+ const current = candidates.find((item) => item.host === cursor.host && item.threadHash === cursor.thread_sha256);
251
+ if (!current || cursor.history_floor < current.historyFloor || cursor.length > current.chain.length ||
252
+ prefixDigest(current.chain.slice(0, cursor.length)) !== cursor.prefix_sha256) return null;
253
+ return current;
254
+ }
255
+
256
+ function fixedCandidate(current, length) {
257
+ const chain = current.chain.slice(0, length);
258
+ return {
259
+ ...current, chain, latestAt: chain.at(-1)?.completed_at ?? -1,
260
+ sourceHash: chain.at(-1)?.source_sha256 ?? current.sourceHash,
261
+ };
262
+ }
263
+
264
+ function logicalDeltaSeries(after, through) {
265
+ if (after.host === null) return { status: 'delta', entries: through.chain };
266
+ if (through.host === null) return null;
267
+ if (after.host !== through.host) return { status: 'host_switched', entries: through.chain };
268
+ if (after.thread_sha256 !== through.threadHash) return { status: 'thread_switched', entries: through.chain };
269
+ if (after.length > through.chain.length || prefixDigest(through.chain.slice(0, after.length)) !== after.prefix_sha256) return null;
270
+ return { status: 'delta', entries: through.chain.slice(after.length) };
271
+ }
272
+
273
+ function projectReadPage({
274
+ status, afterCursor, throughCursor, current, pageEntries, project, dbPath,
275
+ maxBodyChars, maxTotalChars, historyTruncated, complete, nextToken,
276
+ }) {
277
+ const base = {
278
+ schema: OBSERVER_READ_SCHEMA, status, host: current.host, thread_sha256: current.threadHash,
279
+ afterCursor, throughCursor, turns: [], historyTruncated,
280
+ page: { complete, nextToken },
281
+ };
282
+ if (pageEntries.length === 0) return base;
283
+ const projection = readCompletedPairProjection({
284
+ ...(dbPath === undefined ? {} : { dbPath }), sessionId: current.sessionId, projectRoot: project,
285
+ expectedPairs: pageEntries.map(({ origin_sha256, user_sha256, assistant_sha256 }) => ({ origin_sha256, user_sha256, assistant_sha256 })),
286
+ ...(maxBodyChars === undefined ? {} : { maxBodyChars }),
287
+ ...(maxTotalChars === undefined ? {} : { maxTotalChars }),
288
+ });
289
+ if (projection.status === 'pending') {
290
+ return { ...base, status: 'projection_pending', throughCursor: null, page: { complete: false, nextToken: null } };
291
+ }
292
+ return {
293
+ ...base,
294
+ turns: pageEntries.map((entry, index) => ({ ...publicTurnEntry(entry), ...projection.turns[index] })),
295
+ };
296
+ }
297
+
298
+ function publicTurnEntry({ _sessionId: _sessionId, ...entry }) { return entry; }
299
+
300
+ function emptyReadResult(status, afterCursor) {
301
+ return {
302
+ schema: OBSERVER_READ_SCHEMA, status, host: null, thread_sha256: null,
303
+ afterCursor, throughCursor: null, turns: [], historyTruncated: false,
304
+ page: { complete: true, nextToken: null },
305
+ };
306
+ }
307
+
308
+ function pageTokenShape({ projectSha256, afterCursor, throughCursor, offset, entries }) {
309
+ return {
310
+ schema: OBSERVER_PAGE_SCHEMA, project_sha256: projectSha256,
311
+ after_sha256: cursorTokenDigest(afterCursor), through_sha256: cursorTokenDigest(throughCursor),
312
+ offset, offset_prefix_sha256: prefixDigest(entries.slice(0, offset)),
313
+ };
314
+ }
315
+
316
+ function encodeObserverPageToken(value) {
317
+ validatePageTokenShape(value);
318
+ return `${PAGE_PREFIX}${Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')}`;
319
+ }
320
+
321
+ function decodeObserverPageToken(token) {
322
+ if (typeof token !== 'string' || token.length > 4096 || !token.startsWith(PAGE_PREFIX)) {
323
+ throw new TypeError('observer page token invalid');
324
+ }
325
+ const encoded = token.slice(PAGE_PREFIX.length);
326
+ let value;
327
+ try {
328
+ const bytes = Buffer.from(encoded, 'base64url');
329
+ if (bytes.toString('base64url') !== encoded) throw new TypeError('non-canonical token');
330
+ const json = bytes.toString('utf8');
331
+ value = JSON.parse(json);
332
+ if (JSON.stringify(value) !== json) throw new TypeError('non-canonical token');
333
+ } catch { throw new TypeError('observer page token invalid'); }
334
+ validatePageTokenShape(value);
335
+ return value;
336
+ }
337
+
338
+ function validatePageTokenBinding(token, { projectSha256, afterCursor, throughCursor }) {
339
+ if (token.project_sha256 !== projectSha256 || token.after_sha256 !== cursorTokenDigest(afterCursor) ||
340
+ token.through_sha256 !== cursorTokenDigest(throughCursor)) {
341
+ throw new TypeError('observer page token binding invalid');
342
+ }
343
+ }
344
+
345
+ function validatePageTokenPrefix(token, entries) {
346
+ if (token.offset > entries.length || token.offset_prefix_sha256 !== prefixDigest(entries.slice(0, token.offset))) {
347
+ throw new TypeError('observer page token binding invalid');
348
+ }
349
+ }
350
+
351
+ function cursorTokenDigest(value) { return sha256(value === null ? 'null' : value); }
352
+
353
+ function prefixDigest(chain) {
354
+ return sha256(chain.map((entry) => [entry.host, entry.thread_sha256, entry.origin_sha256,
355
+ entry.user_sha256, entry.assistant_sha256, entry.completed_at, entry.source_sha256].join('\0')).join('\n'));
356
+ }
357
+
358
+ function canonicalExistingProject(value) {
359
+ if (typeof value !== 'string' || !isAbsolute(value) || !existsSync(value) || !statSync(value).isDirectory()) {
360
+ throw new TypeError('projectPath must be an existing absolute directory');
361
+ }
362
+ return realpathSync.native(value);
363
+ }
364
+
365
+ function sha256(value) { return createHash('sha256').update(String(value), 'utf8').digest('hex'); }
366
+ function isSha256(value) { return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); }
367
+ function assertPageLimit(value) {
368
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_PAGE_LIMIT) {
369
+ throw new TypeError(`limit must be an integer between 1 and ${MAX_PAGE_LIMIT}`);
370
+ }
371
+ }
372
+ function validatePageTokenShape(value) {
373
+ const keys = ['schema', 'project_sha256', 'after_sha256', 'through_sha256', 'offset', 'offset_prefix_sha256'];
374
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length ||
375
+ keys.some((key) => !(key in value)) || value.schema !== OBSERVER_PAGE_SCHEMA ||
376
+ !isSha256(value.project_sha256) || !isSha256(value.after_sha256) || !isSha256(value.through_sha256) ||
377
+ !Number.isSafeInteger(value.offset) || value.offset < 0 || !isSha256(value.offset_prefix_sha256)) {
378
+ throw new TypeError('observer page token invalid');
379
+ }
380
+ }
381
+ function validateCursorShape(value) {
382
+ const keys = ['schema', 'project_sha256', 'host', 'thread_sha256', 'history_floor', 'length', 'prefix_sha256'];
383
+ const emptyPrefix = prefixDigest([]);
384
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length ||
385
+ keys.some((key) => !(key in value)) || value.schema !== OBSERVER_CURSOR_SCHEMA || !isSha256(value.project_sha256) ||
386
+ !isSha256(value.prefix_sha256) || !Number.isSafeInteger(value.history_floor) || value.history_floor < 1 ||
387
+ !Number.isSafeInteger(value.length) || value.length < 0 ||
388
+ !((value.host === null && value.thread_sha256 === null && value.length === 0 && value.history_floor === 1 && value.prefix_sha256 === emptyPrefix) ||
389
+ (['claude', 'codex'].includes(value.host) && isSha256(value.thread_sha256) && value.length >= 1))) {
390
+ throw new TypeError('observer cursor invalid');
391
+ }
392
+ }