capsule-engine 0.1.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.
- capsule_engine/__init__.py +17 -0
- capsule_engine/bundle_viewer/__init__.py +21 -0
- capsule_engine/bundle_viewer/static/offline_shell.html +1509 -0
- capsule_engine/bundle_viewer/viewer.py +57 -0
- capsule_engine/cli/__init__.py +5 -0
- capsule_engine/cli/bundle_cmd.py +269 -0
- capsule_engine/cli/console_cmd.py +70 -0
- capsule_engine/cli/constraints_cmd.py +74 -0
- capsule_engine/cli/format.py +173 -0
- capsule_engine/cli/guard_cmds.py +361 -0
- capsule_engine/cli/init_cmds.py +169 -0
- capsule_engine/cli/main.py +120 -0
- capsule_engine/cli/packs_cmds.py +96 -0
- capsule_engine/cli/telemetry_cmd.py +80 -0
- capsule_engine/cli/tenant_cmds.py +199 -0
- capsule_engine/cli/verify_cmd.py +110 -0
- capsule_engine/compiler/__init__.py +13 -0
- capsule_engine/compiler/effect_model.py +79 -0
- capsule_engine/compiler/vocabulary.py +253 -0
- capsule_engine/console/__init__.py +10 -0
- capsule_engine/console/api.py +251 -0
- capsule_engine/console/components.css +406 -0
- capsule_engine/console/console.css +130 -0
- capsule_engine/console/console.html +51 -0
- capsule_engine/console/console.js +330 -0
- capsule_engine/console/gallery.html +274 -0
- capsule_engine/console/server.py +137 -0
- capsule_engine/console/tokens.css +68 -0
- capsule_engine/conversation/__init__.py +88 -0
- capsule_engine/conversation/capsules.py +268 -0
- capsule_engine/conversation/exchange.py +279 -0
- capsule_engine/conversation/merkle.py +80 -0
- capsule_engine/conversation/session.py +101 -0
- capsule_engine/envcompat.py +15 -0
- capsule_engine/events/__init__.py +14 -0
- capsule_engine/events/capsule.py +133 -0
- capsule_engine/examples/__init__.py +9 -0
- capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
- capsule_engine/examples/data/tau2_airline/PROVENANCE.json +70 -0
- capsule_engine/examples/data/tau2_airline/hand_labels.json +893 -0
- capsule_engine/examples/data/tau2_airline/pilot1_live_gemini-2-5-flash_airline_shift1.jsonl +190 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_claude-3-7-sonnet_airline_trial0.jsonl +556 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1-mini_airline_trial0.jsonl +480 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_gpt-4-1_airline_trial0.jsonl +496 -0
- capsule_engine/examples/data/tau2_airline/tau2_committed_o4-mini_airline_trial0.jsonl +392 -0
- capsule_engine/examples/data/tau2_airline/tau2_conversations_claude-3-7-sonnet_airline_4trials.jsonl +200 -0
- capsule_engine/examples/tau2_airline_reference.py +460 -0
- capsule_engine/examples/tau2_conversation_exchange.py +92 -0
- capsule_engine/folds/__init__.py +104 -0
- capsule_engine/folds/account_core.py +89 -0
- capsule_engine/folds/approval_latency.py +86 -0
- capsule_engine/folds/catalog.py +79 -0
- capsule_engine/folds/catalog_defs/actions.count_by_developer.yaml +10 -0
- capsule_engine/folds/catalog_defs/actions.executed_count.yaml +16 -0
- capsule_engine/folds/catalog_defs/actions.last_decision.yaml +12 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.v2.yaml +34 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.v3.yaml +38 -0
- capsule_engine/folds/catalog_defs/counterparty.seen_before.yaml +28 -0
- capsule_engine/folds/catalog_defs/disclosure.class_seen.yaml +29 -0
- capsule_engine/folds/catalog_defs/disclosure.statement_seen.yaml +32 -0
- capsule_engine/folds/catalog_defs/hold.active_exposure.yaml +44 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v2.yaml +38 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v3.1.yaml +71 -0
- capsule_engine/folds/catalog_defs/spend.weekly.v3.yaml +76 -0
- capsule_engine/folds/catalog_defs/spend.weekly.yaml +33 -0
- capsule_engine/folds/counterparty_signals.py +179 -0
- capsule_engine/folds/definition.py +421 -0
- capsule_engine/folds/duration.py +16 -0
- capsule_engine/folds/engine.py +436 -0
- capsule_engine/folds/errors.py +44 -0
- capsule_engine/folds/loader.py +24 -0
- capsule_engine/folds/ordering.py +143 -0
- capsule_engine/folds/paths.py +25 -0
- capsule_engine/folds/record_type_coverage.py +58 -0
- capsule_engine/folds/reducers.py +75 -0
- capsule_engine/folds/retention_continuity.py +117 -0
- capsule_engine/folds/set_membership.py +59 -0
- capsule_engine/folds/taxonomy.py +231 -0
- capsule_engine/guards/__init__.py +59 -0
- capsule_engine/guards/action.py +446 -0
- capsule_engine/guards/action_taxonomy.json +27 -0
- capsule_engine/guards/action_taxonomy_v2.json +27 -0
- capsule_engine/guards/action_taxonomy_v3.json +27 -0
- capsule_engine/guards/action_taxonomy_v4.json +27 -0
- capsule_engine/guards/action_taxonomy_v5.json +27 -0
- capsule_engine/guards/capsule.py +377 -0
- capsule_engine/guards/checks/__init__.py +268 -0
- capsule_engine/guards/checks/action_class_gate.py +199 -0
- capsule_engine/guards/checks/base.py +33 -0
- capsule_engine/guards/checks/caps.py +517 -0
- capsule_engine/guards/checks/channel_change.py +64 -0
- capsule_engine/guards/checks/commitment.py +41 -0
- capsule_engine/guards/checks/counterparty_identity_change.py +95 -0
- capsule_engine/guards/checks/counterparty_list.py +176 -0
- capsule_engine/guards/checks/counterparty_seen_before.py +160 -0
- capsule_engine/guards/checks/credential_pattern.py +73 -0
- capsule_engine/guards/checks/dedupe.py +259 -0
- capsule_engine/guards/checks/destination_rail.py +120 -0
- capsule_engine/guards/checks/field_change_count.py +100 -0
- capsule_engine/guards/checks/offer_expiry.py +134 -0
- capsule_engine/guards/checks/plan_containment.py +180 -0
- capsule_engine/guards/checks/price_floor.py +211 -0
- capsule_engine/guards/checks/promise_never.py +236 -0
- capsule_engine/guards/checks/promise_requires_approval.py +214 -0
- capsule_engine/guards/checks/recipient_role.py +117 -0
- capsule_engine/guards/checks/recurring_charge.py +49 -0
- capsule_engine/guards/checks/refundability.py +46 -0
- capsule_engine/guards/checks/release_on_acceptance.py +286 -0
- capsule_engine/guards/checks/required_disclosure.py +233 -0
- capsule_engine/guards/checks/single_commitment.py +221 -0
- capsule_engine/guards/checks/task_authority.py +182 -0
- capsule_engine/guards/checks/upfront_amount.py +71 -0
- capsule_engine/guards/checks/verify_before_dispatch.py +75 -0
- capsule_engine/guards/classes.py +393 -0
- capsule_engine/guards/engine.py +926 -0
- capsule_engine/guards/history_state.py +127 -0
- capsule_engine/guards/plan.py +237 -0
- capsule_engine/guards/revocation.py +120 -0
- capsule_engine/guards/signing.py +60 -0
- capsule_engine/guards/statements.py +62 -0
- capsule_engine/guards/tool_call.py +83 -0
- capsule_engine/guards/wickets/__init__.py +23 -0
- capsule_engine/guards/wickets/catalog.py +75 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.1.yaml +38 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.v1.2.yaml +52 -0
- capsule_engine/guards/wickets/catalog_defs/action_class_gate.yaml +34 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v2.yaml +30 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v3.yaml +49 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v4.yaml +45 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v5.1.yaml +53 -0
- capsule_engine/guards/wickets/catalog_defs/caps.v5.yaml +43 -0
- capsule_engine/guards/wickets/catalog_defs/caps.yaml +17 -0
- capsule_engine/guards/wickets/catalog_defs/caps_holds.yaml +18 -0
- capsule_engine/guards/wickets/catalog_defs/channel_change.v1.1.yaml +18 -0
- capsule_engine/guards/wickets/catalog_defs/channel_change.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_identity_change.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_list.yaml +20 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v2.yaml +15 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v3.yaml +23 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.v4.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/counterparty_seen_before.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/credential_pattern.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/dedupe.yaml +19 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.v1.1.yaml +32 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.seller.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/destination_rail.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/hold_reconcile.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/material_fields_changed.v1.1.yaml +22 -0
- capsule_engine/guards/wickets/catalog_defs/material_fields_changed.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/offer_expiry.v1.1.yaml +31 -0
- capsule_engine/guards/wickets/catalog_defs/offer_expiry.yaml +24 -0
- capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.v1.1.yaml +22 -0
- capsule_engine/guards/wickets/catalog_defs/offer_fields_changed.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/price_floor.yaml +32 -0
- capsule_engine/guards/wickets/catalog_defs/promise_never.v1.1.yaml +64 -0
- capsule_engine/guards/wickets/catalog_defs/promise_never.yaml +16 -0
- capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.v1.1.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/promise_requires_approval.yaml +21 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.1.yaml +23 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.v1.2.yaml +24 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.seller.yaml +10 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.v1.1.yaml +26 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_role.yaml +11 -0
- capsule_engine/guards/wickets/catalog_defs/recipient_seen_before.yaml +16 -0
- capsule_engine/guards/wickets/catalog_defs/recurring_charge.yaml +9 -0
- capsule_engine/guards/wickets/catalog_defs/refundability.v1.1.yaml +15 -0
- capsule_engine/guards/wickets/catalog_defs/refundability.yaml +7 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.1.yaml +44 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.v1.2.yaml +63 -0
- capsule_engine/guards/wickets/catalog_defs/release_on_acceptance.seller.yaml +36 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.1.yaml +36 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.v1.2.yaml +42 -0
- capsule_engine/guards/wickets/catalog_defs/required_disclosure.yaml +25 -0
- capsule_engine/guards/wickets/catalog_defs/single_commitment.seller.yaml +14 -0
- capsule_engine/guards/wickets/catalog_defs/task_authority.v1.1.yaml +20 -0
- capsule_engine/guards/wickets/catalog_defs/task_authority.yaml +8 -0
- capsule_engine/guards/wickets/catalog_defs/upfront_amount.v1.1.yaml +19 -0
- capsule_engine/guards/wickets/catalog_defs/upfront_amount.yaml +11 -0
- capsule_engine/guards/wickets/catalog_defs/verify_before_dispatch.yaml +9 -0
- capsule_engine/guards/wickets/definition.py +164 -0
- capsule_engine/guards/wickets/errors.py +18 -0
- capsule_engine/guards/wickets/loader.py +26 -0
- capsule_engine/guards/wickets/retired.py +62 -0
- capsule_engine/holds/__init__.py +35 -0
- capsule_engine/holds/capsules.py +266 -0
- capsule_engine/holds/engine.py +476 -0
- capsule_engine/holds/errors.py +29 -0
- capsule_engine/holds/policy.py +62 -0
- capsule_engine/holds/scope.py +47 -0
- capsule_engine/mcp/__init__.py +16 -0
- capsule_engine/mcp/config.py +117 -0
- capsule_engine/mcp/server.py +338 -0
- capsule_engine/mcp/tools.py +424 -0
- capsule_engine/packaging.py +60 -0
- capsule_engine/packs/__init__.py +45 -0
- capsule_engine/packs/backfill_coverage.py +238 -0
- capsule_engine/packs/catalog/airline-engagement/AI-BOOTSTRAP.md +53 -0
- capsule_engine/packs/catalog/airline-engagement/folds/session_close.yaml +8 -0
- capsule_engine/packs/catalog/airline-engagement/pack.yaml +146 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/good_execution.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/oversight_declared.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/tampered_execution.json +36 -0
- capsule_engine/packs/catalog/eu-ai-act/fixtures/trace/trusted_issuer.jwk.json +5 -0
- capsule_engine/packs/catalog/eu-ai-act/obligations-pack-note.md +68 -0
- capsule_engine/packs/catalog/eu-ai-act/obligations-register.yaml +761 -0
- capsule_engine/packs/catalog/eu-ai-act/pack.yaml +88 -0
- capsule_engine/packs/catalog/eu-ai-act/register.yaml +96 -0
- capsule_engine/packs/catalog/eu-ai-act-deterministic/fixtures/synthetic_internal_assist_sample.jsonl +6 -0
- capsule_engine/packs/catalog/eu-ai-act-deterministic/pack.yaml +580 -0
- capsule_engine/packs/catalog/everyday/AI-BOOTSTRAP.md +194 -0
- capsule_engine/packs/catalog/everyday/fixtures/mini_ledger.jsonl +45 -0
- capsule_engine/packs/catalog/everyday/pack.yaml +480 -0
- capsule_engine/packs/catalog/payments-safety/AI-BOOTSTRAP.md +83 -0
- capsule_engine/packs/catalog/payments-safety/fixtures/mini_ledger.jsonl +8 -0
- capsule_engine/packs/catalog/payments-safety/folds/spend_weekly.yaml +29 -0
- capsule_engine/packs/catalog/payments-safety/pack.yaml +112 -0
- capsule_engine/packs/catalog/seller/fixtures/mini_ledger.jsonl +83 -0
- capsule_engine/packs/catalog/seller/pack.yaml +503 -0
- capsule_engine/packs/catalog/standard-vendor/AI-BOOTSTRAP.md +70 -0
- capsule_engine/packs/catalog/standard-vendor/folds/session_close.yaml +8 -0
- capsule_engine/packs/catalog/standard-vendor/pack.yaml +466 -0
- capsule_engine/packs/contract_diff.py +809 -0
- capsule_engine/packs/contract_validate.py +185 -0
- capsule_engine/packs/corpus_verify.py +126 -0
- capsule_engine/packs/enforce.py +81 -0
- capsule_engine/packs/errors.py +159 -0
- capsule_engine/packs/eu_ai_act_trace_field_map.py +46 -0
- capsule_engine/packs/install.py +266 -0
- capsule_engine/packs/loader.py +1462 -0
- capsule_engine/packs/measurability_report.py +280 -0
- capsule_engine/packs/obligation_results.py +72 -0
- capsule_engine/packs/pins.py +92 -0
- capsule_engine/packs/schema.py +1053 -0
- capsule_engine/packs/thresholds.py +146 -0
- capsule_engine/packs/trace_attainment.py +188 -0
- capsule_engine/packs/trace_reader.py +104 -0
- capsule_engine/policy/__init__.py +37 -0
- capsule_engine/policy/activation.py +87 -0
- capsule_engine/policy/catalog_defs/default.yaml +23 -0
- capsule_engine/policy/catalog_defs/holds.yaml +27 -0
- capsule_engine/policy/errors.py +46 -0
- capsule_engine/policy/limits.py +287 -0
- capsule_engine/policy/loader.py +26 -0
- capsule_engine/policy/manifest.py +289 -0
- capsule_engine/policy/profile.py +281 -0
- capsule_engine/policy/resolve.py +224 -0
- capsule_engine/register/__init__.py +39 -0
- capsule_engine/register/compiler.py +219 -0
- capsule_engine/register/errors.py +40 -0
- capsule_engine/register/loader.py +247 -0
- capsule_engine/register/schema.py +129 -0
- capsule_engine/registry/__init__.py +19 -0
- capsule_engine/registry/conventions.json +162 -0
- capsule_engine/registry/conventions.py +188 -0
- capsule_engine/registry/cpb_registry.json +78 -0
- capsule_engine/report/__init__.py +62 -0
- capsule_engine/report/build.py +267 -0
- capsule_engine/report/coverage.py +777 -0
- capsule_engine/report/errors.py +35 -0
- capsule_engine/report/live_check.py +126 -0
- capsule_engine/report/live_history.py +420 -0
- capsule_engine/report/model.py +95 -0
- capsule_engine/report/render.py +467 -0
- capsule_engine/report/replay.py +1677 -0
- capsule_engine/report/result.py +504 -0
- capsule_engine/report/result_from_folds.py +222 -0
- capsule_engine/report/result_from_layers.py +670 -0
- capsule_engine/report/sale_bundle.py +864 -0
- capsule_engine/report/static/verify.js +451 -0
- capsule_engine/schemas/evidence-contract-v0.json +383 -0
- capsule_engine/schemas/vendor/epistemic-types.json +21 -0
- capsule_engine/schemas/vendor/evidence-result-v0.json +463 -0
- capsule_engine/telemetry/__init__.py +9 -0
- capsule_engine/telemetry/consent.py +39 -0
- capsule_engine/telemetry/events.py +93 -0
- capsule_engine/telemetry/fixtures/synthetic_events.jsonl +45 -0
- capsule_engine/telemetry/funnel.py +147 -0
- capsule_engine/telemetry/record.py +106 -0
- capsule_engine/telemetry/sink.py +61 -0
- capsule_engine/telemetry/state.py +60 -0
- capsule_engine/tenants.py +327 -0
- capsule_engine-0.1.0.dist-info/METADATA +47 -0
- capsule_engine-0.1.0.dist-info/RECORD +288 -0
- capsule_engine-0.1.0.dist-info/WHEEL +5 -0
- capsule_engine-0.1.0.dist-info/entry_points.txt +2 -0
- capsule_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- capsule_engine-0.1.0.dist-info/licenses/capsule_engine/examples/data/tau2_airline/LICENSE-tau2-bench +21 -0
- capsule_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`capsule console`'s local HTTP server.
|
|
3
|
+
|
|
4
|
+
stdlib `http.server` only -- no new dependency (see pyproject.toml).
|
|
5
|
+
Single-threaded on purpose: `LedgerStore`'s sqlite3 connection is opened
|
|
6
|
+
once at server startup and is not safe to share across request threads, and
|
|
7
|
+
a local, single-operator console has no concurrency requirement that would
|
|
8
|
+
justify the complication. Binds to localhost by default.
|
|
9
|
+
|
|
10
|
+
Serves the static console UI -- this directory's own `tokens.css` /
|
|
11
|
+
`components.css` (the same component-library files the gallery uses, never
|
|
12
|
+
re-invented here) plus `console.css` / `console.js` / `console.html` -- and
|
|
13
|
+
a small JSON API over the real `LedgerAPI` (`api.py` in this package).
|
|
14
|
+
|
|
15
|
+
LOCAL ONLY: no hosted anything, no account system. Every response is
|
|
16
|
+
derived live from the ledger on disk; nothing here calls out to a network.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import shutil
|
|
22
|
+
import tempfile
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from cll.ledger.store import LedgerStore
|
|
28
|
+
|
|
29
|
+
from . import api
|
|
30
|
+
|
|
31
|
+
__all__ = ["ConsoleServer", "build_server"]
|
|
32
|
+
|
|
33
|
+
_STATIC_DIR = Path(__file__).resolve().parent
|
|
34
|
+
_STATIC_FILES = {
|
|
35
|
+
"/": ("console.html", "text/html; charset=utf-8"),
|
|
36
|
+
"/console.html": ("console.html", "text/html; charset=utf-8"),
|
|
37
|
+
"/console.css": ("console.css", "text/css; charset=utf-8"),
|
|
38
|
+
"/console.js": ("console.js", "application/javascript; charset=utf-8"),
|
|
39
|
+
"/tokens.css": ("tokens.css", "text/css; charset=utf-8"),
|
|
40
|
+
"/components.css": ("components.css", "text/css; charset=utf-8"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConsoleRequestHandler(BaseHTTPRequestHandler):
|
|
45
|
+
server_version = "CapsuleConsole/0.1"
|
|
46
|
+
|
|
47
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
48
|
+
pass # the CLI already echoed the one line that matters at startup
|
|
49
|
+
|
|
50
|
+
def _send_json(self, payload: dict, status: int = 200) -> None:
|
|
51
|
+
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
52
|
+
self.send_response(status)
|
|
53
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
54
|
+
self.send_header("Content-Length", str(len(body)))
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
|
|
58
|
+
def _send_static(self, filename: str, content_type: str) -> None:
|
|
59
|
+
body = (_STATIC_DIR / filename).read_bytes()
|
|
60
|
+
self.send_response(200)
|
|
61
|
+
self.send_header("Content-Type", content_type)
|
|
62
|
+
self.send_header("Content-Length", str(len(body)))
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.wfile.write(body)
|
|
65
|
+
|
|
66
|
+
def do_GET(self) -> None: # noqa: N802 (stdlib method name)
|
|
67
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
68
|
+
path = parsed.path
|
|
69
|
+
query = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
|
|
70
|
+
store: LedgerStore = self.server.store
|
|
71
|
+
|
|
72
|
+
if path in _STATIC_FILES:
|
|
73
|
+
filename, content_type = _STATIC_FILES[path]
|
|
74
|
+
self._send_static(filename, content_type)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if path == "/api/checkpoint":
|
|
78
|
+
self._send_json(api.checkpoint_status(store))
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if path == "/api/records":
|
|
82
|
+
filters = {
|
|
83
|
+
"agent": query.get("agent") or None,
|
|
84
|
+
"since": query.get("since") or None,
|
|
85
|
+
"until": query.get("until") or None,
|
|
86
|
+
"counterparty": query.get("counterparty") or None,
|
|
87
|
+
"verdict": query.get("verdict") or None,
|
|
88
|
+
"action_type": query.get("action_type") or None,
|
|
89
|
+
"limit": int(query["limit"]) if query.get("limit") else None,
|
|
90
|
+
}
|
|
91
|
+
self._send_json(api.list_records(store, filters))
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if path.startswith("/api/records/"):
|
|
95
|
+
capsule_id = urllib.parse.unquote(path[len("/api/records/") :])
|
|
96
|
+
detail = api.record_detail(store, capsule_id)
|
|
97
|
+
if detail is None:
|
|
98
|
+
self._send_json({"error": f"no such capsule {capsule_id!r}"}, status=404)
|
|
99
|
+
return
|
|
100
|
+
self._send_json(detail)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self.send_error(404, "not found")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ConsoleServer(HTTPServer):
|
|
107
|
+
"""Single-threaded (see module docstring). Owns the `LedgerStore` it was
|
|
108
|
+
built with and any throwaway import directory, closing/removing both on
|
|
109
|
+
`server_close()`."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, server_address: tuple[str, int], store: LedgerStore, cleanup_dir: Path | None = None):
|
|
112
|
+
super().__init__(server_address, ConsoleRequestHandler)
|
|
113
|
+
self.store = store
|
|
114
|
+
self._cleanup_dir = cleanup_dir
|
|
115
|
+
|
|
116
|
+
def server_close(self) -> None:
|
|
117
|
+
super().server_close()
|
|
118
|
+
self.store.close()
|
|
119
|
+
if self._cleanup_dir is not None:
|
|
120
|
+
shutil.rmtree(self._cleanup_dir, ignore_errors=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_server(ledger_path: str, host: str = "127.0.0.1", port: int = 8420) -> ConsoleServer:
|
|
124
|
+
"""Open `ledger_path` (a `LedgerStore` directory, or a JSONL fixture
|
|
125
|
+
imported once into a throwaway store -- the same convenience every
|
|
126
|
+
other ledger-backed verb offers via `cli/ledger_io.py`) and bind a
|
|
127
|
+
server to it. Does not start serving -- call `.serve_forever()`."""
|
|
128
|
+
p = Path(ledger_path)
|
|
129
|
+
if p.is_dir():
|
|
130
|
+
store = LedgerStore(p)
|
|
131
|
+
cleanup_dir = None
|
|
132
|
+
else:
|
|
133
|
+
cleanup_dir = Path(tempfile.mkdtemp(prefix="capsule-ledger-console-"))
|
|
134
|
+
store = LedgerStore(cleanup_dir)
|
|
135
|
+
store.import_jsonl(p)
|
|
136
|
+
|
|
137
|
+
return ConsoleServer((host, port), store=store, cleanup_dir=cleanup_dir)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
*
|
|
3
|
+
* Design tokens for the capsule-ledger console, transcribed verbatim from
|
|
4
|
+
* its visual language spec (spec v1 · 2026-08-04, capsule format draft-02).
|
|
5
|
+
* Values are copied from that spec's inline styles, not approximated from a
|
|
6
|
+
* summary.
|
|
7
|
+
*
|
|
8
|
+
* No grays: every neutral in this system is warm (cream/bark/sand ladder).
|
|
9
|
+
* --asg-brick below is reserved for verification failure ONLY -- see
|
|
10
|
+
* components.css and the product-law tests in tests/test_console_*.py.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
/* -- color: surfaces -- */
|
|
15
|
+
--asg-cream: #FAF7F2; /* default page bg (GC/light surfaces) */
|
|
16
|
+
--asg-warm-white: #FDF9F4; /* cards */
|
|
17
|
+
--asg-desk: #EFE9DF; /* page bg behind floating cards */
|
|
18
|
+
--asg-bark: #2C1A0E; /* dark surface, primary text on light */
|
|
19
|
+
--asg-console-bg: #231409; /* dark console page bg */
|
|
20
|
+
|
|
21
|
+
/* -- color: accent -- */
|
|
22
|
+
--asg-amber: #C8611A; /* THE accent: CTAs, eyebrows, model-class marks */
|
|
23
|
+
--asg-amber-light: #E8844A; /* hover, amber-on-dark */
|
|
24
|
+
|
|
25
|
+
/* -- color: sage (deterministic class, refusals, positive signals) -- */
|
|
26
|
+
--asg-sage: #7A8C6E;
|
|
27
|
+
--asg-sage-light: #A8B89C; /* on dark */
|
|
28
|
+
|
|
29
|
+
/* -- color: sand (hairlines/borders) -- */
|
|
30
|
+
--asg-sand: #E8D5B8; /* hairlines/borders on light */
|
|
31
|
+
--asg-sand-dark: #D4BC96;
|
|
32
|
+
|
|
33
|
+
/* -- color: text -- */
|
|
34
|
+
--asg-text-body: #3D2B1F;
|
|
35
|
+
--asg-text-muted: #7A6355; /* muted text (light) */
|
|
36
|
+
--asg-text-dark-1: #E8D5B8; /* text ladder on dark, brightest */
|
|
37
|
+
--asg-text-dark-2: #D4BC96;
|
|
38
|
+
--asg-text-dark-3: #A8927D; /* text ladder on dark, dimmest */
|
|
39
|
+
|
|
40
|
+
/* -- color: brick -- verification failure ONLY. Nothing else may use it. */
|
|
41
|
+
--asg-brick: #A93F2B;
|
|
42
|
+
|
|
43
|
+
/* -- typography -- */
|
|
44
|
+
--asg-font-display: 'Playfair Display', ui-serif, Georgia, serif;
|
|
45
|
+
--asg-font-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
|
|
46
|
+
--asg-font-mono: 'DM Mono', ui-monospace, 'SF Mono', Consolas, monospace;
|
|
47
|
+
|
|
48
|
+
/* eyebrows: DM Sans 500/600, uppercase, letter-spacing 1.5-2.5px, 10-11px */
|
|
49
|
+
--asg-eyebrow-tracking: 2px;
|
|
50
|
+
--asg-eyebrow-size: 11px;
|
|
51
|
+
|
|
52
|
+
/* -- radii -- */
|
|
53
|
+
--asg-radius-pill: 100px;
|
|
54
|
+
--asg-radius-card-sm: 12px;
|
|
55
|
+
--asg-radius-card-lg: 16px;
|
|
56
|
+
--asg-radius-floating: 20px;
|
|
57
|
+
--asg-radius-chip: 6px; /* 4-9px squares for class marks */
|
|
58
|
+
|
|
59
|
+
/* -- shadows: bark-tinted only -- */
|
|
60
|
+
--asg-shadow-floating-dark: 0 24px 64px rgba(44, 26, 14, 0.22);
|
|
61
|
+
--asg-shadow-floating-light: 0 24px 64px rgba(44, 26, 14, 0.10);
|
|
62
|
+
--asg-shadow-amber-button: 0 4px 16px rgba(200, 97, 26, 0.30);
|
|
63
|
+
|
|
64
|
+
/* -- motion -- */
|
|
65
|
+
--asg-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
66
|
+
--asg-motion-hover: 200ms;
|
|
67
|
+
--asg-motion-msg-in: 400ms;
|
|
68
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The interim conversation-capsule profile (pre-VAC).
|
|
3
|
+
|
|
4
|
+
Per-turn ``fyi`` capsules sealed AT TURN
|
|
5
|
+
TIME (speaker role, turn-content digest -- never the content itself),
|
|
6
|
+
chained turn-to-turn so there is no unsigned window between a turn
|
|
7
|
+
happening and the ledger holding a signed record of it, plus one
|
|
8
|
+
session-close capsule binding every turn's digest into a single session
|
|
9
|
+
digest (Merkle-style, via ``capsule_emit.checkpoint`` -- the neutral CLL/MMR
|
|
10
|
+
core capsule-ledger consumes rather than forks) for selective disclosure at
|
|
11
|
+
TURN granularity.
|
|
12
|
+
|
|
13
|
+
This is the interim, ledger-native shape -- it migrates to Verifiable
|
|
14
|
+
Agent Conversations (Birkholz et al., COSE_Sign1 session records) once that
|
|
15
|
+
cross-party coordination lands; the no-unsigned-window property this module
|
|
16
|
+
establishes is itself a candidate contribution back to that draft, which
|
|
17
|
+
today has no equivalent guarantee.
|
|
18
|
+
|
|
19
|
+
Per-speaker judgment targeting (B3, the judge harness) reads
|
|
20
|
+
``speaker_role`` off these turn capsules -- ``SPEAKER_ROLES`` is the
|
|
21
|
+
complete, closed vocabulary a judge may target.
|
|
22
|
+
|
|
23
|
+
Public surface:
|
|
24
|
+
- ``build_turn_capsule`` / ``build_session_close_capsule`` -- pure capsule
|
|
25
|
+
builders (no ledger I/O), same layering as ``guards.capsule``.
|
|
26
|
+
- ``build_turn_reference_capsule`` -- a typed cross-reference from a turn to
|
|
27
|
+
capsule(s) it gave rise to (e.g. a caller's own tool-call capsules),
|
|
28
|
+
resolved back via ``find_turn_reference``.
|
|
29
|
+
- ``find_session_turns`` / ``find_session_close`` / ``find_turn_reference``
|
|
30
|
+
-- read a session (or a cross-reference) back off any ``LedgerAPI``.
|
|
31
|
+
- ``session_root`` / ``turn_inclusion_proof`` / ``verify_turn_inclusion`` --
|
|
32
|
+
the Merkle session-digest primitives (``.merkle``).
|
|
33
|
+
- ``ConversationSession`` -- the stateful recorder: seals and appends each
|
|
34
|
+
turn immediately, then ``close()``s the session.
|
|
35
|
+
- ``build_conversation_exchange_capsule`` / ``digest_conversation_exchange``
|
|
36
|
+
-- one capsule per exchange, in the mesh inference capsule shape
|
|
37
|
+
(``model_attestation.compute_attestation`` with ``agent_input_digest``/
|
|
38
|
+
``agent_output_digest`` plus the labeled ``reasoning_digest``/
|
|
39
|
+
``tool_calls_digest`` sub-digests, ``.exchange``).
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from .capsules import (
|
|
44
|
+
EVENT_CONVERSATION_TURN,
|
|
45
|
+
EVENT_SESSION_CLOSE,
|
|
46
|
+
EVENT_TURN_REFERENCE,
|
|
47
|
+
SPEAKER_ROLES,
|
|
48
|
+
InvalidSpeakerRole,
|
|
49
|
+
build_session_close_capsule,
|
|
50
|
+
build_turn_capsule,
|
|
51
|
+
build_turn_reference_capsule,
|
|
52
|
+
find_session_close,
|
|
53
|
+
find_session_turns,
|
|
54
|
+
find_turn_reference,
|
|
55
|
+
)
|
|
56
|
+
from .exchange import (
|
|
57
|
+
EVENT_CONVERSATION_EXCHANGE,
|
|
58
|
+
build_conversation_exchange_capsule,
|
|
59
|
+
build_usage,
|
|
60
|
+
digest_conversation_exchange,
|
|
61
|
+
stringify_floats,
|
|
62
|
+
)
|
|
63
|
+
from .merkle import session_root, turn_inclusion_proof, verify_turn_inclusion
|
|
64
|
+
from .session import ConversationSession, SessionAlreadyClosedError
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"SPEAKER_ROLES",
|
|
68
|
+
"EVENT_CONVERSATION_TURN",
|
|
69
|
+
"EVENT_SESSION_CLOSE",
|
|
70
|
+
"EVENT_TURN_REFERENCE",
|
|
71
|
+
"EVENT_CONVERSATION_EXCHANGE",
|
|
72
|
+
"InvalidSpeakerRole",
|
|
73
|
+
"build_turn_capsule",
|
|
74
|
+
"build_session_close_capsule",
|
|
75
|
+
"build_turn_reference_capsule",
|
|
76
|
+
"build_conversation_exchange_capsule",
|
|
77
|
+
"digest_conversation_exchange",
|
|
78
|
+
"build_usage",
|
|
79
|
+
"stringify_floats",
|
|
80
|
+
"find_session_turns",
|
|
81
|
+
"find_session_close",
|
|
82
|
+
"find_turn_reference",
|
|
83
|
+
"session_root",
|
|
84
|
+
"turn_inclusion_proof",
|
|
85
|
+
"verify_turn_inclusion",
|
|
86
|
+
"ConversationSession",
|
|
87
|
+
"SessionAlreadyClosedError",
|
|
88
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Builders for the conversation-capsule profile's two record types:
|
|
3
|
+
``conversation_turn`` (one per turn, sealed at turn time) and
|
|
4
|
+
``conversation_session_close`` (one per session, binding every turn's
|
|
5
|
+
digest into a single session digest).
|
|
6
|
+
|
|
7
|
+
Both are passive ``fyi`` records -- built via ``events.capsule``'s
|
|
8
|
+
``build_event_capsule``, the same mechanism this codebase already uses for
|
|
9
|
+
degradation/recovery events and policy-manifest activations
|
|
10
|
+
(``policy/activation.py``) -- so a conversation capsule is an ordinary,
|
|
11
|
+
independently verifiable ledger record with no special-cased verification
|
|
12
|
+
path, same as every other capsule this codebase produces.
|
|
13
|
+
|
|
14
|
+
Record-type identity lives in ``asg_payload.event`` (never a repurposed
|
|
15
|
+
spec-defined field, matching the workspace's extension-field rule) --
|
|
16
|
+
``EVENT_CONVERSATION_TURN`` / ``EVENT_SESSION_CLOSE`` below, discovered via
|
|
17
|
+
``find_session_turns`` / ``find_session_close``, the same linear-scan-plus-
|
|
18
|
+
filter shape ``policy/activation.py``'s ``find_latest_activation`` uses.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
|
|
25
|
+
from agent_action_capsule.contracts import is_hex64
|
|
26
|
+
from cll.ledger.api import LedgerAPI, ScanQuery
|
|
27
|
+
from cll.ledger.records import LedgerRecord
|
|
28
|
+
|
|
29
|
+
from ..events.capsule import build_event_capsule
|
|
30
|
+
from ..guards.signing import Signer
|
|
31
|
+
from .merkle import session_root
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"SPEAKER_ROLES",
|
|
35
|
+
"EVENT_CONVERSATION_TURN",
|
|
36
|
+
"EVENT_SESSION_CLOSE",
|
|
37
|
+
"EVENT_TURN_REFERENCE",
|
|
38
|
+
"InvalidSpeakerRole",
|
|
39
|
+
"build_turn_capsule",
|
|
40
|
+
"build_session_close_capsule",
|
|
41
|
+
"build_turn_reference_capsule",
|
|
42
|
+
"find_session_turns",
|
|
43
|
+
"find_session_close",
|
|
44
|
+
"find_turn_reference",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Interim speaker-role vocabulary (pre-VAC; migrates when the Birkholz VAC
|
|
48
|
+
# binding lands). Deliberately CLOSED rather than registry-open: B3's
|
|
49
|
+
# per-speaker judgment targeting depends on this exact set, so a typo'd
|
|
50
|
+
# role must fail loud at seal time, not silently land as a fourth,
|
|
51
|
+
# unaddressable role. Widen only alongside a B3 consumer that can target
|
|
52
|
+
# the new value.
|
|
53
|
+
SPEAKER_ROLES = frozenset({"user", "assistant", "human-agent"})
|
|
54
|
+
|
|
55
|
+
EVENT_CONVERSATION_TURN = "conversation_turn"
|
|
56
|
+
EVENT_SESSION_CLOSE = "conversation_session_close"
|
|
57
|
+
EVENT_TURN_REFERENCE = "conversation_turn_reference"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InvalidSpeakerRole(ValueError):
|
|
61
|
+
"""``speaker_role`` is not one of ``SPEAKER_ROLES``."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require_speaker_role(speaker_role: str) -> None:
|
|
65
|
+
if speaker_role not in SPEAKER_ROLES:
|
|
66
|
+
raise InvalidSpeakerRole(f"speaker_role must be one of {sorted(SPEAKER_ROLES)}; got {speaker_role!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_content_digest(content_digest: str) -> None:
|
|
70
|
+
# The turn's own content never enters the record in any mode (H2
|
|
71
|
+
# invariant) -- only a digest of it, same 64-hex-SHA-256 shape every
|
|
72
|
+
# other digest field in this codebase requires.
|
|
73
|
+
if not is_hex64(content_digest):
|
|
74
|
+
raise ValueError(f"content_digest must be a 64-hex SHA-256 digest; got {content_digest!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_turn_capsule(
|
|
78
|
+
*,
|
|
79
|
+
session_id: str,
|
|
80
|
+
turn_index: int,
|
|
81
|
+
speaker_role: str,
|
|
82
|
+
content_digest: str,
|
|
83
|
+
operator: str,
|
|
84
|
+
developer: str,
|
|
85
|
+
signer: Signer,
|
|
86
|
+
previous_turn_capsule_id: str | None = None,
|
|
87
|
+
timestamp: str | None = None,
|
|
88
|
+
action_id: str | None = None,
|
|
89
|
+
) -> dict:
|
|
90
|
+
"""Seal one conversation turn as a passive ``fyi`` capsule, AT TURN TIME
|
|
91
|
+
-- every turn is signed and ready to append the moment it is built, so a
|
|
92
|
+
conversation never carries an unsigned window between what was said and
|
|
93
|
+
what the ledger can prove was said.
|
|
94
|
+
|
|
95
|
+
Turn 0 opens the session standalone (no chain parent); every later turn
|
|
96
|
+
chains to ``previous_turn_capsule_id`` with relation ``"follows"`` --
|
|
97
|
+
the same generic sequencing relation capsule-emit's emit path
|
|
98
|
+
(``capsule_emit.core``) defaults to for a non-adapter caller. None of the chain-relation
|
|
99
|
+
registry's seeded values (``confirms``/``supersedes``/``epoch_opens``)
|
|
100
|
+
describe "next turn in an ongoing conversation", so this deliberately
|
|
101
|
+
does not reach for one of them.
|
|
102
|
+
"""
|
|
103
|
+
if turn_index < 0:
|
|
104
|
+
raise ValueError(f"turn_index must be >= 0; got {turn_index}")
|
|
105
|
+
if turn_index == 0:
|
|
106
|
+
if previous_turn_capsule_id is not None:
|
|
107
|
+
raise ValueError("turn_index=0 (the session's first turn) must not chain to a previous_turn_capsule_id")
|
|
108
|
+
elif previous_turn_capsule_id is None:
|
|
109
|
+
raise ValueError(f"turn_index={turn_index} requires previous_turn_capsule_id (only turn 0 is standalone)")
|
|
110
|
+
|
|
111
|
+
_require_speaker_role(speaker_role)
|
|
112
|
+
_require_content_digest(content_digest)
|
|
113
|
+
|
|
114
|
+
detail = {
|
|
115
|
+
"session_id": session_id,
|
|
116
|
+
"turn_index": turn_index,
|
|
117
|
+
"speaker_role": speaker_role,
|
|
118
|
+
"content_digest": content_digest,
|
|
119
|
+
}
|
|
120
|
+
return build_event_capsule(
|
|
121
|
+
operator=operator,
|
|
122
|
+
developer=developer,
|
|
123
|
+
signer=signer,
|
|
124
|
+
event=EVENT_CONVERSATION_TURN,
|
|
125
|
+
detail=detail,
|
|
126
|
+
timestamp=timestamp,
|
|
127
|
+
action_id=action_id or f"conversation.turn/{session_id}/{turn_index}",
|
|
128
|
+
chain_parent=previous_turn_capsule_id,
|
|
129
|
+
chain_relation="follows",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_session_close_capsule(
|
|
134
|
+
*,
|
|
135
|
+
session_id: str,
|
|
136
|
+
turn_capsule_ids: Sequence[str],
|
|
137
|
+
operator: str,
|
|
138
|
+
developer: str,
|
|
139
|
+
signer: Signer,
|
|
140
|
+
timestamp: str | None = None,
|
|
141
|
+
action_id: str | None = None,
|
|
142
|
+
) -> dict:
|
|
143
|
+
"""Bind every turn in ``turn_capsule_ids`` (append order) into one
|
|
144
|
+
session digest -- an MMR root over the ordered turn capsule ids
|
|
145
|
+
(``merkle.session_root``) -- and seal that binding as one more ``fyi``
|
|
146
|
+
capsule chained to the last turn. This is what makes selective
|
|
147
|
+
disclosure at TURN granularity possible: a holder can later disclose
|
|
148
|
+
one turn's content plus a ``merkle.turn_inclusion_proof`` against this
|
|
149
|
+
capsule's ``session_digest``, without ever revealing the rest of the
|
|
150
|
+
session.
|
|
151
|
+
|
|
152
|
+
Requires at least one turn -- there is no such thing as an empty
|
|
153
|
+
session's completeness certificate.
|
|
154
|
+
"""
|
|
155
|
+
if not turn_capsule_ids:
|
|
156
|
+
raise ValueError("turn_capsule_ids must be non-empty -- cannot close a session with zero turns")
|
|
157
|
+
|
|
158
|
+
digest = session_root(turn_capsule_ids)
|
|
159
|
+
detail = {
|
|
160
|
+
"session_id": session_id,
|
|
161
|
+
"turn_count": len(turn_capsule_ids),
|
|
162
|
+
"session_digest": digest,
|
|
163
|
+
"turn_capsule_ids": list(turn_capsule_ids),
|
|
164
|
+
}
|
|
165
|
+
return build_event_capsule(
|
|
166
|
+
operator=operator,
|
|
167
|
+
developer=developer,
|
|
168
|
+
signer=signer,
|
|
169
|
+
event=EVENT_SESSION_CLOSE,
|
|
170
|
+
detail=detail,
|
|
171
|
+
timestamp=timestamp,
|
|
172
|
+
action_id=action_id or f"conversation.session_close/{session_id}",
|
|
173
|
+
chain_parent=turn_capsule_ids[-1],
|
|
174
|
+
chain_relation="follows",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def build_turn_reference_capsule(
|
|
179
|
+
*,
|
|
180
|
+
turn_capsule_id: str,
|
|
181
|
+
referenced_capsule_ids: Sequence[str],
|
|
182
|
+
operator: str,
|
|
183
|
+
developer: str,
|
|
184
|
+
signer: Signer,
|
|
185
|
+
timestamp: str | None = None,
|
|
186
|
+
action_id: str | None = None,
|
|
187
|
+
) -> dict:
|
|
188
|
+
"""A typed cross-reference from one turn to the capsule(s) it gave rise
|
|
189
|
+
to (e.g. tool-call capsules a caller's own pipeline recorded for that
|
|
190
|
+
turn) -- built via the same ``build_event_capsule`` every other passive
|
|
191
|
+
record in this module uses, not a new mechanism.
|
|
192
|
+
|
|
193
|
+
Chains to the turn with relation ``"confirms"`` (the reference only
|
|
194
|
+
makes sense once the turn it names already exists), so a caller that
|
|
195
|
+
already has ``turn_capsule_id`` from ``ConversationSession.record_turn``
|
|
196
|
+
can call this once per turn that produced one or more referenced
|
|
197
|
+
capsules -- letting a caller resolve any of those capsules back to the
|
|
198
|
+
turn that prompted it via ``find_turn_reference``, without requiring the
|
|
199
|
+
referenced capsule itself to carry the link (which is often built and
|
|
200
|
+
appended before the turn is even known -- e.g. a tool call dispatched
|
|
201
|
+
live, while the turn is only reconstructable after the fact from a full
|
|
202
|
+
trajectory).
|
|
203
|
+
"""
|
|
204
|
+
if not referenced_capsule_ids:
|
|
205
|
+
raise ValueError("referenced_capsule_ids must be non-empty")
|
|
206
|
+
|
|
207
|
+
detail = {
|
|
208
|
+
"turn_capsule_id": turn_capsule_id,
|
|
209
|
+
"referenced_capsule_ids": list(referenced_capsule_ids),
|
|
210
|
+
}
|
|
211
|
+
return build_event_capsule(
|
|
212
|
+
operator=operator,
|
|
213
|
+
developer=developer,
|
|
214
|
+
signer=signer,
|
|
215
|
+
event=EVENT_TURN_REFERENCE,
|
|
216
|
+
detail=detail,
|
|
217
|
+
timestamp=timestamp,
|
|
218
|
+
action_id=action_id or f"conversation.turn_reference/{turn_capsule_id}",
|
|
219
|
+
chain_parent=turn_capsule_id,
|
|
220
|
+
chain_relation="confirms",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _matches(record: LedgerRecord, event: str, session_id: str) -> bool:
|
|
225
|
+
payload = record.capsule.get("asg_payload") or {}
|
|
226
|
+
if payload.get("event") != event:
|
|
227
|
+
return False
|
|
228
|
+
return (payload.get("detail") or {}).get("session_id") == session_id
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def find_session_turns(ledger: LedgerAPI, session_id: str) -> list[LedgerRecord]:
|
|
232
|
+
"""Every turn capsule recorded for ``session_id``, sorted by
|
|
233
|
+
``turn_index`` (not append order -- defensive against an out-of-order
|
|
234
|
+
replay/import; a live ``ConversationSession`` always appends in order).
|
|
235
|
+
"""
|
|
236
|
+
matches = [r for r in ledger.scan(ScanQuery(action_type="fyi")) if _matches(r, EVENT_CONVERSATION_TURN, session_id)]
|
|
237
|
+
matches.sort(key=lambda r: (r.capsule.get("asg_payload") or {}).get("detail", {}).get("turn_index", 0))
|
|
238
|
+
return matches
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def find_session_close(ledger: LedgerAPI, session_id: str) -> LedgerRecord | None:
|
|
242
|
+
"""The session-close capsule for ``session_id``, or ``None`` if the
|
|
243
|
+
session is still open (or never existed). ``scan()`` is append-ordered
|
|
244
|
+
and a session closes at most once, so the last match wins if more than
|
|
245
|
+
one is ever found (defensive; not an expected shape).
|
|
246
|
+
"""
|
|
247
|
+
latest: LedgerRecord | None = None
|
|
248
|
+
for record in ledger.scan(ScanQuery(action_type="fyi")):
|
|
249
|
+
if _matches(record, EVENT_SESSION_CLOSE, session_id):
|
|
250
|
+
latest = record
|
|
251
|
+
return latest
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def find_turn_reference(ledger: LedgerAPI, capsule_id: str) -> LedgerRecord | None:
|
|
255
|
+
"""The turn-reference capsule (if any) that names ``capsule_id`` among
|
|
256
|
+
its ``referenced_capsule_ids`` -- lets a caller resolve any capsule
|
|
257
|
+
(e.g. a tool-call capsule from an unrelated pipeline) back to the
|
|
258
|
+
conversation turn that gave rise to it. ``None`` if no such reference
|
|
259
|
+
was ever recorded.
|
|
260
|
+
"""
|
|
261
|
+
for record in ledger.scan(ScanQuery(action_type="fyi")):
|
|
262
|
+
payload = record.capsule.get("asg_payload") or {}
|
|
263
|
+
if payload.get("event") != EVENT_TURN_REFERENCE:
|
|
264
|
+
continue
|
|
265
|
+
detail = payload.get("detail") or {}
|
|
266
|
+
if capsule_id in (detail.get("referenced_capsule_ids") or []):
|
|
267
|
+
return record
|
|
268
|
+
return None
|