@volter/supercode-interchange 0.0.0-stage → 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.
@@ -0,0 +1,107 @@
1
+ import { readStore, statement, tableColumns, text, integer, unsigned, jsonColumn } from './sqlite-reader.mjs';
2
+ import { finalizeNouns } from './metadata.mjs';
3
+ import { hermesMirrorKey } from './hermes-mirror.mjs';
4
+ import { nativeInteger } from './native-json.mjs';
5
+
6
+ const extendedColumns = ['session_key', 'chat_id', 'chat_type', 'thread_id', 'user_id', 'profile_name', 'handoff_state', 'handoff_platform', 'handoff_error'];
7
+ const nonempty = (value) => typeof value === 'string' && value !== '' ? value : null;
8
+ const numeric = (value) => value == null ? null : typeof value === 'number' || typeof value === 'bigint' ? Number(value) : (() => { throw new Error('Hermes returned a non-numeric epoch'); })();
9
+
10
+ function sourceTrigger(source) {
11
+ if (!source) return 'unknown';
12
+ if (['cli', 'tui', 'acp', 'console'].includes(source)) return 'human';
13
+ if (['api_server', 'api'].includes(source)) return 'api';
14
+ if (source === 'cron' || source === 'webhook') return source;
15
+ return source === 'kanban' ? 'task' : 'channel';
16
+ }
17
+
18
+ function lineage(db, columns, row, config) {
19
+ if (row.parent_session_id == null) return null;
20
+ if (config?._delegate_from != null) return 'delegate';
21
+ if (config?._branched_from != null) return 'branch';
22
+ if (!columns.has('end_reason')) return 'unknown';
23
+ const parent = statement(db, 'SELECT end_reason, ended_at FROM sessions WHERE id=?').get(row.parent_session_id);
24
+ if (parent?.ended_at != null && typeof parent.ended_at !== 'number' && typeof parent.ended_at !== 'bigint') return 'unknown';
25
+ if (parent?.end_reason === 'compression') return 'compaction';
26
+ if (parent?.end_reason === 'branched' && (numeric(row.started_at) ?? Number.MAX_VALUE) >= (numeric(parent.ended_at) ?? Number.MAX_VALUE)) return 'branch';
27
+ return 'unknown';
28
+ }
29
+
30
+ function mirrorRecurrence(config) {
31
+ const value = config?._supercode_mirror?.recurrence;
32
+ if (value == null || typeof value.job_id !== 'string' || (value.kind !== undefined && typeof value.kind !== 'string')) return null;
33
+ return { job_id: value.job_id, kind: value.kind ?? 'cron' };
34
+ }
35
+
36
+ function nouns(row, relationship, config, extended) {
37
+ const source = nonempty(row.source) ?? '';
38
+ let trigger = relationship === 'delegate' ? 'parent' : sourceTrigger(source);
39
+ let recurrence = null;
40
+ const cron = /^cron_(.+)_([0-9]{8})_([0-9]{6})$/.exec(row.id);
41
+ if (cron) { trigger = 'cron'; recurrence = { job_id: cron[1], kind: 'cron' }; }
42
+ recurrence ??= mirrorRecurrence(config);
43
+ let surface = {}, profile = null;
44
+ if (extended) {
45
+ const key = nonempty(row.session_key);
46
+ const parts = key?.split(':');
47
+ if (parts?.[0] === 'agent' && parts.length >= 4) {
48
+ surface = { key, platform: parts[2], kind: parts[3] };
49
+ if (!['', 'main', 'default'].includes(parts[1])) profile = parts[1];
50
+ if (parts.length >= 5) surface.chat_id = parts[4];
51
+ if (parts.length >= 6) surface.thread_id = parts[5];
52
+ if (parts.length >= 7) surface.participant_id = parts[6];
53
+ } else if (key != null) surface.key = key;
54
+ for (const [column, field] of [['chat_id', 'chat_id'], ['chat_type', 'kind'], ['thread_id', 'thread_id'], ['user_id', 'participant_id']]) {
55
+ const value = nonempty(row[column]);
56
+ if (value != null) surface[field] = value;
57
+ }
58
+ if (surface.platform == null && trigger === 'channel') surface.platform = source;
59
+ profile = nonempty(row.profile_name) ?? profile;
60
+ }
61
+ const state = extended ? nonempty(row.handoff_state) : null;
62
+ const platform = nonempty(row.handoff_platform), error = nonempty(row.handoff_error);
63
+ return { trigger, ...(recurrence ? { recurrence } : {}),
64
+ ...(surface.key != null || surface.platform != null || surface.chat_id != null ? { surface } : {}),
65
+ ...(profile != null ? { profile } : {}),
66
+ ...(state != null ? { cross_surface: { state, ...(platform != null ? { platform } : {}), ...(error != null ? { error } : {}) } } : {}) };
67
+ }
68
+
69
+ export async function readHermesStore(path, { metrics = {} } = {}) {
70
+ return readStore(path, (db) => {
71
+ statement(db, 'SELECT count(*) FROM sessions').get();
72
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
73
+ if (!['sessions', 'messages', 'schema_version'].every((table) => tables.has(table))) return { rows: [], mirrors: new Map() };
74
+ const columns = tableColumns(db, 'sessions');
75
+ const extendedSchema = extendedColumns.every((column) => columns.has(column));
76
+ const values = statement(db, `SELECT id, cwd, title, model, message_count, started_at, ended_at, parent_session_id, source, model_config
77
+ ${extendedSchema ? ',' + extendedColumns.join(',') : ''} FROM sessions ORDER BY started_at DESC`).all();
78
+ const rows = [], mirrors = new Map();
79
+ for (const value of values) {
80
+ for (const column of ['cwd', 'title', 'model', 'parent_session_id', 'source', 'model_config']) text(value[column], true);
81
+ const id = text(value.id), config = jsonColumn(value.model_config);
82
+ const started = numeric(value.started_at), ended = numeric(value.ended_at);
83
+ const milliseconds = ended == null && started == null ? null : Math.max(0, Math.trunc((ended ?? started) * 1000));
84
+ if (milliseconds != null && !Number.isSafeInteger(milliseconds)) throw new Error(`${path} has an epoch outside the exact Node wire range`);
85
+ const nativeCount = integer(value.message_count, true);
86
+ const rawCount = nativeCount == null ? null : Number(nativeCount);
87
+ if (rawCount != null && !Number.isSafeInteger(rawCount)) throw new Error(`${path} has a non-integer or inexact native message count`);
88
+ const count = rawCount == null ? null : unsigned(Math.max(0, rawCount));
89
+ const extended = extendedSchema && extendedColumns.every((column) => value[column] == null || typeof value[column] === 'string');
90
+ const row = finalizeNouns({ locator: { harness: 'hermes', session_id: id, storage: { kind: 'file', path } },
91
+ cwd: value.cwd, title: value.title === '' ? null : value.title, model: value.model, message_count: count,
92
+ updated_at_ms: milliseconds, ...(value.parent_session_id != null ? { parent_session_id: value.parent_session_id } : {}),
93
+ ...nouns(value, lineage(db, columns, value, config), config, extended) });
94
+ const mirror = config?._supercode_mirror;
95
+ const mirroredCount = nativeInteger(mirror, 'messages', { signed: true });
96
+ if (mirroredCount != null && (mirroredCount < BigInt(Number.MIN_SAFE_INTEGER) || mirroredCount > BigInt(Number.MAX_SAFE_INTEGER))) throw new Error(`${path} has an inexact mirror message count`);
97
+ if (mirror != null) mirrors.set(hermesMirrorKey(row), {
98
+ harness: typeof mirror.harness === 'string' ? mirror.harness : null,
99
+ messages: mirroredCount == null ? null : Number(mirroredCount),
100
+ continued: Object.hasOwn(mirror, 'continued_as'), count: rawCount ?? 0,
101
+ });
102
+ rows.push(row);
103
+ }
104
+ metrics.store_sessions_read = (metrics.store_sessions_read ?? 0) + rows.length;
105
+ return { rows, mirrors };
106
+ }, metrics);
107
+ }
package/homes.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import { userInfo } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /** Native HarnessHomes defaults. An empty selected harness home is present;
5
+ * only HOME itself uses the nonempty rule from interchange::user_home. */
6
+ export function resolveHarnessHomes(env = process.env) {
7
+ const accountHome = () => { try { return userInfo().homedir || '.'; } catch { return '.'; } };
8
+ const home = env.HOME || (process.platform === 'win32' && env.USERPROFILE) || accountHome();
9
+ const data = env.XDG_DATA_HOME ?? join(home, '.local', 'share');
10
+ const config = env.XDG_CONFIG_HOME ?? join(home, '.config');
11
+ const goose = env.GOOSE_PATH_ROOT != null
12
+ ? join(env.GOOSE_PATH_ROOT, 'data', 'sessions', 'sessions.db')
13
+ : process.platform === 'darwin'
14
+ ? join(home, 'Library', 'Application Support', 'Block', 'goose', 'sessions', 'sessions.db')
15
+ : process.platform === 'win32'
16
+ ? join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Block', 'goose', 'sessions', 'sessions.db')
17
+ : join(data, 'goose', 'sessions', 'sessions.db');
18
+ return {
19
+ claude_code: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), 'projects'),
20
+ codex: join(env.CODEX_HOME ?? join(home, '.codex'), 'sessions'),
21
+ pi: env.PI_CODING_AGENT_SESSION_DIR ?? join(env.PI_CODING_AGENT_DIR ?? join(home, '.pi', 'agent'), 'sessions'),
22
+ opencode: env.OPENCODE_DB ?? join(data, 'opencode'),
23
+ grok: join(env.GROK_HOME ?? join(home, '.grok'), 'sessions'),
24
+ gemini: env.GEMINI_CLI_HOME ?? join(home, '.gemini'),
25
+ goose,
26
+ supercode: join(env.SUPERCODE_HOME ?? join(config, 'supercode'), 'sessions'),
27
+ openclaw: env.OPENCLAW_STATE_DIR ?? (env.OPENCLAW_HOME != null ? join(env.OPENCLAW_HOME, '.openclaw') : join(home, '.openclaw')),
28
+ hermes: join(env.HERMES_HOME ?? join(home, '.hermes'), 'state.db'),
29
+ orchestrator: env.SUPERCODE_ORCHESTRATOR_HOME ?? join(home, '.supercode', 'orchestrator'),
30
+ };
31
+ }
32
+
33
+ export const HARNESSES = Object.freeze(['claude-code', 'codex', 'pi', 'opencode', 'grok', 'gemini', 'goose', 'supercode', 'openclaw', 'hermes', 'orchestrator']);
34
+
35
+ export function selectedHarnesses(query) {
36
+ return query.harnesses?.length ? [...new Set(query.harnesses)] : [...HARNESSES];
37
+ }
package/index.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ export type HarnessId = string;
2
+ export interface HarnessHomes {
3
+ claude_code: string; codex: string; pi: string; opencode: string;
4
+ grok: string; gemini: string; goose: string; supercode: string;
5
+ openclaw: string; hermes: string; orchestrator: string;
6
+ }
7
+ export type StorageLocator = { kind: 'file'; path: string } | { kind: 'sqlite'; path: string; selector: string };
8
+ export interface SessionLocator { harness: HarnessId; session_id: string; storage: StorageLocator }
9
+ export type SessionTrigger = 'human' | 'channel' | 'cron' | 'heartbeat' | 'webhook' | 'parent' | 'api' | 'task' | 'unknown';
10
+ export interface SessionSurface {
11
+ key?: string; platform?: string; kind?: string; chat_id?: string; thread_id?: string; participant_id?: string;
12
+ }
13
+ export interface PreviewCandidate {
14
+ cursor?: string; role: 'user' | 'assistant'; content: string; metadata: Record<string, string>;
15
+ }
16
+ export interface SessionDescriptor {
17
+ locator: SessionLocator;
18
+ cwd: string | null; title: string | null; updated_at_ms: number | null;
19
+ message_count: number | null; model: string | null;
20
+ parent_session_id?: string | null; child_session_count?: number;
21
+ preview_candidates?: PreviewCandidate[]; latest_message_candidates?: PreviewCandidate[];
22
+ trigger?: SessionTrigger; surface?: SessionSurface; profile?: string;
23
+ recurrence?: { job_id: string; kind: string };
24
+ cross_surface?: { state: string; platform?: string; error?: string };
25
+ workspace?: { kind: 'repo' | 'channel' | 'none'; value?: string };
26
+ }
27
+ export interface DiscoveryQuery {
28
+ homes?: Partial<HarnessHomes>; harnesses?: HarnessId[];
29
+ workspace?: string | null; workspace_subtree?: boolean; workspace_family?: string | null;
30
+ updated_after_ms?: number | null; updated_before_ms?: number | null;
31
+ query?: string | null; search_previews?: boolean; cursor?: string | null; limit?: number | null;
32
+ include_topic_candidates?: boolean; include_child_sessions?: boolean;
33
+ root_session_id?: string | null; profile?: string | null;
34
+ }
35
+ export interface DiscoveryReceipt {
36
+ searched_previews?: boolean;
37
+ requested_after_ms?: number; requested_before_ms?: number; requested_limit?: number;
38
+ oldest_returned_ms?: number; newest_returned_ms?: number;
39
+ returned: number; total_matched: number; truncated: boolean;
40
+ }
41
+ export interface DiscoveryPage {
42
+ sessions: SessionDescriptor[]; next_cursor: string | null; receipt: DiscoveryReceipt;
43
+ }
44
+ export interface RepositoryFamily { identity: string; repository: boolean; origin: string | null }
45
+ /** Source-private mirror facts are never added to a returned descriptor. */
46
+ export interface MetadataProjectionOptions {
47
+ hermesMirrors?: Map<string, { harness: string | null; messages: number | null; continued: boolean; count: number }>;
48
+ }
49
+ export const HARNESSES: readonly HarnessId[];
50
+ /** HOME-first defaults, preserving present empty harness-specific values. */
51
+ export function resolveHarnessHomes(env?: Record<string, string | undefined>): HarnessHomes;
52
+ export function selectedHarnesses(query: DiscoveryQuery): HarnessId[];
53
+ export function workspaceScope(query: DiscoveryQuery): ((descriptor: SessionDescriptor) => boolean) | null;
54
+ export function repositoryFamily(path: string): RepositoryFamily;
55
+ export function sameRepositoryFamily(left: RepositoryFamily, right: RepositoryFamily): boolean;
56
+ export function descriptorKey(row: SessionDescriptor): string;
57
+ export function descriptorOrder(left: SessionDescriptor, right: SessionDescriptor): number;
58
+ export function descriptorMatches(row: SessionDescriptor, lowerCaseSearch: string): boolean;
59
+ /** Raw rows are cloned; metadata projection performs no preview reads. */
60
+ export function projectMetadata(query: DiscoveryQuery, descriptors: Iterable<SessionDescriptor>, options?: MetadataProjectionOptions): SessionDescriptor[];
61
+ /** Existing tuple pagination cursor, separate from a durable change cursor. */
62
+ export function encodeDiscoveryCursor(row: SessionDescriptor): string;
63
+ export function decodeDiscoveryCursor(cursor: string): [number | null, HarnessId, string];
64
+ export function paginateDescriptors(query: DiscoveryQuery, rows: SessionDescriptor[]): { sessions: SessionDescriptor[]; next_cursor: string | null };
65
+ export function discoveryReceipt(query: DiscoveryQuery, sessions: SessionDescriptor[], totalMatched: number, truncated: boolean, searchedPreviews?: boolean): DiscoveryReceipt;
66
+ /** A metadata page only; public discovery enriches its returned rows separately. */
67
+ export function projectMetadataPage(query: DiscoveryQuery, descriptors: Iterable<SessionDescriptor>, options?: MetadataProjectionOptions): DiscoveryPage;
68
+ /** Read-only source APIs used by the daemon owner. These do not scan on behalf
69
+ * of SDK clients or mutate a foreign harness's store. */
70
+ export type IndexMetrics = Record<string, number>;
71
+ export interface FileFingerprint { device: string; inode: string; size: string; mtime_ns: string }
72
+ export interface SourceStamp { path: string; missing?: boolean; fingerprint?: FileFingerprint }
73
+ export interface FileReadCache {
74
+ fingerprint?: FileFingerprint; size?: number; modified_ms?: number | null;
75
+ result?: { descriptor?: SessionDescriptor | null; state?: Record<string, unknown> } | null;
76
+ descriptor?: SessionDescriptor | null; sources?: SourceStamp[];
77
+ }
78
+ export const FILE_HARNESSES: readonly HarnessId[];
79
+ export const STORE_HARNESSES: readonly HarnessId[];
80
+ export function normalizedSourcePath(path: string): Promise<string>;
81
+ export function pathUnder(root: string, path: string): boolean;
82
+ export function fileRoot(harness: HarnessId, homes: HarnessHomes): string | null;
83
+ export function sessionPartFor(harness: HarnessId, root: string, path: string): Promise<string | null>;
84
+ export function listSessionParts(harness: HarnessId, root: string, options?: { folder?: string; beforeDirectory?: (path: string) => Promise<void>; metrics?: IndexMetrics }): Promise<{ parts: Set<string>; directories: Set<string> }>;
85
+ export function readFileMetadata(harness: HarnessId, root: string, path: string, options?: { previous?: FileReadCache; projects?: Map<string, string>; nativeTopic?: (path: string, id: string) => string | null; metrics?: IndexMetrics }): Promise<{ descriptor: SessionDescriptor | null; cache: FileReadCache | null }>;
86
+ export function sourceStat(path: string): Promise<import('node:fs').BigIntStats | null>;
87
+ export function sourceEntries(path: string): Promise<string[]>;
88
+ export function selectStoreFiles(harness: HarnessId, homes: HarnessHomes): Promise<Array<{ path: string; profile: string | null }>>;
89
+ export function storeRegistryDirectories(harness: HarnessId, homes: HarnessHomes): Promise<string[]>;
90
+ export function fileFingerprint(info: import('node:fs').BigIntStats): FileFingerprint;
91
+ export function sameFingerprint(left?: FileFingerprint | null, right?: FileFingerprint | null): boolean;
92
+ export function verifySources(sources: SourceStamp[]): Promise<void>;
93
+ export function storeFingerprints(path: string): Promise<SourceStamp[]>;
94
+ export function readGeminiProjects(root: string, metrics?: IndexMetrics): Promise<{ source: SourceStamp; projects: Map<string, string> }>;
95
+ export function readSourceJson(path: string, metrics?: IndexMetrics): Promise<SourceStamp & { result: unknown }>;
96
+ export interface StoreRead<T> { sources: SourceStamp[]; result: T }
97
+ export function readForeignStore(harness: HarnessId, path: string, options?: { previousMarks?: Map<string, string>; metrics?: IndexMetrics }): Promise<StoreRead<{ marks: Map<string, string>; rows: SessionDescriptor[]; removed: string[] }>>;
98
+ export function readHermesStore(path: string, options?: { metrics?: IndexMetrics }): Promise<StoreRead<{ rows: SessionDescriptor[]; mirrors: NonNullable<MetadataProjectionOptions['hermesMirrors']> }>>;
99
+ export function readOrchestratorStore(path: string, profile: string, options?: { after?: string; metrics?: IndexMetrics }): Promise<StoreRead<{ rows: Array<{ key: string; descriptor: SessionDescriptor | null }>; cursor: string; incremental: boolean; has_changes: boolean }>>;
100
+ export function codexNamesPath(rollout: string): string | null;
101
+ export function readCodexNames(path: string, options?: { previous?: unknown; metrics?: IndexMetrics }): Promise<unknown>;
102
+ export function readCodexHistory(path: string, options?: { previous?: unknown; metrics?: IndexMetrics }): Promise<unknown>;
103
+ export function readClaudeTitles(path: string, options?: { previous?: unknown; metrics?: IndexMetrics }): Promise<unknown>;
104
+ export interface DiscoveryEnrichmentOptions extends MetadataProjectionOptions {
105
+ codexTopics?: Map<string, PreviewCandidate | PreviewCandidate[]>; metrics?: IndexMetrics;
106
+ previewFailure?: (locator: SessionLocator, error: Error) => void;
107
+ liveClaudeName?: (name: string) => Promise<{ id: string; name: string } | null>;
108
+ lateClaudeName?: (name: string) => Promise<string | null>;
109
+ }
110
+ export function enrichDescriptor(query: DiscoveryQuery, descriptor: SessionDescriptor, options?: DiscoveryEnrichmentOptions): Promise<SessionDescriptor>;
111
+ export function enrichIndexPage(query: DiscoveryQuery, descriptors: SessionDescriptor[], options?: DiscoveryEnrichmentOptions): Promise<SessionDescriptor[]>;
112
+ export function discoverFromIndex(query: DiscoveryQuery, descriptors: Iterable<SessionDescriptor>, options?: DiscoveryEnrichmentOptions): Promise<DiscoveryPage>;
113
+ export function nativeTrim(value: string): string;
114
+ export interface IndexMetadataRecord {
115
+ source: string; part: string; row_key: string; descriptor: SessionDescriptor;
116
+ mirror?: { harness: string | null; messages: number | null; continued: boolean; count: number } | null;
117
+ }
118
+ export class RetainedMetadataProjection {
119
+ constructor(query: DiscoveryQuery, sources: string[], records: Iterable<IndexMetadataRecord>, metrics?: IndexMetrics);
120
+ updatePart(source: string, part: string, records: Iterable<IndexMetadataRecord>): void;
121
+ partsFor(key: string): Set<string>;
122
+ page(query: DiscoveryQuery): DiscoveryPage;
123
+ }
124
+ /** Integer facts from readSourceJson, retaining native integer versus float tokens. */
125
+ export function nativeInteger(value: unknown, key: string, options?: { signed?: boolean }): bigint | null;
package/index.mjs ADDED
@@ -0,0 +1,20 @@
1
+ export { HARNESSES, resolveHarnessHomes, selectedHarnesses } from './homes.mjs';
2
+ export { repositoryFamily, sameRepositoryFamily, workspaceScope } from './workspace.mjs';
3
+ export {
4
+ descriptorKey, descriptorOrder, descriptorMatches, projectMetadata,
5
+ encodeDiscoveryCursor, decodeDiscoveryCursor, paginateDescriptors,
6
+ discoveryReceipt, projectMetadataPage,
7
+ } from './query.mjs';
8
+ export { FILE_HARNESSES, STORE_HARNESSES, normalizedSourcePath, pathUnder, fileRoot, sessionPartFor, listSessionParts, readFileMetadata } from './source-layout.mjs';
9
+ export { sourceStat, sourceEntries, selectStoreFiles, storeRegistryDirectories } from './store-registry.mjs';
10
+ export { fileFingerprint, sameFingerprint } from './file-io.mjs';
11
+ export { verifySources, readGeminiProjects, readSourceJson } from './source-files.mjs';
12
+ export { storeFingerprints } from './sqlite-reader.mjs';
13
+ export { readForeignStore } from './foreign-store.mjs';
14
+ export { readHermesStore } from './hermes-store.mjs';
15
+ export { readOrchestratorStore } from './orchestrator-store.mjs';
16
+ export { codexNamesPath, readCodexNames, readCodexHistory, readClaudeTitles } from './native-ledgers.mjs';
17
+ export { enrichDescriptor, enrichIndexPage, discoverFromIndex } from './discovery.mjs';
18
+ export { nativeInteger } from './native-json.mjs';
19
+ export { nativeTrim } from './text.mjs';
20
+ export { RetainedMetadataProjection } from './retained-projection.mjs';
package/jsonl.mjs ADDED
@@ -0,0 +1,124 @@
1
+ import { basename, dirname, extname } from 'node:path';
2
+ import { withSessionFile, readHeaderRecords, parseJsonLine, visitAppendedLines, sameFingerprint, appendableFingerprint } from './file-io.mjs';
3
+ import { finalizeNouns, headerFacts, headerComplete, readTailFacts, foldTailRecord, finishAppendedFacts } from './metadata.mjs';
4
+ import { readCompressedCodexHeader } from './compressed.mjs';
5
+
6
+ /** A cold suffix is kept only when the final line is unfinished. A partial
7
+ * line outside the native metadata budget requests a cold bounded rebuild on
8
+ * its next write; it never becomes a silently truncated JSON record. */
9
+ export async function unfinishedSuffix(reader) {
10
+ if (!reader.size) return { trailing: '', appendable: true };
11
+ const last = await reader.read(reader.size - 1, 1);
12
+ if (last[0] === 10) return { trailing: '', appendable: true };
13
+ const bytes = await reader.read(Math.max(0, reader.size - 1024 * 1024), Math.min(reader.size, 1024 * 1024));
14
+ const newline = bytes.lastIndexOf(10);
15
+ if (newline < 0 && bytes.length < reader.size) return { trailing: '', appendable: false };
16
+ return { trailing: bytes.subarray(newline + 1).toString('base64'), appendable: true };
17
+ }
18
+
19
+ function checkHarness(path, harness) {
20
+ if (!['claude-code', 'codex', 'pi'].includes(harness)) throw new Error(`no generic JSONL reader for ${harness}`);
21
+ if (path.endsWith('.zst')) throw new Error('a compressed rollout requires the compressed header reader');
22
+ }
23
+
24
+ async function readHeader(reader, harness) {
25
+ const records = await readHeaderRecords(reader);
26
+ const header = headerFacts(records, harness);
27
+ // An unfinished record can become malformed on a later write; it cannot
28
+ // freeze the header simply because its current bytes parse successfully.
29
+ return { header, header_frozen: records.scan.terminated && (records.scan.exhausted || headerComplete(header, harness)) };
30
+ }
31
+
32
+ async function descriptor(path, harness, reader, header, facts, nativeTopic) {
33
+ const sessionId = header.session_id ?? basename(path, extname(path));
34
+ let title = header.title ?? header.topic;
35
+ if (title == null && harness === 'codex') {
36
+ if (typeof nativeTopic !== 'function') throw new Error('Codex metadata requires its native thread-name source');
37
+ title = await nativeTopic(path, sessionId);
38
+ }
39
+ const parent = header.parent_session_id ?? (harness === 'claude-code' && basename(dirname(path)) === 'subagents' ? basename(dirname(dirname(path))) : null);
40
+ return finalizeNouns({
41
+ locator: { harness, session_id: sessionId, storage: { kind: 'file', path } },
42
+ cwd: header.cwd, title, updated_at_ms: facts.last_turn_ms ?? reader.modified_ms,
43
+ message_count: null, model: facts.model ?? header.model,
44
+ ...(parent != null ? { parent_session_id: parent } : {}),
45
+ ...(header.trigger != null ? { trigger: header.trigger } : {}),
46
+ });
47
+ }
48
+
49
+ async function cold(reader, harness, nativeTopic) {
50
+ const head = await readHeader(reader, harness);
51
+ const tail = await readColdMetadataTail(reader, harness);
52
+ return {
53
+ descriptor: await descriptor(reader.path, harness, reader, head.header, tail.facts, nativeTopic),
54
+ state: { ...head, ...tail },
55
+ };
56
+ }
57
+
58
+ export async function readColdMetadataTail(reader, harness) {
59
+ return { facts: await readTailFacts(reader, harness), offset: reader.size, ...await unfinishedSuffix(reader) };
60
+ }
61
+
62
+ /** Claude, Codex and Pi metadata from one uncompressed transcript identity.
63
+ * The daemon supplies Codex's separately watched native thread-name index;
64
+ * this reader never launches native discovery or scans unrelated sessions. */
65
+ export async function readUncompressedJsonl(path, harness, { nativeTopic, metrics } = {}) {
66
+ checkHarness(path, harness);
67
+ return withSessionFile(path, (reader) => cold(reader, harness, nativeTopic), metrics);
68
+ }
69
+
70
+ /** Native archived Codex rollouts decode their header, while the retained
71
+ * catalog's tail scan reads the packed file's bytes. Preserve that contract:
72
+ * compressed bytes normally supply no JSON clock, leaving mtime as recency. */
73
+ export async function readCompressedCodex(path, { nativeTopic, metrics, previous } = {}) {
74
+ return withSessionFile(path, async (reader) => {
75
+ if (sameFingerprint(previous?.fingerprint, reader.fingerprint) && previous.result?.state) {
76
+ const old = previous.result.state;
77
+ return { ...previous.result, descriptor: await descriptor(path, 'codex', reader, old.header, old.facts, nativeTopic) };
78
+ }
79
+ const header = await readCompressedCodexHeader(reader);
80
+ const facts = await readTailFacts(reader, 'codex');
81
+ return {
82
+ descriptor: await descriptor(path, 'codex', reader, header, facts, nativeTopic),
83
+ state: { header, header_frozen: true, facts, offset: reader.size, trailing: '', appendable: false },
84
+ };
85
+ }, metrics);
86
+ }
87
+
88
+ /** A replacement, truncation or rewritten same-size file gets its own cold
89
+ * read. Offsets, header and row facts are returned under the same verified
90
+ * identity; the owner commits them together or retains the previous row. */
91
+ export async function refreshUncompressedJsonl(path, harness, previous, { nativeTopic, metrics } = {}) {
92
+ checkHarness(path, harness);
93
+ return withSessionFile(path, async (reader) => {
94
+ if (sameFingerprint(previous?.fingerprint, reader.fingerprint)) {
95
+ const held = previous.result;
96
+ return { ...held, descriptor: await descriptor(path, harness, reader, held.state.header, held.state.facts, nativeTopic) };
97
+ }
98
+ const old = previous?.result?.state;
99
+ if (!old?.appendable || !appendableFingerprint(previous.fingerprint, reader.fingerprint)) return cold(reader, harness, nativeTopic);
100
+ const next = await advanceMetadataTail(reader, harness, old);
101
+ const head = old.header_frozen ? { header: old.header, header_frozen: true } : await readHeader(reader, harness);
102
+ return {
103
+ descriptor: await descriptor(path, harness, reader, head.header, next.facts, nativeTopic),
104
+ state: { ...head, ...next },
105
+ };
106
+ }, metrics);
107
+ }
108
+
109
+ export async function advanceMetadataTail(reader, harness, old) {
110
+ const facts = { ...old.facts };
111
+ const suffix = await visitAppendedLines(reader, old, (line, { start }) => {
112
+ if (line == null) return; // The line exceeds the native tail budget.
113
+ const record = parseJsonLine(line);
114
+ if (record != null) foldTailRecord(facts, record, harness, start);
115
+ }, { maxLineBytes: 1024 * 1024 });
116
+ if (suffix.trailing) {
117
+ const record = parseJsonLine(Buffer.from(suffix.trailing, 'base64'));
118
+ if (record != null) foldTailRecord(facts, record, harness, reader.size - Buffer.byteLength(suffix.trailing, 'base64'));
119
+ }
120
+ // The old EOF record may have been valid before the appended bytes changed
121
+ // it. Its predecessor is not implied by the cached newest-record facts.
122
+ const nextFacts = old.trailing ? await readTailFacts(reader, harness) : await finishAppendedFacts(reader, harness, old.facts, facts);
123
+ return { facts: nextFacts, ...suffix };
124
+ }
package/metadata.mjs ADDED
@@ -0,0 +1,168 @@
1
+ import { tailRecords } from './file-io.mjs';
2
+ import { nativeTrim } from './text.mjs';
3
+
4
+ const string = (value) => typeof value === 'string' ? value : null;
5
+ const fill = (result, key, value) => { if (result[key] == null && typeof value === 'string') result[key] = value; };
6
+
7
+ /** Match sidecar::rfc3339_to_ms as used by the native catalog, including its
8
+ * existing fractional-digit and civil-date rules. Do not silently substitute
9
+ * Date.parse's broader formats or different offset interpretation. */
10
+ export function catalogTimestamp(value) {
11
+ if (typeof value !== 'string') return null;
12
+ let text = nativeTrim(value);
13
+ if (text.endsWith('Z')) text = text.slice(0, -1);
14
+ const at = text.indexOf('T');
15
+ if (at < 0) return null;
16
+ const date = text.slice(0, at), time = text.slice(at + 1);
17
+ const dateParts = /^([+]?\d+)-([+]?\d+)-([+-]?\d+)$/.exec(date);
18
+ const dot = time.indexOf('.');
19
+ const clock = dot < 0 ? time : time.slice(0, dot);
20
+ const timeParts = /^([+-]?\d+):([+-]?\d+):([+-]?\d+)$/.exec(clock);
21
+ if (!dateParts || !timeParts) return null;
22
+ const digits = dot < 0 ? '' : /^\d+/.exec(time.slice(dot + 1))?.[0];
23
+ if (dot >= 0 && !digits) return null;
24
+ const minimum = -(1n << 63n), maximum = (1n << 63n) - 1n;
25
+ const values = [...dateParts.slice(1), ...timeParts.slice(1)].map((part) => BigInt(part));
26
+ if (values.some((part) => part < minimum || part > maximum)) return null;
27
+ let [year, month, day, hour, minute, second] = values;
28
+ if (month < 1n || month > 12n || day < 1n || day > 31n) return null;
29
+ if (month <= 2n) year--;
30
+ const numerator = year >= 0n ? year : year - 399n;
31
+ const era = numerator >= 0n ? numerator / 400n : (numerator - 399n) / 400n;
32
+ const yoe = year - era * 400n;
33
+ const mp = month > 2n ? month - 3n : month + 9n;
34
+ const doy = (153n * mp + 2n) / 5n + day - 1n;
35
+ const days = era * 146097n + yoe * 365n + yoe / 4n - yoe / 100n + doy - 719468n;
36
+ const milliseconds = dot < 0 ? 0n : BigInt(digits.slice(0, 3).padEnd(3, '0'));
37
+ const result = (days * 86400n + hour * 3600n + minute * 60n + second) * 1000n + milliseconds;
38
+ if (result < 0n || result > maximum || result > BigInt(Number.MAX_SAFE_INTEGER)) return null;
39
+ return Number(result);
40
+ }
41
+
42
+ export function recordModel(record, harness) {
43
+ if (harness === 'claude-code' || harness === 'pi') return string(record?.message?.model);
44
+ if (harness === 'codex' && record?.type === 'turn_context') return string(record?.payload?.model);
45
+ return null;
46
+ }
47
+
48
+ export function recordTimestamp(record, harness) {
49
+ return catalogTimestamp(record?.[harness === 'supercode' ? 'ts' : 'timestamp']);
50
+ }
51
+
52
+ /** Native bounded recency/model scan, with source offsets retained for the
53
+ * append-aware owner. Newest means the last valid stamped record in the file,
54
+ * rather than the maximum clock across records or the file's mtime. */
55
+ export async function readTailFacts(reader, harness) {
56
+ const facts = { last_turn_ms: null, model: null, clock_offset: null, model_offset: null };
57
+ let window = Math.min(16 * 1024, reader.size);
58
+ for (;;) {
59
+ const from = reader.size - window;
60
+ const bytes = await reader.read(from, window);
61
+ for (const { value, start } of tailRecords(bytes, window < reader.size)) {
62
+ if (facts.last_turn_ms == null) {
63
+ facts.last_turn_ms = recordTimestamp(value, harness);
64
+ if (facts.last_turn_ms != null) facts.clock_offset = from + start;
65
+ }
66
+ if (facts.model == null) {
67
+ facts.model = recordModel(value, harness);
68
+ if (facts.model != null) facts.model_offset = from + start;
69
+ }
70
+ if (facts.last_turn_ms != null && facts.model != null) break;
71
+ }
72
+ if (facts.last_turn_ms != null || window >= reader.size || window >= 1024 * 1024) return { ...facts, scan_from: from };
73
+ window = Math.min(window * 2, reader.size, 1024 * 1024);
74
+ }
75
+ }
76
+
77
+ export function foldTailRecord(facts, record, harness, start) {
78
+ const clock = recordTimestamp(record, harness), model = recordModel(record, harness);
79
+ if (clock != null) { facts.last_turn_ms = clock; facts.clock_offset = start; }
80
+ if (model != null) { facts.model = model; facts.model_offset = start; }
81
+ }
82
+
83
+ /** Reuse known positions only while the new native tail window contains them.
84
+ * If that window exposes an uninspected prefix, read it before claiming there
85
+ * is no older model. Ordinary appended turns do not reread the old prefix. */
86
+ export async function finishAppendedFacts(reader, harness, previous, facts) {
87
+ let window = Math.min(16 * 1024, reader.size);
88
+ const contains = (start, from) => start != null && (from === 0 || start > from);
89
+ for (;;) {
90
+ const from = reader.size - window;
91
+ if (contains(facts.clock_offset, from) || window >= reader.size || window >= 1024 * 1024) {
92
+ if (from < previous.scan_from && (!contains(facts.clock_offset, from) || !contains(facts.model_offset, from))) return readTailFacts(reader, harness);
93
+ if (!contains(facts.clock_offset, from)) { facts.last_turn_ms = null; facts.clock_offset = null; }
94
+ if (!contains(facts.model_offset, from)) { facts.model = null; facts.model_offset = null; }
95
+ return { ...facts, scan_from: from };
96
+ }
97
+ window = Math.min(window * 2, reader.size, 1024 * 1024);
98
+ }
99
+ }
100
+
101
+ export function codexStartTrigger(payload) {
102
+ if (payload?.source != null && Object.hasOwn(payload.source, 'subagent')) return 'parent';
103
+ const originator = payload?.originator;
104
+ return typeof originator === 'string' && (originator === 'codex_exec' || originator === 'supercode' || originator.startsWith('supercode_')) ? 'api' : null;
105
+ }
106
+
107
+ function humanize(value) {
108
+ const text = value.replace(/[_-]/g, ' ');
109
+ const [first, ...rest] = [...text];
110
+ return first == null ? text : first.toUpperCase() + rest.join('');
111
+ }
112
+
113
+ /** Only the three dialects read by native read_header; other file-backed
114
+ * harnesses have their own container/summary readers. */
115
+ export function headerFacts(records, harness) {
116
+ if (!['claude-code', 'codex', 'pi'].includes(harness)) throw new Error(`no generic JSONL header reader for ${harness}`);
117
+ const result = { session_id: null, cwd: null, title: null, model: null, parent_session_id: null, trigger: null, topic: null };
118
+ for (const value of records) {
119
+ applyHeaderRecord(result, value, harness);
120
+ if (headerComplete(result, harness)) break;
121
+ }
122
+ if (result.session_id == null && result.cwd == null) throw new Error(`no recognizable ${harness} session header`);
123
+ return result;
124
+ }
125
+
126
+ export function applyHeaderRecord(result, value, harness) {
127
+ if (harness === 'claude-code') {
128
+ fill(result, 'session_id', value?.sessionId); fill(result, 'cwd', value?.cwd);
129
+ fill(result, 'title', value?.customTitle); fill(result, 'title', value?.agentName);
130
+ if (value?.type === 'ai-title') fill(result, 'topic', value.aiTitle);
131
+ fill(result, 'model', value?.message?.model);
132
+ } else if (harness === 'codex') {
133
+ const payload = value?.payload;
134
+ if (value?.type === 'session_meta') {
135
+ fill(result, 'session_id', payload?.id); fill(result, 'cwd', payload?.cwd);
136
+ result.trigger ??= codexStartTrigger(payload);
137
+ fill(result, 'title', payload?.thread_name); fill(result, 'title', payload?.title);
138
+ fill(result, 'parent_session_id', payload?.parent_thread_id);
139
+ const spawn = payload?.source?.subagent?.thread_spawn;
140
+ if (typeof spawn?.parent_thread_id === 'string') result.parent_session_id = spawn.parent_thread_id;
141
+ if (result.title == null && typeof spawn?.agent_path === 'string') {
142
+ const part = spawn.agent_path.split('/').filter(Boolean).at(-1);
143
+ if (part != null) result.title = humanize(part);
144
+ }
145
+ }
146
+ if (value?.type === 'turn_context') {
147
+ fill(result, 'cwd', payload?.cwd); fill(result, 'model', payload?.model);
148
+ }
149
+ } else if (harness === 'pi') {
150
+ if (value?.type === 'session') { fill(result, 'session_id', value.id); fill(result, 'cwd', value.cwd); }
151
+ fill(result, 'model', value?.message?.model);
152
+ }
153
+ }
154
+
155
+ export function headerComplete(header, harness) {
156
+ return header.session_id != null && header.cwd != null && header.model != null
157
+ && (harness !== 'claude-code' || header.title != null || header.topic != null);
158
+ }
159
+
160
+ /** Catalog finalization constructs metadata from these descriptor facts only;
161
+ * a parent id by itself does not add a trigger the native row never carried. */
162
+ export function finalizeNouns(row) {
163
+ const surface = row.surface;
164
+ const workspace = row.cwd != null ? { kind: 'repo', value: row.cwd }
165
+ : surface?.platform != null ? { kind: 'channel', value: surface.chat_id != null ? `${surface.platform}:${surface.chat_id}` : surface.platform }
166
+ : { kind: 'none' };
167
+ return { ...row, trigger: row.trigger ?? 'human', workspace };
168
+ }