trajectories 0.1.2 → 0.1.3

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/dist/shared.js ADDED
@@ -0,0 +1,357 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { TrajectoriesError } from './log-types.js';
3
+ import { digest, getObject, putObject, safeId, stableJson } from './log-io.js';
4
+ import { getSession, listSessions } from './log-read.js';
5
+ import { exportManagedDelta, importManagedDelta, validateManagedDelta } from './shared-managed.js';
6
+ import { commitObservedDelta, getObservedManifest, listObservedHistories, prepareObservedHistory, readObservedHistory, sharedObservationId, assertObservedState, assertObservedSummary } from './shared-observed.js';
7
+ export { sharedObservationId } from './shared-observed.js';
8
+ const MAX_BYTES = 64 * 1024 * 1024;
9
+ function tokenBytes(token) {
10
+ if (typeof token !== 'string' || !token || /[\s\x00-\x1f\x7f]/u.test(token) || token.length > 4096)
11
+ throw new TrajectoriesError('INVALID_INPUT', 'A nonempty shared-store token is required.');
12
+ return Buffer.from(`Bearer ${token}`);
13
+ }
14
+ async function boundedBody(message, limit) {
15
+ const declared = message.headers.get('content-length');
16
+ if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > limit))
17
+ throw new TrajectoriesError('CAPACITY_EXCEEDED', 'Shared transfer exceeds the admitted byte limit.');
18
+ const reader = message.body?.getReader();
19
+ if (!reader)
20
+ return new Uint8Array();
21
+ let size = 0;
22
+ const chunks = [];
23
+ try {
24
+ for (;;) {
25
+ const item = await reader.read();
26
+ if (item.done)
27
+ break;
28
+ size += item.value.byteLength;
29
+ if (size > limit)
30
+ throw new TrajectoriesError('CAPACITY_EXCEEDED', 'Shared transfer exceeds the admitted byte limit.');
31
+ chunks.push(item.value);
32
+ }
33
+ }
34
+ catch (error) {
35
+ await reader.cancel().catch(() => { });
36
+ throw error;
37
+ }
38
+ finally {
39
+ reader.releaseLock();
40
+ }
41
+ return Buffer.concat(chunks, size);
42
+ }
43
+ const json = (value, status = 200) => new Response(JSON.stringify(value), {
44
+ status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
45
+ });
46
+ const decode = (bytes) => {
47
+ try {
48
+ return JSON.parse(Buffer.from(bytes).toString());
49
+ }
50
+ catch {
51
+ throw new TrajectoriesError('INVALID_INPUT', 'Invalid shared-store JSON.');
52
+ }
53
+ };
54
+ function missingObject(error) { return error.cause?.code === 'ENOENT'; }
55
+ function assertManagedSummary(value, id) {
56
+ if (!value || value.version !== 2 || typeof value.workspace !== 'string' || !value.workspace
57
+ || typeof value.head !== 'string' || !/^[a-f0-9]{64}$/.test(value.head)
58
+ || typeof value.createdAt !== 'string' || typeof value.updatedAt !== 'string'
59
+ || !value.nativeCheckpoints || !Number.isSafeInteger(value.nativeCheckpoints.maxCount) || value.nativeCheckpoints.maxCount < 0
60
+ || !Number.isSafeInteger(value.nativeCheckpoints.maxBytes) || value.nativeCheckpoints.maxBytes < 0)
61
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Invalid shared managed session metadata.');
62
+ safeId(value.id);
63
+ if (id !== undefined && value.id !== id)
64
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared managed session identity mismatch.');
65
+ }
66
+ /** Node-compatible fetch handler; the host owns sockets, TLS, credentials and process lifetime. */
67
+ export function createSharedHandler(options) {
68
+ const expected = tokenBytes(options.token), limit = options.maxBytes ?? MAX_BYTES;
69
+ if (!Number.isSafeInteger(limit) || limit <= 0)
70
+ throw new TrajectoriesError('INVALID_INPUT', 'Invalid shared-store byte limit.');
71
+ return async (request) => {
72
+ const supplied = Buffer.from(request.headers.get('authorization') ?? '');
73
+ if (supplied.byteLength !== expected.byteLength || !timingSafeEqual(supplied, expected))
74
+ return json({ error: { code: 'UNAUTHORIZED', message: 'Shared-store authentication required.' } }, 401);
75
+ try {
76
+ const url = new URL(request.url), path = url.pathname;
77
+ if (path === '/v1/objects/check' && request.method === 'POST') {
78
+ const keys = decode(await boundedBody(request, limit));
79
+ if (!Array.isArray(keys) || keys.length > 512 || new Set(keys).size !== keys.length
80
+ || !keys.every(key => typeof key === 'string' && /^[a-f0-9]{64}$/.test(key)))
81
+ throw new TrajectoriesError('INVALID_INPUT', 'Invalid shared object inventory.');
82
+ const missing = [];
83
+ for (const key of keys) {
84
+ try {
85
+ await getObject(options.store, key);
86
+ }
87
+ catch (error) {
88
+ if (missingObject(error))
89
+ missing.push(key);
90
+ else
91
+ throw error;
92
+ }
93
+ }
94
+ return json({ missing });
95
+ }
96
+ if (path === '/v1/objects/batch' && request.method === 'POST') {
97
+ const items = decode(await boundedBody(request, limit));
98
+ if (!Array.isArray(items) || items.length > 512)
99
+ throw new TrajectoriesError('INVALID_INPUT', 'Invalid shared object batch.');
100
+ const verified = new Map();
101
+ for (const item of items) {
102
+ if (!item || typeof item.object !== 'string' || !/^[a-f0-9]{64}$/.test(item.object)
103
+ || typeof item.base64 !== 'string' || verified.has(item.object))
104
+ throw new TrajectoriesError('INVALID_INPUT', 'Invalid shared batch item.');
105
+ const bytes = Buffer.from(item.base64, 'base64');
106
+ if (bytes.toString('base64') !== item.base64 || digest(bytes) !== item.object)
107
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared batch contains corrupt content.');
108
+ verified.set(item.object, bytes);
109
+ }
110
+ for (const bytes of verified.values())
111
+ await putObject(options.store, bytes);
112
+ return json({ objects: [...verified.keys()] });
113
+ }
114
+ const object = /^\/v1\/objects\/([a-f0-9]{64})$/.exec(path);
115
+ if (object && ['GET', 'HEAD', 'PUT'].includes(request.method)) {
116
+ const key = object[1];
117
+ if (request.method === 'PUT') {
118
+ const bytes = await boundedBody(request, limit);
119
+ if (digest(bytes) !== key)
120
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared object does not match its declared digest.');
121
+ await putObject(options.store, bytes);
122
+ return json({ object: key });
123
+ }
124
+ let bytes;
125
+ try {
126
+ bytes = await getObject(options.store, key);
127
+ }
128
+ catch (error) {
129
+ if (missingObject(error))
130
+ return json({ error: { code: 'NOT_FOUND', message: 'Shared object not found.' } }, 404);
131
+ throw error;
132
+ }
133
+ if (bytes.byteLength > limit)
134
+ throw new TrajectoriesError('CAPACITY_EXCEEDED', 'Shared object exceeds the admitted byte limit.');
135
+ return new Response(request.method === 'HEAD' ? null : new Uint8Array(bytes), {
136
+ headers: { 'content-type': 'application/octet-stream', 'content-length': String(bytes.byteLength), 'cache-control': 'no-store' },
137
+ });
138
+ }
139
+ if (path === '/v1/observed' && request.method === 'GET')
140
+ return json(await listObservedHistories(options.store));
141
+ const observed = /^\/v1\/observed\/([a-f0-9]{64})$/.exec(path);
142
+ if (observed && request.method === 'GET')
143
+ return json(url.searchParams.get('manifest') === '1'
144
+ ? await getObservedManifest(options.store, observed[1]) : await readObservedHistory(options.store, observed[1]));
145
+ if (observed && request.method === 'POST') {
146
+ const delta = decode(await boundedBody(request, limit));
147
+ if (delta?.id !== observed[1])
148
+ throw new TrajectoriesError('INVALID_INPUT', 'Shared route and observation identity differ.');
149
+ return json(await commitObservedDelta(options.store, delta));
150
+ }
151
+ if (path === '/v1/managed' && request.method === 'GET') {
152
+ const sessions = [];
153
+ let cursor;
154
+ do {
155
+ const page = await listSessions(options.store, { cursor, limit: 1000 });
156
+ sessions.push(...page.items);
157
+ cursor = page.nextCursor ?? undefined;
158
+ } while (cursor);
159
+ return json(sessions);
160
+ }
161
+ const managed = /^\/v1\/managed\/([A-Za-z0-9][A-Za-z0-9_-]{0,127})$/.exec(path);
162
+ if (managed && request.method === 'GET') {
163
+ if (url.searchParams.get('summary') === '1')
164
+ return json(await getSession(options.store, managed[1]));
165
+ const after = url.searchParams.get('after');
166
+ return json(await exportManagedDelta(options.store, managed[1], { afterHead: after }));
167
+ }
168
+ if (managed && request.method === 'POST') {
169
+ const input = decode(await boundedBody(request, limit));
170
+ if (input?.delta?.sessionId !== managed[1])
171
+ throw new TrajectoriesError('INVALID_INPUT', 'Shared route and managed identity differ.');
172
+ return json(await importManagedDelta(options.store, input.delta, { expectedHead: input.expectedHead }));
173
+ }
174
+ return json({ error: { code: 'NOT_FOUND', message: 'Shared-store route not found.' } }, 404);
175
+ }
176
+ catch (error) {
177
+ const code = error instanceof TrajectoriesError ? error.code : 'INTEGRITY_ERROR';
178
+ const status = code === 'NOT_FOUND' ? 404 : code === 'STALE_HEAD' || code === 'INTENT_CONFLICT' || code === 'OPERATION_PENDING' ? 409
179
+ : code === 'CAPACITY_EXCEEDED' ? 413 : 422;
180
+ return json({ error: { code, message: error instanceof TrajectoriesError ? error.message : 'Shared history could not be validated.' } }, status);
181
+ }
182
+ };
183
+ }
184
+ export function createSharedClient(options) {
185
+ tokenBytes(options.token);
186
+ const base = new URL(options.url);
187
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash)
188
+ throw new TrajectoriesError('INVALID_INPUT', 'Shared endpoint must be an HTTP(S) URL without embedded credentials, query or fragment.');
189
+ if (!base.pathname.endsWith('/'))
190
+ base.pathname += '/';
191
+ async function request(path, init = {}) {
192
+ const response = await fetch(new URL(path, base), { ...init, redirect: 'error', signal: options.signal,
193
+ headers: { ...Object.fromEntries(new Headers(init.headers)), authorization: `Bearer ${options.token}` } });
194
+ if (!response.ok) {
195
+ if (init.method === 'HEAD')
196
+ throw new TrajectoriesError(response.status === 404 ? 'NOT_FOUND' : 'INTEGRITY_ERROR', 'Shared object lookup failed.');
197
+ const data = decode(await boundedBody(response, MAX_BYTES));
198
+ const code = data.error?.code;
199
+ const allowed = ['INVALID_INPUT', 'NOT_FOUND', 'INTEGRITY_ERROR', 'UNSUPPORTED_CONTEXT', 'STALE_HEAD', 'INTENT_CONFLICT', 'OPERATION_PENDING', 'OUTCOME_UNKNOWN', 'CAPACITY_EXCEEDED'];
200
+ throw new TrajectoriesError(allowed.includes(code ?? '') ? code : 'INVALID_INPUT', data.error?.message ?? 'Shared request failed.');
201
+ }
202
+ return response;
203
+ }
204
+ const read = async (path) => decode(await boundedBody(await request(path), MAX_BYTES));
205
+ async function upload(objects) {
206
+ let objectsTransferred = 0, bytesTransferred = 0;
207
+ const entries = [...objects];
208
+ for (let offset = 0; offset < entries.length; offset += 512) {
209
+ const group = entries.slice(offset, offset + 512), keys = group.map(([key]) => key);
210
+ const response = await request('v1/objects/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(keys) });
211
+ const inventory = decode(await boundedBody(response, MAX_BYTES));
212
+ if (!inventory || !Array.isArray(inventory.missing) || new Set(inventory.missing).size !== inventory.missing.length
213
+ || !inventory.missing.every(key => keys.includes(key)))
214
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Invalid shared object inventory response.');
215
+ const missing = new Set(inventory.missing);
216
+ let batch = [], batchBytes = 0;
217
+ const flush = async () => {
218
+ if (!batch.length)
219
+ return;
220
+ const reply = decode(await boundedBody(await request('v1/objects/batch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(batch) }), MAX_BYTES));
221
+ if (!reply || stableJson(reply.objects) !== stableJson(batch.map(item => item.object)))
222
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared batch acknowledgment differs.');
223
+ objectsTransferred += batch.length;
224
+ bytesTransferred += batchBytes;
225
+ batch = [];
226
+ batchBytes = 0;
227
+ };
228
+ for (const [key, bytes] of group) {
229
+ if (!missing.has(key))
230
+ continue;
231
+ if (digest(bytes) !== key)
232
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Local shared object is corrupt.');
233
+ if (bytes.byteLength > 1024 * 1024) {
234
+ await flush();
235
+ await request(`v1/objects/${key}`, { method: 'PUT', body: new Uint8Array(bytes) });
236
+ objectsTransferred++;
237
+ bytesTransferred += bytes.byteLength;
238
+ continue;
239
+ }
240
+ if (batchBytes + bytes.byteLength > 1024 * 1024)
241
+ await flush();
242
+ batch.push({ object: key, base64: Buffer.from(bytes).toString('base64') });
243
+ batchBytes += bytes.byteLength;
244
+ }
245
+ await flush();
246
+ }
247
+ return { objectsTransferred, bytesTransferred };
248
+ }
249
+ return {
250
+ async listObserved() {
251
+ const values = await read('v1/observed');
252
+ if (!Array.isArray(values))
253
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Invalid shared session list.');
254
+ const ids = new Set();
255
+ for (const value of values) {
256
+ assertObservedSummary(value);
257
+ if (ids.has(value.id))
258
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Duplicate shared session.');
259
+ ids.add(value.id);
260
+ }
261
+ return values;
262
+ },
263
+ async getObserved(id) { const value = await read(`v1/observed/${id}`); assertObservedState(value, id); return value; },
264
+ async listManaged() {
265
+ const values = await read('v1/managed');
266
+ if (!Array.isArray(values))
267
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Invalid managed session list.');
268
+ const ids = new Set();
269
+ for (const value of values) {
270
+ assertManagedSummary(value);
271
+ if (ids.has(value.id))
272
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Duplicate managed session.');
273
+ ids.add(value.id);
274
+ }
275
+ return values;
276
+ },
277
+ async pushObserved(input) {
278
+ const id = sharedObservationId(input.machineId, input.session.id);
279
+ let previous;
280
+ try {
281
+ previous = await read(`v1/observed/${id}?manifest=1`);
282
+ }
283
+ catch (error) {
284
+ if (!(error instanceof TrajectoriesError) || error.code !== 'NOT_FOUND')
285
+ throw error;
286
+ }
287
+ const prepared = await prepareObservedHistory(input, previous);
288
+ if (!prepared.delta)
289
+ return { id, head: previous.head, changedRecords: 0, removedRecords: 0, objectsTransferred: 0, bytesTransferred: 0 };
290
+ const transferred = await upload(prepared.objects);
291
+ const response = await request(`v1/observed/${id}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: stableJson(prepared.delta) });
292
+ const committed = decode(await boundedBody(response, MAX_BYTES));
293
+ assertObservedSummary(committed, id);
294
+ if (committed.head !== digest(stableJson(prepared.delta)) || stableJson(committed.session) !== stableJson(input.session))
295
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared observation acknowledgment does not match the published delta.');
296
+ return { id, head: committed.head, ...transferred, changedRecords: prepared.changedRecords, removedRecords: prepared.removedRecords };
297
+ },
298
+ async pushManaged(store, id) {
299
+ let remote;
300
+ try {
301
+ remote = await read(`v1/managed/${id}?summary=1`);
302
+ }
303
+ catch (error) {
304
+ if (!(error instanceof TrajectoriesError) || error.code !== 'NOT_FOUND')
305
+ throw error;
306
+ }
307
+ if (remote)
308
+ assertManagedSummary(remote, id);
309
+ const delta = await exportManagedDelta(store, id, { afterHead: remote?.head ?? null });
310
+ const objects = new Map();
311
+ for (const key of delta.objectIds)
312
+ objects.set(key, await getObject(store, key));
313
+ const transferred = await upload(objects);
314
+ if (delta.frames.length) {
315
+ const committed = decode(await boundedBody(await request(`v1/managed/${id}`, { method: 'POST', headers: { 'content-type': 'application/json' },
316
+ body: stableJson({ expectedHead: remote?.head ?? null, delta }) }), MAX_BYTES));
317
+ assertManagedSummary(committed, id);
318
+ if (committed.head !== delta.head)
319
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared managed acknowledgment differs from the submitted head.');
320
+ }
321
+ return { id, head: delta.head, framesTransferred: delta.frames.length, ...transferred };
322
+ },
323
+ async pullManaged(store, id) {
324
+ let local;
325
+ try {
326
+ local = await getSession(store, id);
327
+ }
328
+ catch (error) {
329
+ if (!(error instanceof TrajectoriesError) || error.code !== 'NOT_FOUND')
330
+ throw error;
331
+ }
332
+ const delta = await read(`v1/managed/${id}${local ? `?after=${encodeURIComponent(local.head)}` : ''}`);
333
+ if (delta?.sessionId !== id)
334
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Shared managed transfer belongs to another session.');
335
+ validateManagedDelta(delta, local?.head ?? null);
336
+ let objectsTransferred = 0, bytesTransferred = 0;
337
+ for (const key of delta.objectIds) {
338
+ try {
339
+ await getObject(store, key);
340
+ continue;
341
+ }
342
+ catch (error) {
343
+ if (!missingObject(error))
344
+ throw error;
345
+ }
346
+ const bytes = await boundedBody(await request(`v1/objects/${key}`), MAX_BYTES);
347
+ if (digest(bytes) !== key)
348
+ throw new TrajectoriesError('INTEGRITY_ERROR', 'Remote shared object is corrupt.');
349
+ await putObject(store, bytes);
350
+ objectsTransferred++;
351
+ bytesTransferred += bytes.byteLength;
352
+ }
353
+ await importManagedDelta(store, delta, { expectedHead: local?.head ?? null });
354
+ return { id, head: delta.head, framesTransferred: delta.frames.length, objectsTransferred, bytesTransferred };
355
+ },
356
+ };
357
+ }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,42 @@
1
- import type { CanonicalResult, NormalizedRecord } from '@letta-ai/trajectory';
2
- export declare const HARNESSES: readonly ["claude-code", "codex", "pi", "omp", "opencode", "grok-build", "cursor", "deepagents", "github-copilot", "cline", "fx"];
1
+ import type { CanonicalRecord, CanonicalResult, NormalizedRecord } from '@letta-ai/trajectory';
2
+ /** Harnesses that own a native store this package can export to. */
3
+ export declare const NATIVE_HARNESSSES: readonly ["claude-code", "codex", "pi", "omp", "opencode", "grok-build", "cursor", "deepagents", "github-copilot", "cline", "fx"];
4
+ export type NativeHarness = typeof NATIVE_HARNESSSES[number];
5
+ /** ATIF is a read-only interchange format: it is captured and normalized, but has no native writer. */
6
+ export declare const HARNESSES: readonly ["claude-code", "codex", "pi", "omp", "opencode", "grok-build", "cursor", "deepagents", "github-copilot", "cline", "fx", "atif"];
3
7
  export type Harness = typeof HARNESSES[number];
8
+ export declare function isNativeHarness(value: Harness): value is NativeHarness;
9
+ /** Opaque per-record enrichment (for example ATIF step `metrics`/`extra`).
10
+ * Carried outside `record_json`, so `record_hash`, `record_id` and `content_hash`
11
+ * never move when enrichment is attached. */
12
+ export type CanonicalRecordMetadata = Record<string, unknown>;
13
+ /** Who authored a message's underlying source turn when the source format can tell.
14
+ * ATIF records harness-injected prompts as `user` steps tagged `extra.role`; those
15
+ * surface as canonical user records with origin `harness`. */
16
+ export type RecordOrigin = 'user' | 'harness';
17
+ /** A canonical record plus optional post-normalization enrichment.
18
+ * The wrapper properties are excluded from every per-record hash and from the
19
+ * lossless `record_json`; the snapshot id deterministically covers them. */
20
+ export interface ExtendedCanonicalRecord extends CanonicalRecord {
21
+ metadata?: CanonicalRecordMetadata;
22
+ origin?: RecordOrigin;
23
+ }
24
+ /** Canonical form as retained by a Snapshot; records may carry enrichment. */
25
+ export interface SnapshotCanonical extends CanonicalResult {
26
+ records: ExtendedCanonicalRecord[];
27
+ }
28
+ /** Post-hoc annotation for a finished trajectory (judge verdict, review error,
29
+ * first-pointed step). The channel is append-only and lives outside `raw` and
30
+ * every content hash: attaching one never re-normalizes, never changes the
31
+ * snapshot id, and never moves a record id. */
32
+ export interface SnapshotAnnotation {
33
+ /** What this annotation is, e.g. `judge-verdict`. */
34
+ kind: string;
35
+ /** Canonical JSON payload; integrity-hashed on its own. */
36
+ content: string;
37
+ sha256: string;
38
+ createdAt: string;
39
+ }
4
40
  /** Immutable history. Text-file inputs retain decoded source text;
5
41
  * Cursor retains SQLite rows/blobs;
6
42
  * Deep Agents retains decoded messages and metadata from one selected checkpoint. */
@@ -22,7 +58,9 @@ export interface Snapshot {
22
58
  content: string;
23
59
  sha256: string;
24
60
  }>;
25
- canonical: CanonicalResult;
61
+ /** Appended after normalization; outside the snapshot id and all content hashes. */
62
+ annotations?: SnapshotAnnotation[];
63
+ canonical: SnapshotCanonical;
26
64
  /** Archived histories can be inspected even when active-context conversion is unsupported. */
27
65
  portabilityIssues?: ConversionDiagnostic[];
28
66
  }
package/dist/types.js CHANGED
@@ -1 +1,7 @@
1
- export const HARNESSES = ['claude-code', 'codex', 'pi', 'omp', 'opencode', 'grok-build', 'cursor', 'deepagents', 'github-copilot', 'cline', 'fx'];
1
+ /** Harnesses that own a native store this package can export to. */
2
+ export const NATIVE_HARNESSSES = ['claude-code', 'codex', 'pi', 'omp', 'opencode', 'grok-build', 'cursor', 'deepagents', 'github-copilot', 'cline', 'fx'];
3
+ /** ATIF is a read-only interchange format: it is captured and normalized, but has no native writer. */
4
+ export const HARNESSES = [...NATIVE_HARNESSSES, 'atif'];
5
+ export function isNativeHarness(value) {
6
+ return NATIVE_HARNESSSES.includes(value);
7
+ }
@@ -0,0 +1,133 @@
1
+ # Shared history across machines
2
+
3
+ Trajectories keeps SQLite for local ingestion and search. An authenticated shared
4
+ store receives canonical records and immutable managed-session evidence, so another
5
+ machine can read or index that history without reaching the originating machine.
6
+ Each session retains its own history and identity. Synchronization uploads missing
7
+ objects and changed records or managed log suffixes; it does not copy SQLite files
8
+ or create a full snapshot on every sync.
9
+
10
+ Confirm your installed CLI supports this feature with `trajectories remote --help`.
11
+ Upgrade to the latest published package, or build from this checkout if unavailable.
12
+
13
+ ## CLI
14
+
15
+ Provision the same secret on the server and each client in an owner-owned regular
16
+ file with mode `0600`. Pass its **path**, never its value, through `--token-file`
17
+ or `TRAJECTORIES_REMOTE_TOKEN_FILE`.
18
+
19
+ ```sh
20
+ # Shared host: private durable directory; listens on loopback by default.
21
+ trajectories remote serve --store /srv/private/trajectories \
22
+ --token-file /private/trajectories-token --port 8789
23
+
24
+ # Client: reach that listener through a private SSH tunnel.
25
+ ssh -N -L 8789:127.0.0.1:8789 user@shared-host
26
+
27
+ # Publish this machine's native histories already indexed in SQLite.
28
+ trajectories remote push --url http://127.0.0.1:8789 \
29
+ --token-file /private/trajectories-token --machine laptop \
30
+ --db /local/ledger.sqlite
31
+
32
+ # Publish managed sessions instead; --session selects one when needed.
33
+ trajectories remote push --url http://127.0.0.1:8789 \
34
+ --token-file /private/trajectories-token --machine laptop \
35
+ --store /local/session-store --session conversation
36
+
37
+ # Another machine: pull observed histories into its existing lexical index.
38
+ # --store also mirrors managed logs and indexes their accepted history.
39
+ trajectories remote pull --url http://127.0.0.1:8789 \
40
+ --token-file /private/trajectories-token --machine server --db /local/ledger.sqlite \
41
+ --store /local/managed-mirror
42
+ trajectories remote list --url http://127.0.0.1:8789 \
43
+ --token-file /private/trajectories-token
44
+ trajectories search 'deployment decision' --db /local/ledger.sqlite --robot
45
+ ```
46
+
47
+ Keep `--machine` stable for that originating machine. With neither `--store` nor
48
+ `--db`, push uses the existing canonical SQLite path. An explicit `--store`
49
+ selects managed sessions; add `--db` to publish both lanes. Push and pull print
50
+ JSON summaries, not record bodies or credentials. Remote `list` also prints only
51
+ summary identities, heads, sources and counts. Use TLS termination or a private
52
+ tunnel for transport; the built-in HTTP listener does not provide TLS.
53
+
54
+ ## What is shared
55
+
56
+ - **Observed native history:** histories ingested by any supported native reader
57
+ contribute canonical session records, complete tool-output fields and normalization
58
+ diagnostics. A SQLite read transaction pins one source generation before upload.
59
+ Machine identity namespaces colliding native session IDs. Pulled rows retain
60
+ per-record lineage and parent links; their local origin is
61
+ `shared://MACHINE/ENCODED_INDEXED_ID`. The original source path remains available
62
+ through the remote SDK. These records are history, not a raw native transcript
63
+ archive or proof of native execution recovery.
64
+ - **Managed sessions:** transfer the exact canonical log chain, accepted ordering,
65
+ bindings, receipts and required immutable backing evidence. Hash verification
66
+ and expected-head checks reject corruption and conflicting continuations.
67
+ Repeating an unchanged push sends no additional objects. Local indexing remains
68
+ a derived lexical projection of accepted history.
69
+
70
+ An unchanged observed mirror skips downloading its history body when its stored
71
+ head matches the remote summary. Managed pulls still validate their transfer,
72
+ including an empty delta. If a managed mirror has advanced locally, pull verifies
73
+ that a refreshed remote head is an ancestor of its accepted local chain and reports
74
+ `ahead`, retaining that branch. A diverged session reports `conflict`; pull continues
75
+ with other sessions, indexes available accepted histories and prints its summary
76
+ before exiting nonzero. It does not merge conflicting branches automatically.
77
+
78
+ When pulling into a machine's existing native index, pass the same stable
79
+ `--machine` ID used by its pushes. Its own observed sessions are reported as
80
+ `skipped-origin`, so native rows remain the only local copy while other machines'
81
+ histories are imported. Omit this option when building a complete separate mirror.
82
+
83
+ Pull never launches a harness, takes native execution ownership or migrates a
84
+ workspace. A restored managed session retains its workspace reference; preparing
85
+ and executing a continuation still requires the caller's workspace, native tools
86
+ and credentials. Pulled observed rows are excluded from later native-history pushes.
87
+
88
+ Existing CLI deletions suppress the selected local mirror through its tombstone;
89
+ another pull cannot revive it. They do not delete the shared session or propagate
90
+ a global deletion. Diagnostic first-seen times are preserved; last-seen times are
91
+ derived locally when indexing.
92
+
93
+ ## SDK
94
+
95
+ The shared handler and client work in Node 22.13+ without Bun SQLite. The host owns
96
+ HTTP adaptation, TLS, credential loading and process lifetime.
97
+
98
+ ```ts
99
+ import { createSharedHandler, createSharedClient } from 'trajectories';
100
+
101
+ const token = await loadSecret(); // Your host's private credential mechanism.
102
+ const handler = createSharedHandler({ store: '/srv/private/trajectories', token });
103
+ // Mount handler as (request: Request) => Promise<Response> in your HTTP host.
104
+
105
+ const client = createSharedClient({ url: 'https://history.example.com/', token });
106
+ const observed = await client.listObserved();
107
+ const history = await client.getObserved(observed[0]!.id);
108
+ await client.pushManaged('/local/session-store', 'conversation');
109
+ await client.pullManaged('/local/managed-mirror', 'conversation');
110
+ ```
111
+
112
+ `pushObserved({ machineId, session, records })` publishes the canonical SQLite-shaped
113
+ metadata and record structures exposed by the SDK types. SDK clients can also pass
114
+ an `AbortSignal`. Tokens in endpoint URLs and redirects are rejected.
115
+
116
+ The default request/response body limit is **64 MiB**; the handler can configure
117
+ `maxBytes`, while the client retains its 64 MiB admission limit. Complete observed
118
+ history responses, managed deltas and individual objects must fit those limits;
119
+ an oversized transfer fails explicitly. Object uploads batch at most 512 objects
120
+ and check the remote inventory first. `bytesTransferred` counts missing immutable
121
+ object bytes only, excluding manifests, JSON/base64 encoding and HTTP/TLS overhead.
122
+
123
+ ## DuckDB's role
124
+
125
+ DuckDB remains an optional, rebuildable analytical projection, with no production
126
+ dependency or local-storage migration. On the measured metadata corpus, four warm
127
+ cross-session aggregate queries ran **2.9–33.5× faster** than indexed SQLite.
128
+ DuckDB's [SQLite extension](https://duckdb.org/docs/current/core_extensions/sqlite)
129
+ can read existing SQLite tables directly. That approach had mixed results in this
130
+ trial, including a slower large-session
131
+ read. This evidence supports repeated aggregate analysis, not replacing SQLite
132
+ ingestion, FTS5 search or managed commit authority. See the
133
+ [benchmark, costs and limits](../evidence/duckdb-local-2026-10-13/README.md).
package/legacy/src/db.ts CHANGED
@@ -1289,7 +1289,7 @@ export class TrajectoryDb {
1289
1289
  session.id,
1290
1290
  ],
1291
1291
  );
1292
- this.insertRecordsInternal(session.id, session.source, records);
1292
+ this.insertRecordsInternal(session.id, session.source, records, session.source_path);
1293
1293
  if (diagnostics) this.writeIngestDiagnostics(session.id, session.source, diagnostics);
1294
1294
  return { inserted: false, updated: true, deduped: false };
1295
1295
  }
@@ -1321,7 +1321,7 @@ export class TrajectoryDb {
1321
1321
  session.thread_role ?? null,
1322
1322
  ],
1323
1323
  );
1324
- this.insertRecordsInternal(session.id, session.source, records);
1324
+ this.insertRecordsInternal(session.id, session.source, records, session.source_path);
1325
1325
  if (diagnostics) this.writeIngestDiagnostics(session.id, session.source, diagnostics);
1326
1326
  return { inserted: true, updated: false, deduped: false };
1327
1327
  }).immediate();
@@ -1368,7 +1368,7 @@ export class TrajectoryDb {
1368
1368
  }
1369
1369
  const contentHash = extendSessionHash(session.content_hash, fresh);
1370
1370
 
1371
- this.insertRecordsInternal(sessionId, session.source, fresh);
1371
+ this.insertRecordsInternal(sessionId, session.source, fresh, session.source_path);
1372
1372
  this.db.run(
1373
1373
  `UPDATE sessions SET
1374
1374
  content_hash = ?, content_hash_version = ?, ended_at = ?, record_count = ?, updated_at = ?
@@ -1702,18 +1702,22 @@ export class TrajectoryDb {
1702
1702
  for (const row of rows.iterate(...key)) {
1703
1703
  batch.push(JSON.parse(row.canonical_json) as CanonicalDbRecord);
1704
1704
  if (batch.length === 256) {
1705
- this.insertRecordsInternal(session.id, session.source, batch);
1705
+ this.insertRecordsInternal(session.id, session.source, batch, session.source_path);
1706
1706
  batch = [];
1707
1707
  }
1708
1708
  }
1709
- if (batch.length) this.insertRecordsInternal(session.id, session.source, batch);
1709
+ if (batch.length) this.insertRecordsInternal(session.id, session.source, batch, session.source_path);
1710
1710
  }
1711
1711
 
1712
1712
  private insertRecordsInternal(
1713
1713
  sessionId: string,
1714
1714
  sourceType: string,
1715
1715
  records: CanonicalDbRecord[],
1716
+ sourcePath?: string,
1716
1717
  ): void {
1718
+ // Shared mirrors carry original per-record lineage, as managed projections do.
1719
+ // Native ingestion still attributes records to the admitted source adapter.
1720
+ const preserveRecordSource = sourceType === "managed" || sourcePath?.startsWith("shared://") === true;
1717
1721
  const insertRecordStmt = this.db.prepare(
1718
1722
  `INSERT OR REPLACE INTO records (
1719
1723
  id, session_id, source_type, native_session_id, binding_id, record_type, stable_source_record_id,
@@ -1741,7 +1745,7 @@ export class TrajectoryDb {
1741
1745
  insertRecordStmt.run(
1742
1746
  r.id,
1743
1747
  sessionId,
1744
- sourceType === "managed" ? r.source_type : sourceType,
1748
+ preserveRecordSource ? r.source_type : sourceType,
1745
1749
  r.native_session_id ?? null,
1746
1750
  r.binding_id ?? null,
1747
1751
  r.record_type,
@@ -1808,7 +1812,7 @@ export class TrajectoryDb {
1808
1812
  insertFtsStmt.run(
1809
1813
  r.id,
1810
1814
  sessionId,
1811
- sourceType === "managed" ? r.source_type : sourceType,
1815
+ preserveRecordSource ? r.source_type : sourceType,
1812
1816
  r.record_type,
1813
1817
  r.content ?? "",
1814
1818
  r.tool_name ?? "",
@@ -15,6 +15,7 @@ import { SCHEMA_VERSION } from "./schema.js";
15
15
  import { classifyReadStore, formatMismatchMessage, incompleteStoreMessage } from "./schema-gate.js";
16
16
  import type {
17
17
  CanonicalDbRecord,
18
+ IngestDiagnosticRecord,
18
19
  QueryResult,
19
20
  RecordMatchType,
20
21
  RecordSearchHit,
@@ -1220,6 +1221,26 @@ export class TrajectoryReader {
1220
1221
  return sessionRecords(this.db, sessionId);
1221
1222
  }
1222
1223
 
1224
+ /** Read normalization uncertainty without opening a writer or reviving a tombstone. */
1225
+ getSessionDiagnostics(sessionId: string): IngestDiagnosticRecord[] {
1226
+ if (!this.getSession(sessionId)) return [];
1227
+ return this.db.query<IngestDiagnosticRecord, [string]>(
1228
+ `SELECT session_id, source, code, occurrence_count, sample, first_seen_at, last_seen_at
1229
+ FROM ingest_diagnostics WHERE session_id = ? ORDER BY code ASC`,
1230
+ ).all(sessionId);
1231
+ }
1232
+
1233
+ /** Pin one source generation before sending its complete history to a remote. */
1234
+ readSessionForSync(sessionId: string): {
1235
+ session: SessionRecord; records: CanonicalDbRecord[]; diagnostics: IngestDiagnosticRecord[];
1236
+ } | null {
1237
+ return this.db.transaction(() => {
1238
+ const session = this.getSession(sessionId);
1239
+ if (!session) return null;
1240
+ return { session, records: this.getSessionRecords(sessionId), diagnostics: this.getSessionDiagnostics(sessionId) };
1241
+ }).deferred();
1242
+ }
1243
+
1223
1244
  getSessionCount(): number {
1224
1245
  return countSessions(this.db);
1225
1246
  }