trajectories 0.1.1 → 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/convert.d.ts CHANGED
@@ -21,6 +21,14 @@ export declare function readSnapshot(input: {
21
21
  pythonExecutable?: string;
22
22
  }): Promise<Snapshot>;
23
23
  export declare function assertSnapshot(snapshot: Snapshot): void;
24
+ /** Append a post-hoc annotation (judge verdict, review error, first-pointed step)
25
+ * to a finished snapshot. The channel is outside `raw` and every content hash:
26
+ * the snapshot id, source sha256 and record ids are unchanged, so annotations
27
+ * attach without re-normalizing. The result stays valid for assertSnapshot. */
28
+ export declare function annotateSnapshot(snapshot: Snapshot, annotation: {
29
+ kind: string;
30
+ content: string;
31
+ }): Snapshot;
24
32
  /** Complete portable context, stricter than an archive's integrity-only validation. */
25
33
  export declare function assertPortableSnapshot(snapshot: Snapshot): void;
26
34
  export declare function convert(snapshot: Snapshot, target: Harness, options: WriteOptions): NativeExport;
package/dist/convert.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { readFile, mkdir, writeFile, realpath } from 'node:fs/promises';
3
3
  import { resolve, dirname, relative, isAbsolute, sep, join, basename } from 'node:path';
4
4
  import { normalizeToCanonical, validateTranscript } from '@letta-ai/trajectory';
5
- import { HARNESSES } from './types.js';
5
+ import { HARNESSES, isNativeHarness } from './types.js';
6
6
  import { createSeededNativeEntropy } from './writers/entropy.js';
7
7
  import { writePi } from './writers/pi.js';
8
8
  import { writeOmp } from './writers/omp.js';
@@ -16,6 +16,7 @@ import { writeGitHubCopilot } from './writers/github-copilot.js';
16
16
  import { writeCline } from './writers/cline.js';
17
17
  import { writeFx } from './writers/fx.js';
18
18
  import { normalizeGrokBuild } from './readers/grok-build.js';
19
+ import { enrichAtifCanonical } from './readers/atif.js';
19
20
  import { readCursor, readCursorNative } from './readers/cursor.js';
20
21
  import { readDeepAgents, readDeepAgentsSeed } from './readers/deepagents.js';
21
22
  import { readCline } from './readers/cline.js';
@@ -212,7 +213,8 @@ function finalizeSnapshot(input, archived, clineCompaction = false) {
212
213
  const invalid = archived.diagnostics.filter(d => d.code === 'invalid_json_line' || d.code === 'non_object_json_line');
213
214
  if (invalid.length)
214
215
  throw new Error(`Invalid native transcript: ${invalid.map(d => d.message).join('; ')}`);
215
- const canonical = input.harness === 'pi' ? projectPiProviderContext(raw, archived) : archived;
216
+ const canonical = input.harness === 'pi' ? projectPiProviderContext(raw, archived)
217
+ : input.harness === 'atif' ? enrichAtifCanonical(raw, archived) : archived;
216
218
  const metadata = provenance(input.harness, canonical);
217
219
  if (input.nativeSessionId && metadata.nativeSessionId !== input.nativeSessionId)
218
220
  throw new Error('Selected native session does not match source provenance.');
@@ -352,12 +354,31 @@ export function assertSnapshot(snapshot) {
352
354
  if (sha256(attachment.content) !== attachment.sha256)
353
355
  throw new Error('Snapshot attachment integrity check failed.');
354
356
  }
357
+ for (const annotation of snapshot.annotations ?? []) {
358
+ if (typeof annotation.kind !== 'string' || !annotation.kind || typeof annotation.content !== 'string'
359
+ || sha256(annotation.content) !== annotation.sha256)
360
+ throw new Error('Snapshot annotation integrity check failed.');
361
+ }
355
362
  for (const record of canonical.records) {
356
363
  if (sha256(record.record_json) !== record.record_hash)
357
364
  throw new Error('Snapshot canonical record integrity check failed.');
358
365
  }
359
366
  validateTranscript(canonical.records.map(r => JSON.parse(r.record_json)));
360
367
  }
368
+ /** Append a post-hoc annotation (judge verdict, review error, first-pointed step)
369
+ * to a finished snapshot. The channel is outside `raw` and every content hash:
370
+ * the snapshot id, source sha256 and record ids are unchanged, so annotations
371
+ * attach without re-normalizing. The result stays valid for assertSnapshot. */
372
+ export function annotateSnapshot(snapshot, annotation) {
373
+ assertSnapshot(snapshot);
374
+ if (typeof annotation?.kind !== 'string' || !annotation.kind.trim())
375
+ throw new Error('Snapshot annotation requires a kind.');
376
+ if (typeof annotation.content !== 'string')
377
+ throw new Error('Snapshot annotation content must be a string.');
378
+ const entry = { kind: annotation.kind, content: annotation.content,
379
+ sha256: sha256(annotation.content), createdAt: new Date().toISOString() };
380
+ return { ...snapshot, annotations: [...(snapshot.annotations ?? []), entry] };
381
+ }
361
382
  /** Complete portable context, stricter than an archive's integrity-only validation. */
362
383
  export function assertPortableSnapshot(snapshot) {
363
384
  assertSnapshot(snapshot);
@@ -385,8 +406,9 @@ export function convertManaged(snapshot, target, options, entropySeed, records)
385
406
  }
386
407
  function convertWithEntropy(snapshot, target, options, entropy, retainedRecords) {
387
408
  assertPortableSnapshot(snapshot);
388
- if (!HARNESSES.includes(target))
389
- throw new Error(`Unsupported target: ${target}`);
409
+ if (!isNativeHarness(target)) {
410
+ throw new Error(HARNESSES.includes(target) ? `Unsupported target: ${target} is a read-only interchange format.` : `Unsupported target: ${target}`);
411
+ }
390
412
  if (!isAbsolute(options.cwd) || !isAbsolute(options.directory))
391
413
  throw new Error('cwd and directory must be absolute paths.');
392
414
  const records = retainedRecords ?? snapshot.canonical.records.map(r => JSON.parse(r.record_json));
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env python3
2
+ """Read a Python LangGraph SQLite checkpoint through LangGraph's public APIs."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import sys
10
+ from collections.abc import Iterable, Mapping
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ def _emit(payload: Mapping[str, Any]) -> None:
16
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
17
+
18
+
19
+ def _fail(code: str, message: str) -> None:
20
+ _emit({"ok": False, "code": code, "message": message})
21
+ raise SystemExit(0)
22
+
23
+
24
+ try:
25
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
26
+ from langgraph.checkpoint.sqlite import SqliteSaver
27
+ from langgraph.graph.message import add_messages
28
+ from langgraph.types import Overwrite
29
+ except (ImportError, ModuleNotFoundError) as error:
30
+ _fail(
31
+ "python_dependency_missing",
32
+ "Reading Deep Agents checkpoints requires Python packages "
33
+ "langgraph and langgraph-checkpoint-sqlite; install the "
34
+ "agent-trajectory[deepagents] extra in this Python environment "
35
+ f"(import error: {error}).",
36
+ )
37
+
38
+
39
+ def _request() -> dict[str, Any]:
40
+ try:
41
+ value = json.load(sys.stdin)
42
+ except (OSError, json.JSONDecodeError) as error:
43
+ _fail("invalid_input", f"Deep Agents checkpoint request is invalid: {error}")
44
+ if not isinstance(value, dict):
45
+ _fail("invalid_input", "Deep Agents checkpoint request must be an object.")
46
+ return value
47
+
48
+
49
+ def _unwrap_snapshot(value: Any) -> Any:
50
+ # DeltaChannel history exposes its decoded snapshot through the public
51
+ # `seed` contract. In current LangGraph releases that value is a namedtuple
52
+ # with one `value` field; avoid importing its private concrete class.
53
+ if (
54
+ isinstance(value, tuple)
55
+ and getattr(value, "_fields", None) == ("value",)
56
+ and value.__class__.__name__.endswith("DeltaSnapshot")
57
+ ):
58
+ return value.value
59
+ return value
60
+
61
+
62
+ def _as_messages(value: Any) -> list[Any]:
63
+ value = _unwrap_snapshot(value)
64
+ if value is None:
65
+ return []
66
+ if isinstance(value, (list, tuple)):
67
+ return list(value)
68
+ return [value]
69
+
70
+
71
+ def _apply_message_writes(messages: list[Any], writes: Iterable[tuple[Any, str, Any]]) -> list[Any]:
72
+ current = messages
73
+ for _task_id, channel, value in writes:
74
+ if channel != "messages":
75
+ continue
76
+ if isinstance(value, Overwrite):
77
+ current = _as_messages(value.value)
78
+ else:
79
+ current = list(add_messages(current, value))
80
+ return current
81
+
82
+
83
+ def _reconstruct_messages(saver: Any, checkpoint_tuple: Any) -> list[Any]:
84
+ channel_values = checkpoint_tuple.checkpoint.get("channel_values") or {}
85
+ if not isinstance(channel_values, Mapping):
86
+ _fail("invalid_checkpoint_state", "Checkpoint channel_values must be a mapping.")
87
+
88
+ if "messages" in channel_values:
89
+ messages = _as_messages(channel_values["messages"])
90
+ else:
91
+ # This is the public LangGraph API for reconstructing a DeltaChannel:
92
+ # it follows only the selected checkpoint's parent chain and returns
93
+ # already-deserialized seed/writes in oldest-to-newest order.
94
+ if not hasattr(saver, "get_delta_channel_history"):
95
+ _fail(
96
+ "python_dependency_missing",
97
+ "This checkpoint requires a LangGraph release with "
98
+ "get_delta_channel_history support; upgrade the deepagents extra.",
99
+ )
100
+ histories = saver.get_delta_channel_history(
101
+ config=checkpoint_tuple.config,
102
+ channels=["messages"],
103
+ )
104
+ history = histories.get("messages", {})
105
+ messages = _as_messages(history.get("seed"))
106
+ messages = _apply_message_writes(messages, history.get("writes", ()))
107
+
108
+ return _apply_message_writes(messages, checkpoint_tuple.pending_writes or ())
109
+
110
+
111
+ def _content_parts(content: Any) -> tuple[str, list[str]]:
112
+ if isinstance(content, str):
113
+ return content, []
114
+ if not isinstance(content, (list, tuple)):
115
+ return _stringify(content), []
116
+
117
+ text: list[str] = []
118
+ reasoning: list[str] = []
119
+ for block in content:
120
+ if isinstance(block, str):
121
+ text.append(block)
122
+ continue
123
+ if not isinstance(block, Mapping):
124
+ continue
125
+ block_type = block.get("type")
126
+ if block_type == "reasoning":
127
+ value = block.get("reasoning", block.get("text"))
128
+ if isinstance(value, str) and value:
129
+ reasoning.append(value)
130
+ elif block_type in {"text", "input_text", "output_text"} or (
131
+ block_type is None and "text" in block
132
+ ):
133
+ value = block.get("text")
134
+ if isinstance(value, str) and value:
135
+ text.append(value)
136
+ return "\n".join(text), reasoning
137
+
138
+
139
+ def _stringify(value: Any) -> str:
140
+ if isinstance(value, str):
141
+ return value
142
+ try:
143
+ return json.dumps(value, ensure_ascii=False, default=str, separators=(",", ":"))
144
+ except (TypeError, ValueError):
145
+ return str(value)
146
+
147
+
148
+ def _json_safe(value: Any) -> Any:
149
+ if value is None or isinstance(value, (str, int, float, bool)):
150
+ return value
151
+ if isinstance(value, Mapping):
152
+ return {str(key): _json_safe(item) for key, item in value.items()}
153
+ if isinstance(value, (list, tuple)):
154
+ return [_json_safe(item) for item in value]
155
+ return str(value)
156
+
157
+
158
+ def _timestamp(message: Any) -> str | None:
159
+ for container in (
160
+ getattr(message, "additional_kwargs", None),
161
+ getattr(message, "response_metadata", None),
162
+ ):
163
+ if not isinstance(container, Mapping):
164
+ continue
165
+ for key in ("timestamp", "created_at", "createdAt"):
166
+ value = container.get(key)
167
+ if isinstance(value, str) and value:
168
+ return value
169
+ return None
170
+
171
+
172
+ def _model(message: Any) -> str | None:
173
+ for container in (
174
+ getattr(message, "response_metadata", None),
175
+ getattr(message, "additional_kwargs", None),
176
+ ):
177
+ if not isinstance(container, Mapping):
178
+ continue
179
+ for key in ("model_name", "model", "model_id"):
180
+ value = container.get(key)
181
+ if isinstance(value, str) and value:
182
+ return value
183
+ return None
184
+
185
+
186
+ def _reasoning_from_metadata(message: Any) -> list[str]:
187
+ output: list[str] = []
188
+ for container in (
189
+ getattr(message, "additional_kwargs", None),
190
+ getattr(message, "response_metadata", None),
191
+ ):
192
+ if not isinstance(container, Mapping):
193
+ continue
194
+ for key in ("reasoning", "reasoning_content"):
195
+ value = container.get(key)
196
+ if isinstance(value, str) and value and value not in output:
197
+ output.append(value)
198
+ return output
199
+
200
+
201
+ def _message_data(message: Any) -> dict[str, Any] | None:
202
+ timestamp = _timestamp(message)
203
+ if isinstance(message, HumanMessage) or getattr(message, "type", None) == "human":
204
+ content, _ = _content_parts(message.content)
205
+ return {
206
+ "role": "human",
207
+ "content": content,
208
+ **({"timestamp": timestamp} if timestamp else {}),
209
+ }
210
+
211
+ if isinstance(message, AIMessage) or getattr(message, "type", None) == "ai":
212
+ content, reasoning = _content_parts(message.content)
213
+ reasoning.extend(
214
+ value for value in _reasoning_from_metadata(message) if value not in reasoning
215
+ )
216
+ calls: list[dict[str, Any]] = []
217
+ for call in getattr(message, "tool_calls", None) or ():
218
+ if not isinstance(call, Mapping):
219
+ continue
220
+ calls.append(
221
+ {
222
+ "args": _json_safe(call.get("args", {})),
223
+ **({"id": call["id"]} if isinstance(call.get("id"), str) else {}),
224
+ **(
225
+ {"name": call["name"]}
226
+ if isinstance(call.get("name"), str)
227
+ else {}
228
+ ),
229
+ }
230
+ )
231
+ model = _model(message)
232
+ return {
233
+ "role": "ai",
234
+ "content": content,
235
+ "reasoning": reasoning,
236
+ "toolCalls": calls,
237
+ **({"model": model} if model else {}),
238
+ **({"timestamp": timestamp} if timestamp else {}),
239
+ }
240
+
241
+ if isinstance(message, ToolMessage) or getattr(message, "type", None) == "tool":
242
+ content, _ = _content_parts(message.content)
243
+ call_id = getattr(message, "tool_call_id", None)
244
+ if not isinstance(call_id, str):
245
+ call_id = ""
246
+ return {
247
+ "role": "tool",
248
+ "content": content,
249
+ "toolCallId": call_id,
250
+ **({"timestamp": timestamp} if timestamp else {}),
251
+ }
252
+ return None
253
+
254
+
255
+ def _context_string(
256
+ channel_values: Mapping[str, Any], metadata: Mapping[str, Any], keys: tuple[str, ...]
257
+ ) -> str | None:
258
+ for container in (channel_values, metadata):
259
+ for key in keys:
260
+ value = container.get(key)
261
+ if isinstance(value, str) and value:
262
+ return value
263
+ return None
264
+
265
+
266
+ def main() -> None:
267
+ request = _request()
268
+ path_value = request.get("path")
269
+ thread_id = request.get("threadId")
270
+ namespace = request.get("checkpointNamespace", "")
271
+ checkpoint_id = request.get("checkpointId")
272
+ if not isinstance(path_value, str) or not path_value:
273
+ _fail("invalid_input", "Deep Agents checkpoint path is required.")
274
+ if not isinstance(thread_id, str) or not thread_id:
275
+ _fail("invalid_input", "Deep Agents threadId is required.")
276
+ if not isinstance(namespace, str):
277
+ _fail("invalid_input", "Deep Agents checkpointNamespace must be a string.")
278
+ if checkpoint_id is not None and (
279
+ not isinstance(checkpoint_id, str) or not checkpoint_id
280
+ ):
281
+ _fail("invalid_input", "Deep Agents checkpointId must be a non-empty string.")
282
+
283
+ path = Path(path_value).expanduser()
284
+ if not path.is_file():
285
+ _fail(
286
+ "checkpoint_database_not_found",
287
+ f"Deep Agents checkpoint database does not exist: {path}",
288
+ )
289
+ if not os.access(path, os.R_OK):
290
+ _fail(
291
+ "checkpoint_database_unreadable",
292
+ f"Deep Agents checkpoint database is not readable: {path}",
293
+ )
294
+
295
+ config: dict[str, Any] = {
296
+ "configurable": {
297
+ "thread_id": thread_id,
298
+ "checkpoint_ns": namespace,
299
+ **({"checkpoint_id": checkpoint_id} if checkpoint_id else {}),
300
+ }
301
+ }
302
+ try:
303
+ with SqliteSaver.from_conn_string(str(path)) as saver:
304
+ checkpoint_tuple = saver.get_tuple(config)
305
+ if checkpoint_tuple is None:
306
+ label = f" checkpoint {checkpoint_id!r}" if checkpoint_id else ""
307
+ _fail(
308
+ "checkpoint_not_found",
309
+ f"No Deep Agents{label} checkpoint was found for thread "
310
+ f"{thread_id!r} in namespace {namespace!r}.",
311
+ )
312
+ messages = _reconstruct_messages(saver, checkpoint_tuple)
313
+ except sqlite3.DatabaseError as error:
314
+ _fail("checkpoint_database_unreadable", f"Could not read checkpoint database: {error}")
315
+ except (OSError, ValueError, TypeError, KeyError) as error:
316
+ _fail("checkpoint_read_failed", f"Could not reconstruct Deep Agents checkpoint: {error}")
317
+
318
+ serialized_messages = [
319
+ data for message in messages if (data := _message_data(message)) is not None
320
+ ]
321
+ if not serialized_messages:
322
+ _fail(
323
+ "checkpoint_messages_missing",
324
+ "Deep Agents checkpoint did not contain canonical LangChain messages.",
325
+ )
326
+
327
+ channel_values = checkpoint_tuple.checkpoint.get("channel_values") or {}
328
+ metadata = checkpoint_tuple.metadata or {}
329
+ selected = checkpoint_tuple.config.get("configurable", {})
330
+ cwd = _context_string(
331
+ channel_values,
332
+ metadata,
333
+ ("cwd", "working_directory", "current_working_directory"),
334
+ )
335
+ model = _context_string(channel_values, metadata, ("model", "model_name", "model_id"))
336
+ if model is None:
337
+ model = next(
338
+ (
339
+ item["model"]
340
+ for item in serialized_messages
341
+ if item.get("role") == "ai" and isinstance(item.get("model"), str)
342
+ ),
343
+ None,
344
+ )
345
+
346
+ _emit(
347
+ {
348
+ "ok": True,
349
+ "data": {
350
+ "checkpointId": str(selected.get("checkpoint_id", checkpoint_tuple.checkpoint["id"])),
351
+ "checkpointNamespace": str(selected.get("checkpoint_ns", namespace)),
352
+ "checkpointTimestamp": str(checkpoint_tuple.checkpoint["ts"]),
353
+ "messages": serialized_messages,
354
+ **({"cwd": cwd} if cwd else {}),
355
+ **({"model": model} if model else {}),
356
+ },
357
+ }
358
+ )
359
+
360
+
361
+ if __name__ == "__main__":
362
+ main()
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- export { HARNESSES } from './types.js';
2
- export type { Harness, Snapshot, ConversionDiagnostic, WriteOptions, NativeExport, NativeWriter, NativeEntropy } from './types.js';
1
+ export { HARNESSES, NATIVE_HARNESSSES } from './types.js';
2
+ export type { Harness, NativeHarness, Snapshot, SnapshotAnnotation, SnapshotCanonical, ExtendedCanonicalRecord, CanonicalRecordMetadata, ConversionDiagnostic, WriteOptions, NativeExport, NativeWriter, NativeEntropy } from './types.js';
3
3
  export * from './log-types.js';
4
- export { readSnapshot, assertSnapshot, assertPortableSnapshot, convert, writeNativeExport, writeSnapshot } from './convert.js';
4
+ export { readSnapshot, assertSnapshot, assertPortableSnapshot, annotateSnapshot, convert, writeNativeExport, writeSnapshot } from './convert.js';
5
5
  export { createSession, getSession, listSessions, stageCapture, appendCapture, switchSession, readSession, getNativeBinding, getHeadMetadata } from './log.js';
6
6
  export { assertSnapshotContinuation } from './store.js';
7
7
  export { migrateLegacyStore, type MigrationManifest, type MigratedSession } from './migrate.js';
8
+ export { createSharedClient, createSharedHandler, sharedObservationId } from './shared.js';
9
+ export type { ObservedRecord, ObservedSession, ObservedState, ObservedSummary, SharedTransfer, ObservedSyncReceipt, ManagedSyncReceipt } from './shared.js';