pi-durable-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
pi_durable/delta.py ADDED
@@ -0,0 +1,269 @@
1
+ """Chord-compatible JSON operation batches.
2
+
3
+ Durable documents are materialized by replaying ordered operation batches. The tuple vocabulary
4
+ is the one Chord Delta publishes:
5
+
6
+ ``["r", value]``
7
+ Replace the complete value.
8
+ ``["s", path, value]``
9
+ Set an object property or array element.
10
+ ``["d", path]``
11
+ Delete an object property or remove an array element.
12
+ ``["a", path, text]``
13
+ Append to a string.
14
+ ``["t", path, count]``
15
+ Remove ``count`` UTF-16 code units from a string's front.
16
+ ``["p", path, index, remove, items]``
17
+ Splice an array.
18
+ ``["m", path, permutation]``
19
+ Reorder an array in place: ``new[i] = old[permutation[i]]``.
20
+
21
+ The applier is intentionally independent of the document tracker. Memory storage uses it to
22
+ materialize persisted deltas; Session drafts emit the same tuples from their own overlay.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Iterable, Sequence
28
+ from typing import Any, TypeAlias, cast
29
+
30
+ from .json import JsonValue, copy_json
31
+
32
+ Op: TypeAlias = list[Any]
33
+ """One decoded operation tuple, represented as its JSON array."""
34
+
35
+ RESERVED_SEGMENTS = frozenset({"__proto__", "constructor", "prototype"})
36
+
37
+ MAX_DELTA_OPERATIONS = 4_096
38
+ """Beyond this many operations a batch folds into one root replacement, as Chord does."""
39
+
40
+ OVERLAP_SCAN_LIMIT = 65_536
41
+ """Characters Chord's bounded overlap search examines; beyond it a string is written whole."""
42
+
43
+
44
+ class UnsafePathError(Exception):
45
+ """An operation addresses an unsafe or malformed path segment."""
46
+
47
+ def __init__(self, segment: object) -> None:
48
+ super().__init__(f"unsafe path segment: {segment}")
49
+ self.segment = segment
50
+ self.name = "UnsafePathError"
51
+
52
+
53
+ class PathError(Exception):
54
+ """An operation cannot resolve to a value of the shape it expects."""
55
+
56
+ def __init__(self, path: object) -> None:
57
+ super().__init__(f"unresolvable path: {path}")
58
+ self.path = path
59
+ self.name = "PathError"
60
+
61
+
62
+ def _is_int(value: object) -> bool:
63
+ return isinstance(value, int) and not isinstance(value, bool)
64
+
65
+
66
+ def overlap_strings(before: str, after: str, limit: int = OVERLAP_SCAN_LIMIT) -> int:
67
+ """Longest suffix of ``before`` that is a prefix of ``after``, within ``limit`` characters.
68
+
69
+ Knuth–Morris–Pratt, so a large tail window costs one pass; Chord's delta writer uses the same
70
+ bounded search and falls back to writing the whole string when it finds no shared part.
71
+ """
72
+
73
+ if not before or not after or limit <= 0:
74
+ return 0
75
+ prefix = after[:limit]
76
+ combined = prefix + "\x00" + before
77
+ table = [0] * len(combined)
78
+ length = 0
79
+ for index in range(1, len(combined)):
80
+ while length > 0 and combined[index] != combined[length]:
81
+ length = table[length - 1]
82
+ if combined[index] == combined[length]:
83
+ length += 1
84
+ table[index] = length
85
+ return min(table[-1], len(prefix), len(before))
86
+
87
+
88
+ def assert_safe_path(path: object) -> None:
89
+ """Reject segments that could reach the prototype chain or are not JSON path segments."""
90
+
91
+ if not isinstance(path, list):
92
+ raise TypeError("path is not an array")
93
+ for segment in path:
94
+ if isinstance(segment, str):
95
+ if segment in RESERVED_SEGMENTS:
96
+ raise UnsafePathError(segment)
97
+ elif not _is_int(segment) or segment < 0:
98
+ raise UnsafePathError(segment)
99
+
100
+
101
+ def _assert_path_arg(value: object, *, non_empty: bool = False) -> None:
102
+ if not isinstance(value, list):
103
+ raise TypeError("path is not an array")
104
+ if non_empty and not value:
105
+ raise TypeError("path is empty")
106
+ assert_safe_path(value)
107
+
108
+
109
+ def _assert_permutation(value: object) -> None:
110
+ if not isinstance(value, list):
111
+ raise TypeError("m permutation is not an array")
112
+ seen = [False] * len(value)
113
+ for index in value:
114
+ if not _is_int(index) or index < 0 or index >= len(value) or seen[index]:
115
+ raise TypeError("m permutation is not a bijection")
116
+ seen[index] = True
117
+
118
+
119
+ def assert_valid_op(op: object) -> None:
120
+ """Validate one decoded operation tuple."""
121
+
122
+ if not isinstance(op, list) or not op:
123
+ raise TypeError("op is not a tuple")
124
+ verb = op[0]
125
+ if verb == "r":
126
+ if len(op) != 2:
127
+ raise TypeError("r arity")
128
+ elif verb == "s":
129
+ if len(op) != 3:
130
+ raise TypeError("s arity")
131
+ _assert_path_arg(op[1], non_empty=True)
132
+ elif verb == "d":
133
+ if len(op) != 2:
134
+ raise TypeError("d arity")
135
+ _assert_path_arg(op[1], non_empty=True)
136
+ elif verb == "a":
137
+ if len(op) != 3 or not isinstance(op[2], str):
138
+ raise TypeError("a shape")
139
+ _assert_path_arg(op[1], non_empty=True)
140
+ elif verb == "t":
141
+ if len(op) != 3 or not _is_int(op[2]) or op[2] < 0:
142
+ raise TypeError("t shape")
143
+ _assert_path_arg(op[1], non_empty=True)
144
+ elif verb == "p":
145
+ if len(op) != 5:
146
+ raise TypeError("p arity")
147
+ _assert_path_arg(op[1])
148
+ if not _is_int(op[2]) or op[2] < 0:
149
+ raise TypeError("p index")
150
+ if not _is_int(op[3]) or op[3] < 0:
151
+ raise TypeError("p remove")
152
+ if not isinstance(op[4], list):
153
+ raise TypeError("p items")
154
+ elif verb == "m":
155
+ if len(op) != 3:
156
+ raise TypeError("m arity")
157
+ _assert_path_arg(op[1])
158
+ _assert_permutation(op[2])
159
+ else:
160
+ raise TypeError(f"unknown op verb: {verb}")
161
+
162
+
163
+ def _resolve(root: JsonValue, path: Sequence[Any]) -> Any:
164
+ value: Any = root
165
+ for segment in path:
166
+ if isinstance(segment, str):
167
+ if not isinstance(value, dict):
168
+ raise PathError(list(path))
169
+ value = value[segment]
170
+ else:
171
+ if not isinstance(value, list) or segment >= len(value):
172
+ raise PathError(list(path))
173
+ value = value[segment]
174
+ return value
175
+
176
+
177
+ def _assert_index_in_range(parent: list[Any], index: int) -> None:
178
+ if index > len(parent):
179
+ raise UnsafePathError(index)
180
+
181
+
182
+ def apply(target: JsonValue, ops: Iterable[Op]) -> JsonValue:
183
+ """Apply decoded operations to a mutable value and return the possibly replaced root.
184
+
185
+ Payloads carried by an operation are copied into the result, so the result never aliases the
186
+ caller's operations and later operations can safely address what an earlier one inserted.
187
+ """
188
+
189
+ root: Any = target
190
+ for op in ops:
191
+ assert_valid_op(op)
192
+ if op[0] == "r":
193
+ root = copy_json(op[1])
194
+ continue
195
+ path = op[1]
196
+ assert_safe_path(path)
197
+ if op[0] == "p":
198
+ parent = root if not path else _resolve(root, path)
199
+ if not isinstance(parent, list):
200
+ raise PathError(path)
201
+ index, remove, items = op[2], op[3], op[4]
202
+ parent[index:index + remove] = cast(list[Any], copy_json(items))
203
+ continue
204
+ if op[0] == "m":
205
+ parent = root if not path else _resolve(root, path)
206
+ if not isinstance(parent, list) or len(parent) != len(op[2]):
207
+ raise PathError(path)
208
+ previous = parent[:]
209
+ for index, source in enumerate(op[2]):
210
+ parent[index] = previous[source]
211
+ continue
212
+
213
+ parent = _resolve(root, path[:-1])
214
+ key = path[-1]
215
+ if isinstance(parent, list):
216
+ if not _is_int(key):
217
+ raise UnsafePathError(key)
218
+ _assert_index_in_range(parent, key)
219
+ if not isinstance(parent, (dict, list)):
220
+ raise PathError(path)
221
+ if op[0] == "s":
222
+ parent[key] = copy_json(op[2])
223
+ elif op[0] == "d":
224
+ if isinstance(parent, list):
225
+ if key >= len(parent):
226
+ raise PathError(path)
227
+ del parent[key]
228
+ else:
229
+ parent.pop(key, None)
230
+ elif op[0] == "a":
231
+ current = parent[key] if isinstance(parent, list) or key in parent else None
232
+ if not isinstance(current, str):
233
+ raise PathError(path)
234
+ parent[key] = current + op[2]
235
+ elif op[0] == "t":
236
+ current = parent[key] if isinstance(parent, list) or key in parent else None
237
+ if not isinstance(current, str):
238
+ raise PathError(path)
239
+ parent[key] = current[op[2]:]
240
+ return root
241
+
242
+
243
+ def apply_immutable(target: JsonValue, ops: Sequence[Op]) -> JsonValue:
244
+ """Apply one batch without mutating ``target`` or its containers."""
245
+
246
+ return apply(copy_json(target), ops)
247
+
248
+
249
+ def apply_immutable_batches(target: JsonValue, batches: Iterable[Sequence[Op]]) -> JsonValue:
250
+ """Apply ordered batches without exposing or mutating an intermediate revision."""
251
+
252
+ root = copy_json(target)
253
+ for ops in batches:
254
+ root = apply(root, ops)
255
+ return root
256
+
257
+
258
+ __all__ = [
259
+ "MAX_DELTA_OPERATIONS",
260
+ "Op",
261
+ "PathError",
262
+ "RESERVED_SEGMENTS",
263
+ "UnsafePathError",
264
+ "apply",
265
+ "apply_immutable",
266
+ "apply_immutable_batches",
267
+ "assert_safe_path",
268
+ "assert_valid_op",
269
+ ]
@@ -0,0 +1,366 @@
1
+ """Document definitions, typed tokens, and materialization.
2
+
3
+ A document definition is a token-safe value object. Persisted records keep the v1.3.0 camelCase
4
+ shape; Python callers configure definitions with snake_case names and may also pass a mapping.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import inspect
11
+ import json
12
+ from collections.abc import Callable, Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any, Literal, TypeAlias, cast
15
+
16
+ from .delta import Op
17
+ from .ids import (
18
+ ConversationId,
19
+ DocumentId,
20
+ TaskId,
21
+ )
22
+ from .json import JsonObject, copy_json
23
+ from .types import CheckpointInfo, DocumentAddress, DocumentCreate, DocumentRecord, StoredDocument
24
+
25
+ DocScope: TypeAlias = Literal["session", "conversation", "task"]
26
+
27
+ #: Positional arities of the definition callbacks without the optional receiver.
28
+ _SINGLETON_INITIAL_ARITY = 0
29
+ _FAMILY_INITIAL_ARITY = 1
30
+ _MIGRATE_ARITY = 2
31
+ _CHECKPOINT_ARITY = 3
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class DocDefinition:
36
+ """Erased document definition used by the Session after token resolution."""
37
+
38
+ kind: str
39
+ version: int
40
+ scope: DocScope
41
+ initial: Callable[..., JsonObject]
42
+ family: bool = False
43
+ history: Literal["latest", "rewindable"] | None = None
44
+ fork: Literal["asOf", "current", "initial"] | None = None
45
+ migrate: Callable[[JsonObject, int], JsonObject] | None = None
46
+ checkpoint_when: Callable[[JsonObject, list[Op], CheckpointInfo], bool] | None = None
47
+
48
+ def __getitem__(self, key: str) -> Any:
49
+ if key == "checkpointWhen":
50
+ return self.checkpoint_when
51
+ return getattr(self, key)
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class DocToken:
56
+ """Typed singleton document token passed explicitly to Session access."""
57
+
58
+ definition: DocDefinition
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class DocFamilyToken:
63
+ """Typed keyed document-family token passed explicitly to Session access."""
64
+
65
+ definition: DocDefinition
66
+
67
+
68
+ AnyDocToken: TypeAlias = DocToken | DocFamilyToken
69
+
70
+ # Scope-specific token names from the v1.3.0 public surface. Python resolves the scope at runtime
71
+ # from the definition, so these are the same erased token classes.
72
+ SessionDocToken: TypeAlias = DocToken
73
+ ConversationDocToken: TypeAlias = DocToken
74
+ RewindableConversationDocToken: TypeAlias = DocToken
75
+ TaskDocToken: TypeAlias = DocToken
76
+ SessionDocFamilyToken: TypeAlias = DocFamilyToken
77
+ ConversationDocFamilyToken: TypeAlias = DocFamilyToken
78
+ RewindableConversationDocFamilyToken: TypeAlias = DocFamilyToken
79
+ TaskDocFamilyToken: TypeAlias = DocFamilyToken
80
+
81
+ #: Erased definition shapes used by callers that do not care about family/singleton tokens.
82
+ CommonDocDefinition: TypeAlias = DocDefinition
83
+ DocFamilyDefinition: TypeAlias = DocDefinition
84
+
85
+
86
+ def _definition_values(definition: Mapping[str, Any] | None, kwargs: Mapping[str, Any]) -> dict[str, Any]:
87
+ values = dict(definition or {})
88
+ values.update(kwargs)
89
+ aliases = {
90
+ "checkpointWhen": "checkpoint_when",
91
+ "initial": "initial",
92
+ }
93
+ for source, target in aliases.items():
94
+ if source in values and target not in values:
95
+ values[target] = values.pop(source)
96
+ return values
97
+
98
+
99
+ def _bind_receiver(
100
+ callback: Callable[..., Any] | None,
101
+ definition: DocDefinition,
102
+ arity: int,
103
+ ) -> Callable[..., Any] | None:
104
+ """Bind a definition callback that declares the definition as its receiver.
105
+
106
+ v1.3.0 invokes `initial`, `migrate`, and `checkpointWhen` as methods, so `this` is the
107
+ definition. Python callables are plain functions; one whose first parameter is named `self`
108
+ or `definition` and that declares exactly one extra positional parameter receives the
109
+ definition bound to it.
110
+ """
111
+
112
+ if callback is None:
113
+ return None
114
+ try:
115
+ parameters = list(inspect.signature(callback).parameters.values())
116
+ except (TypeError, ValueError):
117
+ return callback
118
+ if any(parameter.kind is inspect.Parameter.VAR_POSITIONAL for parameter in parameters):
119
+ return callback
120
+ positional = [
121
+ parameter
122
+ for parameter in parameters
123
+ if parameter.kind
124
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
125
+ ]
126
+ if len(positional) != arity + 1:
127
+ return callback
128
+ if positional[0].name not in ("self", "definition"):
129
+ return callback
130
+ return functools.partial(callback, definition)
131
+
132
+
133
+ def _build_definition(values: Mapping[str, Any], *, family: bool) -> DocDefinition:
134
+ kind = values.get("kind")
135
+ if not isinstance(kind, str):
136
+ raise TypeError("Document kind must be a string")
137
+ version = values.get("version")
138
+ if not isinstance(version, int) or isinstance(version, bool) or version < 1:
139
+ raise TypeError(f"Document {kind} version must be a positive integer")
140
+ scope = values.get("scope")
141
+ if scope not in ("session", "conversation", "task"):
142
+ raise TypeError(f"Document {kind} scope must be session, conversation, or task")
143
+ initial = values.get("initial")
144
+ if not callable(initial):
145
+ raise TypeError(f"Document {kind} requires an initial() function")
146
+ history = values.get("history")
147
+ fork = values.get("fork")
148
+ if scope == "conversation":
149
+ if history not in ("latest", "rewindable"):
150
+ raise TypeError(f"Conversation document {kind} requires history 'latest' or 'rewindable'")
151
+ if fork not in ("asOf", "current", "initial"):
152
+ raise TypeError(f"Conversation document {kind} requires a fork policy")
153
+ elif history is not None or fork is not None:
154
+ raise TypeError(f"Document {kind} history and fork apply only to conversation scope")
155
+ migrate = values.get("migrate")
156
+ if migrate is not None and not callable(migrate):
157
+ raise TypeError(f"Document {kind} migrate must be callable")
158
+ checkpoint_when = values.get("checkpoint_when")
159
+ if checkpoint_when is not None and not callable(checkpoint_when):
160
+ raise TypeError(f"Document {kind} checkpoint_when must be callable")
161
+ definition = DocDefinition(
162
+ kind=kind,
163
+ version=version,
164
+ scope=scope,
165
+ initial=initial,
166
+ family=family,
167
+ history=history,
168
+ fork=fork,
169
+ migrate=migrate,
170
+ checkpoint_when=checkpoint_when,
171
+ )
172
+ # Bind definition callbacks that declare the definition as their receiver, after constructing
173
+ # the final value so the receiver is the token's own definition.
174
+ object.__setattr__(
175
+ definition,
176
+ "initial",
177
+ _bind_receiver(
178
+ initial, definition, _FAMILY_INITIAL_ARITY if family else _SINGLETON_INITIAL_ARITY
179
+ ),
180
+ )
181
+ object.__setattr__(
182
+ definition, "migrate", _bind_receiver(migrate, definition, _MIGRATE_ARITY)
183
+ )
184
+ object.__setattr__(
185
+ definition,
186
+ "checkpoint_when",
187
+ _bind_receiver(checkpoint_when, definition, _CHECKPOINT_ARITY),
188
+ )
189
+ return definition
190
+
191
+
192
+ def define_doc(
193
+ definition: Mapping[str, Any] | None = None,
194
+ /,
195
+ **kwargs: Any,
196
+ ) -> DocToken:
197
+ """Define a Session-, conversation-, or task-scoped singleton document."""
198
+
199
+ return DocToken(_build_definition(_definition_values(definition, kwargs), family=False))
200
+
201
+
202
+ def define_doc_family(
203
+ definition: Mapping[str, Any] | None = None,
204
+ /,
205
+ **kwargs: Any,
206
+ ) -> DocFamilyToken:
207
+ """Define a keyed Session-, conversation-, or task-scoped document family."""
208
+
209
+ values = _definition_values(definition, kwargs)
210
+ values.pop("family", None)
211
+ return DocFamilyToken(_build_definition(values, family=True))
212
+
213
+
214
+ @dataclass(frozen=True, slots=True)
215
+ class ResolvedAddress:
216
+ """Logical address plus its stable cache identity and consumed argument count."""
217
+
218
+ address: DocumentAddress
219
+ id: str
220
+ next_argument: int
221
+
222
+
223
+ def _owner_id(value: object, definition: DocDefinition, scope: str) -> int:
224
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
225
+ raise TypeError(f"Document {definition.kind} requires a {scope} ID")
226
+ return value
227
+
228
+
229
+ def resolve_address(definition: DocDefinition, args: tuple[Any, ...] | list[Any]) -> ResolvedAddress:
230
+ """Resolve an overloaded argument list and return the index after owner and family key."""
231
+
232
+ index = 0
233
+ if definition.scope == "session":
234
+ scope: dict[str, Any] = {"kind": "session"}
235
+ elif definition.scope == "conversation":
236
+ scope = {
237
+ "kind": "conversation",
238
+ "conversationId": cast(ConversationId, _owner_id(args[index], definition, "conversation")),
239
+ }
240
+ index += 1
241
+ else:
242
+ scope = {"kind": "task", "taskId": cast(TaskId, _owner_id(args[index], definition, "task"))}
243
+ index += 1
244
+ key: str | None = None
245
+ if definition.family:
246
+ if index >= len(args) or not isinstance(args[index], str):
247
+ raise TypeError(f"Document family {definition.kind} requires a string key")
248
+ key = args[index]
249
+ index += 1
250
+ address: DocumentAddress = {"kind": definition.kind, "scope": cast(Any, scope)}
251
+ if key is not None:
252
+ address["key"] = key
253
+ return ResolvedAddress(address=address, id=address_id(address), next_argument=index)
254
+
255
+
256
+ def address_id(address: DocumentAddress) -> str:
257
+ """Return the stable string identity of one logical address."""
258
+
259
+ scope = address["scope"]
260
+ owner = None if scope["kind"] == "session" else scope.get("conversationId", scope.get("taskId"))
261
+ return json.dumps(
262
+ [address["kind"], scope["kind"], owner, address.get("key")],
263
+ separators=(",", ":"),
264
+ )
265
+
266
+
267
+ def document_create(
268
+ definition: DocDefinition, address: DocumentAddress, document_id: DocumentId
269
+ ) -> DocumentCreate:
270
+ """Build the storage create record for a new incarnation at an address."""
271
+
272
+ record: dict[str, Any] = {
273
+ "id": document_id,
274
+ "kind": address["kind"],
275
+ "scope": copy_json(address["scope"]),
276
+ }
277
+ if "key" in address:
278
+ record["key"] = address["key"]
279
+ if address["scope"]["kind"] == "conversation":
280
+ record["history"] = definition.history
281
+ record["fork"] = definition.fork
282
+ return cast(DocumentCreate, record)
283
+
284
+
285
+ def check_record_scope(definition: DocDefinition, record: DocumentCreate | DocumentRecord) -> None:
286
+ """Reject typed access whose token disagrees with the persisted scope and semantics."""
287
+
288
+ scope = record["scope"]
289
+ if scope["kind"] != definition.scope:
290
+ raise TypeError(
291
+ f"Document {record['id']} ({record['kind']}) does not match the supplied definition semantics"
292
+ )
293
+ if scope["kind"] == "conversation":
294
+ if record.get("history") != definition.history or record.get("fork") != definition.fork:
295
+ raise TypeError(
296
+ f"Document {record['id']} ({record['kind']}) does not match the supplied definition semantics"
297
+ )
298
+
299
+
300
+ def check_record_version(
301
+ definition: DocDefinition,
302
+ record: DocumentCreate | DocumentRecord,
303
+ version: int,
304
+ ) -> None:
305
+ """Reject typed access to a stored version the supplied definition cannot use."""
306
+
307
+ if version > definition.version:
308
+ raise ValueError(
309
+ f"Document {record['id']} ({record['kind']}) has newer version {version} "
310
+ f"than {definition.version}"
311
+ )
312
+ if version < definition.version and definition.migrate is None:
313
+ raise ValueError(
314
+ f"Document {record['id']} ({record['kind']}) requires migration from version {version}"
315
+ )
316
+
317
+
318
+ def materialize_document_value(
319
+ definition: DocDefinition,
320
+ record: DocumentCreate | DocumentRecord,
321
+ version: int,
322
+ value: JsonObject,
323
+ ) -> JsonObject:
324
+ """Validate and materialize one detached value for typed access."""
325
+
326
+ check_record_scope(definition, record)
327
+ check_record_version(definition, record, version)
328
+ if version == definition.version:
329
+ return value
330
+ assert definition.migrate is not None
331
+ return cast(JsonObject, copy_json(definition.migrate(cast(JsonObject, copy_json(value)), version)))
332
+
333
+
334
+ def materialize_document(definition: DocDefinition, stored: StoredDocument) -> JsonObject:
335
+ """Validate and materialize one detached stored document."""
336
+
337
+ return materialize_document_value(definition, stored["record"], stored["version"], stored["value"])
338
+
339
+
340
+ __all__ = [
341
+ "AnyDocToken",
342
+ "CommonDocDefinition",
343
+ "ConversationDocFamilyToken",
344
+ "ConversationDocToken",
345
+ "DocDefinition",
346
+ "DocFamilyDefinition",
347
+ "DocFamilyToken",
348
+ "DocScope",
349
+ "DocToken",
350
+ "ResolvedAddress",
351
+ "RewindableConversationDocFamilyToken",
352
+ "RewindableConversationDocToken",
353
+ "SessionDocFamilyToken",
354
+ "SessionDocToken",
355
+ "TaskDocFamilyToken",
356
+ "TaskDocToken",
357
+ "address_id",
358
+ "check_record_scope",
359
+ "check_record_version",
360
+ "define_doc",
361
+ "define_doc_family",
362
+ "document_create",
363
+ "materialize_document",
364
+ "materialize_document_value",
365
+ "resolve_address",
366
+ ]
pi_durable/entries.py ADDED
@@ -0,0 +1,66 @@
1
+ """Typed entry kinds.
2
+
3
+ An ``Entry`` token carries the persisted discriminator and narrows an entry record by ``kind``.
4
+ The built-in tokens name the transcript entries durable itself writes; applications define their
5
+ own with :func:`define_entry`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from typing import Any, Generic, Never, TypeGuard, TypeVar, cast
13
+
14
+ from .json import JsonValue
15
+ from .types import TypedEntry
16
+
17
+ D = TypeVar("D", bound=JsonValue)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Entry(Generic[D]):
22
+ """A typed entry kind with a narrowing guard for its ``data`` payload."""
23
+
24
+ kind: str
25
+
26
+ def is_(self, entry: Mapping[str, Any] | None) -> TypeGuard[TypedEntry[D]]:
27
+ """Return whether ``entry`` is present and has this token's kind."""
28
+
29
+ return entry is not None and entry.get("kind") == self.kind
30
+
31
+
32
+ def define_entry(kind: str) -> Entry[D]:
33
+ """Define a typed entry kind whose guard narrows by ``EntryRecord.kind``."""
34
+
35
+ if not isinstance(kind, str) or not kind:
36
+ raise TypeError("Entry kind must be a non-empty string")
37
+ return cast(Entry[D], Entry(kind))
38
+
39
+
40
+ #: User input: ``model`` is ``[UserMessage]``. Written by submissions.
41
+ UserEntry: Entry[Never] = define_entry("pi.user")
42
+ #: Provider result with any stop reason: ``model`` is ``[AssistantMessage]``. Written by generation.
43
+ AssistantEntry: Entry[Never] = define_entry("pi.assistant")
44
+ #: Positional prompt and tool change: ``model`` is ``[SystemMessage]`` with empty ``content``.
45
+ SystemEntry: Entry[Never] = define_entry("pi.system")
46
+ #: Tool result: ``model`` is ``[ToolResultMessage]``, whose content ends with the rendered
47
+ #: diagnostics block; ``data`` holds the structured diagnostics, possibly none.
48
+ ToolResultEntry: Entry[dict[str, JsonValue]] = define_entry("pi.tool-result")
49
+ #: Start of a new context: always ``head: "self"``, with ``model`` absent for a plain reset or
50
+ #: ``[UserMessage]`` carrying the handoff text.
51
+ ResetEntry: Entry[Never] = define_entry("pi.reset")
52
+ #: Compaction summary: ``model`` is ``[UserMessage]`` with the wrapped summary, ``head`` the first
53
+ #: kept entry.
54
+ CompactionEntry: Entry[dict[str, JsonValue]] = define_entry("pi.compaction")
55
+
56
+
57
+ __all__ = [
58
+ "AssistantEntry",
59
+ "CompactionEntry",
60
+ "Entry",
61
+ "ResetEntry",
62
+ "SystemEntry",
63
+ "ToolResultEntry",
64
+ "UserEntry",
65
+ "define_entry",
66
+ ]