prime-agent-dsh 0.2.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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/SECURITY.md +27 -0
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/docs/context-spill.md +7 -0
- package/docs/durable-context-query.md +34 -0
- package/docs/getting-started.md +253 -0
- package/docs/security.md +78 -0
- package/docs/shadow-telemetry-validation.md +18 -0
- package/docs/single-window-cache-architecture.md +24 -0
- package/extensions/index.ts +161 -0
- package/extensions/shadow-context.ts +168 -0
- package/package.json +109 -0
- package/scripts/package-smoke.mjs +148 -0
- package/scripts/patch-pi-ai-partial-json.mjs +14 -0
- package/skills/dsh-context/SKILL.md +62 -0
- package/skills/dsh-context/pyproject.toml +13 -0
- package/skills/dsh-context/src/dsh_context/__init__.py +573 -0
- package/src/context-converter.ts +176 -0
- package/src/context-objects.ts +247 -0
- package/src/context-protocol.ts +37 -0
- package/src/context-spill.ts +109 -0
- package/src/dsh-context-service.ts +38 -0
- package/src/dsh-image-attachments.ts +99 -0
- package/src/durable-context-query.ts +233 -0
- package/src/durable-context-store.ts +838 -0
- package/src/durable-file-attachments.ts +186 -0
- package/src/prefix-metrics.ts +91 -0
- package/src/provider-cache-series.ts +56 -0
- package/src/recursive-context-loader.ts +215 -0
- package/src/rlm-context-bootstrap.ts +333 -0
- package/src/rlm-context-inheritance.ts +389 -0
- package/src/shadow-telemetry.ts +84 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dsh-context
|
|
3
|
+
description: Inspect the current Prime session through lazy DeepSeek Harness context objects. Use to search prior turns, read bounded transcript slices, inspect provider-reported cache usage, create immutable snapshots or private artifacts, deliberately admit selected context, and grant bounded parent context to an RLM descendant.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DSH Context Objects
|
|
7
|
+
|
|
8
|
+
The extension mirrors Prime's canonical active branch into a rebuildable DSH projection before each model request. Prime remains the only model/tool loop and canonical session log. The Python module reads immutable private snapshots; it never edits either history.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
ctx = dsh_context.current()
|
|
12
|
+
ctx
|
|
13
|
+
|
|
14
|
+
ctx.entries(last=10)
|
|
15
|
+
ctx.search("authentication", limit=20)
|
|
16
|
+
ctx.metrics
|
|
17
|
+
|
|
18
|
+
selection = ctx.search("migration decision", limit=8)
|
|
19
|
+
print(ctx.inject(selection, label="Relevant migration decisions"))
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## API
|
|
23
|
+
|
|
24
|
+
- `dsh_context.current()` returns the current session's `ContextHandle`.
|
|
25
|
+
- `ctx.snapshot(digest=None)` pins the current or named immutable snapshot.
|
|
26
|
+
- `ctx.entries(start=0, limit=20, last=None, role=None)` returns bounded Prime branch entries.
|
|
27
|
+
- `ctx.messages(start=0, limit=20, last=None, role=None)` returns bounded DSH-projected messages.
|
|
28
|
+
- `ctx.search(query, limit=20, regex=False, case_sensitive=False)` searches the complete retained entry range.
|
|
29
|
+
- `ctx.metrics` reports provider-supplied input, output, cache-read, cache-write, and total token aggregates. It does not infer cache hits.
|
|
30
|
+
- `ctx.artifact(value, label="context")` writes selected material to a private content-addressed Markdown file.
|
|
31
|
+
- `ctx.inject(value, label="Selected DSH context", max_bytes=65536)` returns bounded model-facing text. **Print or return this value from the IPython cell** so Prime records it as a durable tool result. `ctx.admit` is an alias.
|
|
32
|
+
- `grant = ctx.grant(value, label="Shared parent context")` creates a bounded, expiring read-only capability. Include `grant.instruction` in an RLM child's task.
|
|
33
|
+
- `dsh_context.open_grant(grant.uri)` opens a grant reachable from the current session directory or one of its ancestors.
|
|
34
|
+
|
|
35
|
+
## Parent-to-child context
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
selection = ctx.search("authentication design", limit=12)
|
|
39
|
+
grant = ctx.grant(selection, label="Authentication evidence")
|
|
40
|
+
child = await rlm.spawn(
|
|
41
|
+
"Review the authentication design. " + grant.instruction,
|
|
42
|
+
name="auth-review",
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The child can then call:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
evidence = dsh_context.open_grant("dsh-context-grant:...")
|
|
50
|
+
evidence.value
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A grant contains only the selected bounded value, its source snapshot identity, and expiry. It does not grant parent tool authority or a live parent transcript.
|
|
54
|
+
|
|
55
|
+
## Rules
|
|
56
|
+
|
|
57
|
+
- Search and inspect before admitting context; do not copy the complete transcript into active context.
|
|
58
|
+
- Treat snapshots as read-only evidence at their recorded revision and branch leaf.
|
|
59
|
+
- A child sees its own session snapshot. A small untrusted evidence capsule is inherited automatically through stock Prime lifecycle hooks; use an explicit capability grant for larger selected parent context.
|
|
60
|
+
- Reference-only snapshot metadata and indexes are derived and rebuildable. Prime JSONL is the only full-content authority; new DSH roots do not persist duplicate message bodies or compatibility text.
|
|
61
|
+
- Printing `ctx.inject(...)` is the supported durable-admission path on Prime 0.9.5. Arbitrary extension-defined Python host requests are not public in that release.
|
|
62
|
+
- Provider cache metrics are authoritative; context-object access itself does not imply a cache hit.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dsh-context"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Lazy DeepSeek Harness context objects for Prime Agent kernels"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[build-system]
|
|
9
|
+
requires = ["hatchling"]
|
|
10
|
+
build-backend = "hatchling.build"
|
|
11
|
+
|
|
12
|
+
[tool.hatch.build.targets.wheel]
|
|
13
|
+
packages = ["src/dsh_context"]
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Lazy DeepSeek Harness context objects for Prime Agent kernels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_VERSION = "prime-agent-dsh/context-object-v1"
|
|
17
|
+
_GRANT_VERSION = "prime-agent-dsh/context-grant-v1"
|
|
18
|
+
_DEFAULT_LIMIT = 20
|
|
19
|
+
_MAX_LIMIT = 200
|
|
20
|
+
_MAX_INJECT_BYTES = 65_536
|
|
21
|
+
_MAX_ARTIFACT_BYTES = 4 * 1024 * 1024
|
|
22
|
+
_MAX_GRANT_BYTES = 1024 * 1024
|
|
23
|
+
_MAX_GRANT_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
24
|
+
_GRANT_TOKEN = re.compile(r"^[A-Za-z0-9_-]{24,128}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _session_dir() -> Path:
|
|
28
|
+
raw = os.environ.get("RLM_SESSION_DIR")
|
|
29
|
+
if not raw:
|
|
30
|
+
raise RuntimeError("RLM_SESSION_DIR is unavailable; dsh_context requires a Prime Agent session kernel")
|
|
31
|
+
return Path(raw).expanduser().resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _private_root() -> Path:
|
|
35
|
+
root = _session_dir() / "dsh-context"
|
|
36
|
+
manifest = root / "manifest.json"
|
|
37
|
+
if not manifest.is_file():
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
f"DSH context snapshot is not ready at {manifest}; run one model turn after loading the extension"
|
|
40
|
+
)
|
|
41
|
+
return root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
45
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise RuntimeError(f"invalid DSH context object at {path}")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _canonical_json(value: Any) -> bytes:
|
|
52
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bounded_limit(limit: int) -> int:
|
|
56
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0:
|
|
57
|
+
raise TypeError("limit must be a non-negative integer")
|
|
58
|
+
return min(limit, _MAX_LIMIT)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _message_text(message: dict[str, Any]) -> str:
|
|
62
|
+
out: list[str] = []
|
|
63
|
+
content = message.get("content")
|
|
64
|
+
if not isinstance(content, list):
|
|
65
|
+
return ""
|
|
66
|
+
for block in content:
|
|
67
|
+
if not isinstance(block, dict):
|
|
68
|
+
continue
|
|
69
|
+
kind = block.get("type")
|
|
70
|
+
if kind in ("text", "reasoning") and isinstance(block.get("text"), str):
|
|
71
|
+
out.append(block["text"])
|
|
72
|
+
elif kind == "tool-call":
|
|
73
|
+
out.append(f"[tool {block.get('name', 'unknown')}] {block.get('arguments', '')}")
|
|
74
|
+
elif kind == "tool-result":
|
|
75
|
+
out.append(_message_text({"content": block.get("content", [])}))
|
|
76
|
+
elif kind == "image":
|
|
77
|
+
out.append("[image attachment]")
|
|
78
|
+
return "\n".join(part for part in out if part)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _jsonable(value: Any) -> Any:
|
|
82
|
+
if isinstance(value, ContextSelection):
|
|
83
|
+
return [item.data for item in value]
|
|
84
|
+
if isinstance(value, ContextItem):
|
|
85
|
+
return value.data
|
|
86
|
+
if isinstance(value, ContextSnapshot):
|
|
87
|
+
return value.data
|
|
88
|
+
if isinstance(value, GrantedContext):
|
|
89
|
+
return value.value
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _render(value: Any) -> str:
|
|
94
|
+
plain = _jsonable(value)
|
|
95
|
+
if isinstance(plain, str):
|
|
96
|
+
return plain
|
|
97
|
+
return json.dumps(plain, ensure_ascii=False, indent=2, sort_keys=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _crop_utf8(value: str, max_bytes: int) -> str:
|
|
101
|
+
encoded = value.encode("utf-8")
|
|
102
|
+
if len(encoded) <= max_bytes:
|
|
103
|
+
return value
|
|
104
|
+
marker = f"\n[… {len(encoded) - max_bytes} UTF-8 bytes omitted …]"
|
|
105
|
+
budget = max(0, max_bytes - len(marker.encode("utf-8")))
|
|
106
|
+
return encoded[:budget].decode("utf-8", errors="ignore") + marker
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _private_directory(path: Path) -> None:
|
|
110
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
111
|
+
if path.is_symlink() or not path.is_dir():
|
|
112
|
+
raise RuntimeError(f"unsafe DSH context directory: {path}")
|
|
113
|
+
os.chmod(path, 0o700)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _exclusive_write(path: Path, raw: bytes) -> None:
|
|
117
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
118
|
+
with os.fdopen(fd, "wb") as stream:
|
|
119
|
+
stream.write(raw)
|
|
120
|
+
stream.flush()
|
|
121
|
+
os.fsync(stream.fileno())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ContextItem:
|
|
126
|
+
"""One immutable entry or model message selected from a snapshot."""
|
|
127
|
+
|
|
128
|
+
kind: str
|
|
129
|
+
index: int
|
|
130
|
+
data: dict[str, Any]
|
|
131
|
+
text: str
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def role(self) -> str | None:
|
|
135
|
+
value = self.data.get("role")
|
|
136
|
+
return value if isinstance(value, str) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ContextSelection(Sequence[ContextItem]):
|
|
140
|
+
"""A bounded immutable collection tied to one snapshot digest."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, digest: str, items: Iterable[ContextItem]):
|
|
143
|
+
self.digest = digest
|
|
144
|
+
self._items = tuple(items)
|
|
145
|
+
|
|
146
|
+
def __getitem__(self, index: int | slice) -> ContextItem | tuple[ContextItem, ...]:
|
|
147
|
+
return self._items[index]
|
|
148
|
+
|
|
149
|
+
def __len__(self) -> int:
|
|
150
|
+
return len(self._items)
|
|
151
|
+
|
|
152
|
+
def __iter__(self) -> Iterator[ContextItem]:
|
|
153
|
+
return iter(self._items)
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
return f"ContextSelection(digest={self.digest[:12]!r}, items={len(self)})"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ContextSnapshot:
|
|
160
|
+
"""An immutable content-addressed view of one exact observed Prime branch."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, root: Path, manifest: dict[str, Any], data: dict[str, Any]):
|
|
163
|
+
self.root = root
|
|
164
|
+
self.manifest = manifest
|
|
165
|
+
self.data = data
|
|
166
|
+
self.digest = str(manifest["digest"])
|
|
167
|
+
self.session_id = str(data["sessionId"])
|
|
168
|
+
self.branch_id = str(data["branchId"])
|
|
169
|
+
self.revision = int(data["revision"])
|
|
170
|
+
self.cropped = bool(data.get("cropped", False))
|
|
171
|
+
self.unavailable_message_count = int(data.get("unavailableEffectiveEntries", 0))
|
|
172
|
+
metrics = data.get("metrics")
|
|
173
|
+
self.metrics = dict(metrics) if isinstance(metrics, dict) else {}
|
|
174
|
+
|
|
175
|
+
def _select(
|
|
176
|
+
self,
|
|
177
|
+
kind: str,
|
|
178
|
+
values: list[Any],
|
|
179
|
+
*,
|
|
180
|
+
start: int,
|
|
181
|
+
limit: int,
|
|
182
|
+
last: int | None,
|
|
183
|
+
role: str | None,
|
|
184
|
+
) -> ContextSelection:
|
|
185
|
+
if not isinstance(start, int) or isinstance(start, bool) or start < 0:
|
|
186
|
+
raise TypeError("start must be a non-negative integer")
|
|
187
|
+
limit = _bounded_limit(limit)
|
|
188
|
+
items: list[ContextItem] = []
|
|
189
|
+
for index, raw in enumerate(values):
|
|
190
|
+
if not isinstance(raw, dict):
|
|
191
|
+
continue
|
|
192
|
+
item_role = raw.get("role")
|
|
193
|
+
if role is not None and item_role != role:
|
|
194
|
+
continue
|
|
195
|
+
text = str(raw.get("text", "")) if kind == "entry" else _message_text(raw)
|
|
196
|
+
items.append(ContextItem(kind=kind, index=index, data=raw, text=text))
|
|
197
|
+
if last is not None:
|
|
198
|
+
if not isinstance(last, int) or isinstance(last, bool) or last < 0:
|
|
199
|
+
raise TypeError("last must be a non-negative integer or None")
|
|
200
|
+
count = min(last, _MAX_LIMIT)
|
|
201
|
+
return ContextSelection(self.digest, items[-count:] if count else [])
|
|
202
|
+
return ContextSelection(self.digest, items[start : start + limit])
|
|
203
|
+
|
|
204
|
+
def entries(
|
|
205
|
+
self,
|
|
206
|
+
start: int = 0,
|
|
207
|
+
limit: int = _DEFAULT_LIMIT,
|
|
208
|
+
*,
|
|
209
|
+
last: int | None = None,
|
|
210
|
+
role: str | None = None,
|
|
211
|
+
) -> ContextSelection:
|
|
212
|
+
values = self.data.get("entries", [])
|
|
213
|
+
return self._select("entry", values if isinstance(values, list) else [], start=start, limit=limit, last=last, role=role)
|
|
214
|
+
|
|
215
|
+
def messages(
|
|
216
|
+
self,
|
|
217
|
+
start: int = 0,
|
|
218
|
+
limit: int = _DEFAULT_LIMIT,
|
|
219
|
+
*,
|
|
220
|
+
last: int | None = None,
|
|
221
|
+
role: str | None = None,
|
|
222
|
+
) -> ContextSelection:
|
|
223
|
+
values = self.data.get("messages", [])
|
|
224
|
+
return self._select("message", values if isinstance(values, list) else [], start=start, limit=limit, last=last, role=role)
|
|
225
|
+
|
|
226
|
+
def search(
|
|
227
|
+
self,
|
|
228
|
+
query: str,
|
|
229
|
+
limit: int = _DEFAULT_LIMIT,
|
|
230
|
+
*,
|
|
231
|
+
regex: bool = False,
|
|
232
|
+
case_sensitive: bool = False,
|
|
233
|
+
) -> ContextSelection:
|
|
234
|
+
if not isinstance(query, str) or not query:
|
|
235
|
+
raise ValueError("query must be a non-empty string")
|
|
236
|
+
limit = _bounded_limit(limit)
|
|
237
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
238
|
+
pattern = re.compile(query if regex else re.escape(query), flags)
|
|
239
|
+
values = self.data.get("entries", [])
|
|
240
|
+
items: list[ContextItem] = []
|
|
241
|
+
for index, raw in enumerate(values if isinstance(values, list) else []):
|
|
242
|
+
if not isinstance(raw, dict):
|
|
243
|
+
continue
|
|
244
|
+
text = str(raw.get("text", ""))
|
|
245
|
+
if pattern.search(text):
|
|
246
|
+
items.append(ContextItem(kind="entry", index=index, data=raw, text=text))
|
|
247
|
+
if len(items) == limit:
|
|
248
|
+
break
|
|
249
|
+
return ContextSelection(self.digest, items)
|
|
250
|
+
|
|
251
|
+
def __repr__(self) -> str:
|
|
252
|
+
return (
|
|
253
|
+
f"ContextSnapshot(session_id={self.session_id!r}, branch_id={self.branch_id!r}, "
|
|
254
|
+
f"revision={self.revision}, digest={self.digest[:12]!r}, cropped={self.cropped})"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class ContextArtifact:
|
|
260
|
+
path: Path
|
|
261
|
+
digest: str
|
|
262
|
+
bytes: int
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class ContextGrant:
|
|
267
|
+
"""Capability token for a bounded immutable parent-context selection."""
|
|
268
|
+
|
|
269
|
+
token: str
|
|
270
|
+
digest: str
|
|
271
|
+
expires_at: int
|
|
272
|
+
label: str
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def uri(self) -> str:
|
|
276
|
+
return f"dsh-context-grant:{self.token}"
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def instruction(self) -> str:
|
|
280
|
+
return (
|
|
281
|
+
f"A read-only parent context grant is available as {self.uri}. "
|
|
282
|
+
"In the Python REPL call dsh_context.open_grant(" + repr(self.uri) + ") to inspect it."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class GrantedContext:
|
|
288
|
+
token: str
|
|
289
|
+
label: str
|
|
290
|
+
source_session_id: str
|
|
291
|
+
source_branch_id: str
|
|
292
|
+
source_snapshot_digest: str
|
|
293
|
+
created_at: int
|
|
294
|
+
expires_at: int
|
|
295
|
+
value: Any
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class ContextHandle:
|
|
299
|
+
"""Live pointer whose operations pin the current immutable snapshot first."""
|
|
300
|
+
|
|
301
|
+
def __init__(self, root: Path):
|
|
302
|
+
self.root = root
|
|
303
|
+
|
|
304
|
+
def snapshot(self, digest: str | None = None, *, expected_branch_id: str | None = None) -> ContextSnapshot:
|
|
305
|
+
manifest = _read_json(self.root / "manifest.json")
|
|
306
|
+
if manifest.get("version") != _VERSION:
|
|
307
|
+
raise RuntimeError(f"unsupported DSH context manifest version: {manifest.get('version')!r}")
|
|
308
|
+
expected = manifest.get("digest") if digest is None else digest
|
|
309
|
+
if not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected):
|
|
310
|
+
raise ValueError("snapshot digest must be 64 lowercase hexadecimal characters")
|
|
311
|
+
if digest is None or digest == manifest.get("digest"):
|
|
312
|
+
relative = manifest.get("snapshot")
|
|
313
|
+
if relative != f"objects/{expected}.json":
|
|
314
|
+
raise RuntimeError("invalid DSH context object path")
|
|
315
|
+
else:
|
|
316
|
+
manifest = {**manifest, "digest": expected, "snapshot": f"objects/{expected}.json"}
|
|
317
|
+
path = (self.root / "objects" / f"{expected}.json").resolve()
|
|
318
|
+
if self.root not in path.parents or path.is_symlink() or not path.is_file():
|
|
319
|
+
raise RuntimeError(f"invalid DSH context object file: {path}")
|
|
320
|
+
raw = path.read_bytes()
|
|
321
|
+
if sha256(raw.rstrip(b"\n")).hexdigest() != expected:
|
|
322
|
+
raise RuntimeError("DSH context object digest mismatch")
|
|
323
|
+
stored = json.loads(raw)
|
|
324
|
+
if not isinstance(stored, dict) or stored.get("version") != "prime-agent-dsh/derived-object-v3-reference":
|
|
325
|
+
raise RuntimeError("unsupported DSH derived object schema")
|
|
326
|
+
compatibility = stored.get("compatibility")
|
|
327
|
+
if not isinstance(compatibility, dict) or compatibility.get("version") != "prime-agent-dsh/durable-store-v3-reference":
|
|
328
|
+
raise RuntimeError("invalid DSH durable context object")
|
|
329
|
+
binding = _read_json(self.root / "BINDING")
|
|
330
|
+
if binding.get("version") != "prime-agent-dsh/durable-store-v3-reference":
|
|
331
|
+
raise RuntimeError("unsupported DSH durable store schema")
|
|
332
|
+
prime_raw = binding.get("primeSessionFile")
|
|
333
|
+
if not isinstance(prime_raw, str) or not os.path.isabs(prime_raw):
|
|
334
|
+
raise RuntimeError("invalid DSH Prime binding")
|
|
335
|
+
prime_path = Path(prime_raw)
|
|
336
|
+
if prime_path.is_symlink() or not prime_path.is_file():
|
|
337
|
+
raise RuntimeError("missing or unsafe bound Prime JSONL")
|
|
338
|
+
prime = prime_path.read_bytes()
|
|
339
|
+
digests = stored.get("sourceEntryDigests")
|
|
340
|
+
locators = stored.get("sourceLocators")
|
|
341
|
+
if not isinstance(digests, list) or not isinstance(locators, list) or len(digests) != len(locators):
|
|
342
|
+
raise RuntimeError("invalid DSH source locators")
|
|
343
|
+
source: list[Any] = []
|
|
344
|
+
for index, (entry_digest, locator) in enumerate(zip(digests, locators)):
|
|
345
|
+
if (not isinstance(entry_digest, str) or not re.fullmatch(r"[a-f0-9]{64}", entry_digest)
|
|
346
|
+
or not isinstance(locator, dict) or locator.get("index") != index
|
|
347
|
+
or locator.get("entryDigest") != entry_digest):
|
|
348
|
+
raise RuntimeError("invalid DSH source locator")
|
|
349
|
+
offset, length = locator.get("byteOffset"), locator.get("byteLength")
|
|
350
|
+
if (not isinstance(offset, int) or isinstance(offset, bool) or offset < 0
|
|
351
|
+
or not isinstance(length, int) or isinstance(length, bool) or length <= 0
|
|
352
|
+
or offset + length > len(prime)):
|
|
353
|
+
raise RuntimeError("invalid DSH source locator bounds")
|
|
354
|
+
end = offset + length
|
|
355
|
+
if ((offset > 0 and prime[offset - 1] != 0x0A)
|
|
356
|
+
or (end < len(prime) and prime[end] != 0x0A
|
|
357
|
+
and not (prime[end] == 0x0D and end + 1 < len(prime) and prime[end + 1] == 0x0A))):
|
|
358
|
+
raise RuntimeError("DSH source locator is not a complete JSONL line")
|
|
359
|
+
try:
|
|
360
|
+
value = json.loads(prime[offset:end])
|
|
361
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
362
|
+
raise RuntimeError("invalid located Prime JSONL entry") from error
|
|
363
|
+
if sha256(_canonical_json(value)).hexdigest() != entry_digest:
|
|
364
|
+
raise RuntimeError("bound Prime JSONL entry digest mismatch")
|
|
365
|
+
entry_id = locator.get("entryId")
|
|
366
|
+
if entry_id is not None and (not isinstance(value, dict) or value.get("id") != entry_id):
|
|
367
|
+
raise RuntimeError("bound Prime JSONL entry id mismatch")
|
|
368
|
+
source.append(value)
|
|
369
|
+
if sha256(_canonical_json(source)).hexdigest() != stored.get("sourceDigest"):
|
|
370
|
+
raise RuntimeError("DSH source aggregate digest mismatch")
|
|
371
|
+
refs = stored.get("effectiveReferences")
|
|
372
|
+
effective_digests = stored.get("effectiveEntryDigests")
|
|
373
|
+
if not isinstance(refs, list) or not isinstance(effective_digests, list) or len(refs) != len(effective_digests):
|
|
374
|
+
raise RuntimeError("invalid DSH effective references")
|
|
375
|
+
def context_message(entry: Any) -> dict[str, Any] | None:
|
|
376
|
+
if not isinstance(entry, dict): return None
|
|
377
|
+
kind = entry.get("type")
|
|
378
|
+
if kind == "message" and isinstance(entry.get("message"), dict): return dict(entry["message"])
|
|
379
|
+
if kind == "custom_message":
|
|
380
|
+
return {"role": "custom", "customType": entry.get("customType", ""), "content": entry.get("content", []),
|
|
381
|
+
"display": entry.get("display", False), "details": entry.get("details"), "timestamp": entry.get("timestamp")}
|
|
382
|
+
if kind == "branch_summary" and isinstance(entry.get("summary"), str):
|
|
383
|
+
return {"role": "branchSummary", "summary": entry["summary"], "fromId": entry.get("fromId"), "timestamp": entry.get("timestamp")}
|
|
384
|
+
if kind == "compaction" and isinstance(entry.get("summary"), str):
|
|
385
|
+
return {"role": "compactionSummary", "summary": entry["summary"], "tokensBefore": entry.get("tokensBefore", 0), "timestamp": entry.get("timestamp")}
|
|
386
|
+
return None
|
|
387
|
+
messages: list[Any] = []
|
|
388
|
+
unavailable = 0
|
|
389
|
+
for reference, expected_digest in zip(refs, effective_digests):
|
|
390
|
+
if (not isinstance(expected_digest, str) or not re.fullmatch(r"[a-f0-9]{64}", expected_digest)
|
|
391
|
+
or not isinstance(reference, dict) or reference.get("entryDigest") != expected_digest):
|
|
392
|
+
raise RuntimeError("invalid DSH effective reference")
|
|
393
|
+
source_index = reference.get("sourceIndex")
|
|
394
|
+
if source_index is None:
|
|
395
|
+
unavailable += 1
|
|
396
|
+
continue
|
|
397
|
+
if not isinstance(source_index, int) or isinstance(source_index, bool) or not 0 <= source_index < len(source):
|
|
398
|
+
raise RuntimeError("invalid DSH effective source index")
|
|
399
|
+
message = context_message(source[source_index])
|
|
400
|
+
if message is None:
|
|
401
|
+
unavailable += 1
|
|
402
|
+
continue
|
|
403
|
+
rendered = dict(message)
|
|
404
|
+
if isinstance(rendered.get("content"), str):
|
|
405
|
+
rendered["content"] = [{"type": "text", "text": rendered["content"]}]
|
|
406
|
+
messages.append(rendered)
|
|
407
|
+
def entry_text(value: Any) -> str:
|
|
408
|
+
if isinstance(value, dict):
|
|
409
|
+
message = value.get("message")
|
|
410
|
+
candidate = message if isinstance(message, dict) else value
|
|
411
|
+
content = candidate.get("content")
|
|
412
|
+
if isinstance(content, str): return content
|
|
413
|
+
if isinstance(content, list): return _message_text({"content": content})
|
|
414
|
+
if isinstance(candidate.get("summary"), str): return candidate["summary"]
|
|
415
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
416
|
+
entries = [{**value, "index": index, "text": entry_text(value)} if isinstance(value, dict)
|
|
417
|
+
else {"index": index, "text": entry_text(value), "value": value}
|
|
418
|
+
for index, value in enumerate(source)]
|
|
419
|
+
if compatibility.get("messageCount") != len(refs) or compatibility.get("entries") != [] or "messages" in compatibility:
|
|
420
|
+
raise RuntimeError("invalid DSH reference-only view")
|
|
421
|
+
data = {**compatibility, "version": _VERSION, "entries": entries, "messages": messages,
|
|
422
|
+
"unavailableEffectiveEntries": unavailable,
|
|
423
|
+
"metrics": compatibility.get("metrics", manifest.get("metrics", {}))}
|
|
424
|
+
snapshot = ContextSnapshot(self.root, manifest, data)
|
|
425
|
+
if expected_branch_id is not None and snapshot.branch_id != expected_branch_id:
|
|
426
|
+
raise RuntimeError("DSH context snapshot branch mismatch")
|
|
427
|
+
return snapshot
|
|
428
|
+
|
|
429
|
+
def entries(self, *args: Any, **kwargs: Any) -> ContextSelection:
|
|
430
|
+
return self.snapshot().entries(*args, **kwargs)
|
|
431
|
+
|
|
432
|
+
def messages(self, *args: Any, **kwargs: Any) -> ContextSelection:
|
|
433
|
+
return self.snapshot().messages(*args, **kwargs)
|
|
434
|
+
|
|
435
|
+
def search(self, *args: Any, **kwargs: Any) -> ContextSelection:
|
|
436
|
+
return self.snapshot().search(*args, **kwargs)
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def metrics(self) -> dict[str, Any]:
|
|
440
|
+
return dict(self.snapshot().metrics)
|
|
441
|
+
|
|
442
|
+
def artifact(self, value: Any, label: str = "context") -> ContextArtifact:
|
|
443
|
+
if not isinstance(label, str) or not label.strip():
|
|
444
|
+
raise ValueError("label must be a non-empty string")
|
|
445
|
+
body = f"# {label.strip()}\n\n{_render(value)}\n"
|
|
446
|
+
raw = body.encode("utf-8")
|
|
447
|
+
if len(raw) > _MAX_ARTIFACT_BYTES:
|
|
448
|
+
raise ValueError(f"artifact exceeds {_MAX_ARTIFACT_BYTES} bytes")
|
|
449
|
+
digest = sha256(raw).hexdigest()
|
|
450
|
+
directory = self.root / "artifacts"
|
|
451
|
+
_private_directory(directory)
|
|
452
|
+
path = directory / f"{digest}.md"
|
|
453
|
+
if path.exists() or path.is_symlink():
|
|
454
|
+
if path.is_symlink() or not path.is_file() or sha256(path.read_bytes()).hexdigest() != digest:
|
|
455
|
+
raise RuntimeError(f"unsafe existing context artifact: {path}")
|
|
456
|
+
else:
|
|
457
|
+
_exclusive_write(path, raw)
|
|
458
|
+
return ContextArtifact(path=path, digest=digest, bytes=len(raw))
|
|
459
|
+
|
|
460
|
+
def inject(self, value: Any, label: str = "Selected DSH context", max_bytes: int = _MAX_INJECT_BYTES) -> str:
|
|
461
|
+
"""Return bounded context text; print/return it so Prime logs the IPython result."""
|
|
462
|
+
if not isinstance(label, str) or not label.strip():
|
|
463
|
+
raise ValueError("label must be a non-empty string")
|
|
464
|
+
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0 or max_bytes > _MAX_INJECT_BYTES:
|
|
465
|
+
raise ValueError(f"max_bytes must be between 1 and {_MAX_INJECT_BYTES}")
|
|
466
|
+
rendered = _crop_utf8(_render(value), max_bytes)
|
|
467
|
+
return f"<dsh-context label={json.dumps(label.strip(), ensure_ascii=False)}>\n{rendered}\n</dsh-context>"
|
|
468
|
+
|
|
469
|
+
admit = inject
|
|
470
|
+
|
|
471
|
+
def grant(
|
|
472
|
+
self,
|
|
473
|
+
value: Any,
|
|
474
|
+
label: str = "Shared parent context",
|
|
475
|
+
*,
|
|
476
|
+
ttl_seconds: int = 24 * 60 * 60,
|
|
477
|
+
) -> ContextGrant:
|
|
478
|
+
"""Create a bounded read-only grant that a descendant can open by token."""
|
|
479
|
+
if not isinstance(label, str) or not label.strip():
|
|
480
|
+
raise ValueError("label must be a non-empty string")
|
|
481
|
+
if not isinstance(ttl_seconds, int) or isinstance(ttl_seconds, bool) or ttl_seconds <= 0 or ttl_seconds > _MAX_GRANT_TTL_SECONDS:
|
|
482
|
+
raise ValueError(f"ttl_seconds must be between 1 and {_MAX_GRANT_TTL_SECONDS}")
|
|
483
|
+
selection_digest = value.digest if isinstance(value, ContextSelection) else None
|
|
484
|
+
snapshot = self.snapshot(selection_digest)
|
|
485
|
+
now = int(time.time())
|
|
486
|
+
token = secrets.token_urlsafe(32)
|
|
487
|
+
record = {
|
|
488
|
+
"version": _GRANT_VERSION,
|
|
489
|
+
"token": token,
|
|
490
|
+
"label": label.strip(),
|
|
491
|
+
"sourceSessionId": snapshot.session_id,
|
|
492
|
+
"sourceBranchId": snapshot.branch_id,
|
|
493
|
+
"sourceSnapshotDigest": snapshot.digest,
|
|
494
|
+
"createdAt": now,
|
|
495
|
+
"expiresAt": now + ttl_seconds,
|
|
496
|
+
"value": _jsonable(value),
|
|
497
|
+
}
|
|
498
|
+
payload = _canonical_json(record)
|
|
499
|
+
if len(payload) > _MAX_GRANT_BYTES:
|
|
500
|
+
raise ValueError(f"context grant exceeds {_MAX_GRANT_BYTES} bytes")
|
|
501
|
+
digest = sha256(payload).hexdigest()
|
|
502
|
+
encoded = _canonical_json({**record, "digest": digest}) + b"\n"
|
|
503
|
+
directory = self.root / "grants"
|
|
504
|
+
_private_directory(directory)
|
|
505
|
+
_exclusive_write(directory / f"{token}.json", encoded)
|
|
506
|
+
return ContextGrant(token=token, digest=digest, expires_at=record["expiresAt"], label=label.strip())
|
|
507
|
+
|
|
508
|
+
def __repr__(self) -> str:
|
|
509
|
+
try:
|
|
510
|
+
return f"ContextHandle({self.snapshot()!r})"
|
|
511
|
+
except Exception as error:
|
|
512
|
+
return f"ContextHandle(unavailable={error!r})"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _grant_token(value: str) -> str:
|
|
516
|
+
prefix = "dsh-context-grant:"
|
|
517
|
+
token = value[len(prefix) :] if value.startswith(prefix) else value
|
|
518
|
+
if not _GRANT_TOKEN.fullmatch(token):
|
|
519
|
+
raise ValueError("invalid DSH context grant token")
|
|
520
|
+
return token
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def open_grant(value: str) -> GrantedContext:
|
|
524
|
+
"""Open a capability grant from this session or one of its ancestors."""
|
|
525
|
+
token = _grant_token(value)
|
|
526
|
+
for ancestor in (_session_dir(), *_session_dir().parents):
|
|
527
|
+
candidate = ancestor / "dsh-context" / "grants" / f"{token}.json"
|
|
528
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
529
|
+
continue
|
|
530
|
+
if candidate.is_symlink() or not candidate.is_file():
|
|
531
|
+
raise RuntimeError(f"unsafe DSH context grant path: {candidate}")
|
|
532
|
+
record = _read_json(candidate)
|
|
533
|
+
if record.get("version") != _GRANT_VERSION or record.get("token") != token:
|
|
534
|
+
raise RuntimeError("invalid DSH context grant")
|
|
535
|
+
supplied = record.get("digest")
|
|
536
|
+
unsigned = {key: item for key, item in record.items() if key != "digest"}
|
|
537
|
+
if not isinstance(supplied, str) or sha256(_canonical_json(unsigned)).hexdigest() != supplied:
|
|
538
|
+
raise RuntimeError("DSH context grant digest mismatch")
|
|
539
|
+
expires_at = record.get("expiresAt")
|
|
540
|
+
if not isinstance(expires_at, int) or expires_at < int(time.time()):
|
|
541
|
+
raise RuntimeError("DSH context grant has expired")
|
|
542
|
+
required = ("label", "sourceSessionId", "sourceBranchId", "sourceSnapshotDigest", "createdAt")
|
|
543
|
+
if any(not isinstance(record.get(key), (str if key != "createdAt" else int)) for key in required):
|
|
544
|
+
raise RuntimeError("invalid DSH context grant fields")
|
|
545
|
+
return GrantedContext(
|
|
546
|
+
token=token,
|
|
547
|
+
label=record["label"],
|
|
548
|
+
source_session_id=record["sourceSessionId"],
|
|
549
|
+
source_branch_id=record["sourceBranchId"],
|
|
550
|
+
source_snapshot_digest=record["sourceSnapshotDigest"],
|
|
551
|
+
created_at=record["createdAt"],
|
|
552
|
+
expires_at=expires_at,
|
|
553
|
+
value=record.get("value"),
|
|
554
|
+
)
|
|
555
|
+
raise FileNotFoundError(f"DSH context grant {token!r} is not reachable from this session")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def current() -> ContextHandle:
|
|
559
|
+
"""Return the current Prime session's lazy DSH context handle."""
|
|
560
|
+
return ContextHandle(_private_root())
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
__all__ = [
|
|
564
|
+
"ContextArtifact",
|
|
565
|
+
"ContextGrant",
|
|
566
|
+
"ContextHandle",
|
|
567
|
+
"ContextItem",
|
|
568
|
+
"ContextSelection",
|
|
569
|
+
"ContextSnapshot",
|
|
570
|
+
"GrantedContext",
|
|
571
|
+
"current",
|
|
572
|
+
"open_grant",
|
|
573
|
+
]
|