sediment-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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 (83) hide show
  1. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/PKG-INFO +5 -5
  2. sediment_cli-0.3.0/hatch_build.py +21 -0
  3. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/pyproject.toml +8 -5
  4. sediment_cli-0.3.0/sediment_cli/_pi/LICENSE +21 -0
  5. sediment_cli-0.3.0/sediment_cli/_pi/README.md +204 -0
  6. sediment_cli-0.3.0/sediment_cli/_pi/index.ts +156 -0
  7. sediment_cli-0.3.0/sediment_cli/_pi/lib/contract.ts +189 -0
  8. sediment_cli-0.3.0/sediment_cli/_pi/lib/process.ts +118 -0
  9. sediment_cli-0.3.0/sediment_cli/_pi/lib/provider.ts +7 -0
  10. sediment_cli-0.3.0/sediment_cli/_pi/lib/register.ts +295 -0
  11. sediment_cli-0.3.0/sediment_cli/_pi/lib/retrieval.ts +424 -0
  12. sediment_cli-0.3.0/sediment_cli/_pi/package.json +20 -0
  13. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/attribution.py +154 -39
  14. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/cli.py +11 -1
  15. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/delivery.py +14 -2
  16. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/ui.py +38 -5
  17. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_ui.py +64 -1
  18. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_delivery_transport.py +47 -0
  19. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_installed_wheel.py +91 -0
  20. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/install.txt +4 -2
  21. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/sediment.txt +1 -1
  22. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/.gitignore +0 -0
  23. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/LICENSE +0 -0
  24. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/__init__.py +0 -0
  25. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/client.py +0 -0
  26. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/evidence.py +0 -0
  27. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/local_postgres.py +0 -0
  28. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/sediment_cli/transcript.py +0 -0
  29. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/conftest.py +0 -0
  30. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli.py +0 -0
  31. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_capture_authority.py +0 -0
  32. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_db.py +0 -0
  33. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_evidence.py +0 -0
  34. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_evidence_integration.py +0 -0
  35. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_help.py +0 -0
  36. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_libpq_unavailable.py +0 -0
  37. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_cli_remote.py +0 -0
  38. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_consumer_profile_cli.py +0 -0
  39. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_demo_verb.py +0 -0
  40. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_e2e_quickstart.py +0 -0
  41. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/test_local_postgres.py +0 -0
  42. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/commit.txt +0 -0
  43. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/db-provision.txt +0 -0
  44. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/db-status.txt +0 -0
  45. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/db-upgrade.txt +0 -0
  46. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/db.txt +0 -0
  47. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/delivery-enqueue.txt +0 -0
  48. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/delivery-replay.txt +0 -0
  49. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/delivery-status.txt +0 -0
  50. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/delivery.txt +0 -0
  51. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/demo.txt +0 -0
  52. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/derive.txt +0 -0
  53. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/doctor.txt +0 -0
  54. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/evidence-fetch.txt +0 -0
  55. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/evidence-inspect.txt +0 -0
  56. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/evidence-inventory.txt +0 -0
  57. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/evidence.txt +0 -0
  58. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/export-diff-sft.txt +0 -0
  59. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/export-dpo.txt +0 -0
  60. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/export-recovery.txt +0 -0
  61. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/export-rlvr.txt +0 -0
  62. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/export-sft.txt +0 -0
  63. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/export.txt +0 -0
  64. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/facts.txt +0 -0
  65. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/login.txt +0 -0
  66. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/logout.txt +0 -0
  67. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/mirror-gc.txt +0 -0
  68. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/quarantine-inference-calls.txt +0 -0
  69. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/quarantine-log.txt +0 -0
  70. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/quarantine.txt +0 -0
  71. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/release.txt +0 -0
  72. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-abandonment.txt +0 -0
  73. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-attribution-share.txt +0 -0
  74. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-dataset-diagnostics.txt +0 -0
  75. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-label-confidence-inspection.txt +0 -0
  76. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-lifecycle.txt +0 -0
  77. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-merge-retention.txt +0 -0
  78. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-model.txt +0 -0
  79. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-precision.txt +0 -0
  80. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report-recovery-yield.txt +0 -0
  81. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/report.txt +0 -0
  82. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/server.txt +0 -0
  83. {sediment_cli-0.2.0 → sediment_cli-0.3.0}/tests/testdata/help/uninstall.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sediment-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: The sediment command: login, install, server, facts, exports
5
5
  Project-URL: Source, https://github.com/sediment-ai/sediment
6
6
  Project-URL: Documentation, https://github.com/sediment-ai/sediment/tree/main/docs
@@ -10,7 +10,7 @@ License-Expression: AGPL-3.0-or-later
10
10
  License-File: LICENSE
11
11
  Requires-Python: >=3.12
12
12
  Requires-Dist: httpx>=0.27
13
- Requires-Dist: sediment-api==0.2.0
14
- Requires-Dist: sediment-core==0.2.0
15
- Requires-Dist: sediment-derive==0.2.0
16
- Requires-Dist: sediment-export==0.2.0
13
+ Requires-Dist: sediment-api==0.3.0
14
+ Requires-Dist: sediment-core==0.3.0
15
+ Requires-Dist: sediment-derive==0.3.0
16
+ Requires-Dist: sediment-export==0.3.0
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Include the MIT pi runtime in wheels and source distributions."""
3
+
4
+ from pathlib import Path
5
+
6
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
7
+
8
+
9
+ class PiExtensionHook(BuildHookInterface):
10
+ def initialize(self, version, build_data):
11
+ # Editable installs resolve shims/pi directly from the checkout.
12
+ if version == "editable":
13
+ return
14
+ root = Path(self.root)
15
+ bundled = root / "sediment_cli" / "_pi"
16
+ # A source distribution already contains the runtime as package data.
17
+ if bundled.is_dir():
18
+ return
19
+ source = root.parent / "shims" / "pi"
20
+ for name in ("index.ts", "lib", "package.json", "LICENSE", "README.md"):
21
+ build_data["force_include"][str(source / name)] = f"sediment_cli/_pi/{name}"
@@ -5,7 +5,7 @@
5
5
  # Depends on sediment-api deliberately: the operator verbs are DB-local by
6
6
  # design (ADR 0001) and `server`/reports/mirror-gc forward into it.
7
7
  name = "sediment-cli"
8
- version = "0.2.0"
8
+ version = "0.3.0"
9
9
  description = "The sediment command: login, install, server, facts, exports"
10
10
  requires-python = ">=3.12"
11
11
  license = "AGPL-3.0-or-later"
@@ -14,10 +14,10 @@ license-files = ["LICENSE"]
14
14
  # so an install never mixes member versions (the release workflow asserts
15
15
  # the versions agree before publishing).
16
16
  dependencies = [
17
- "sediment-core==0.2.0",
18
- "sediment-derive==0.2.0",
19
- "sediment-export==0.2.0",
20
- "sediment-api==0.2.0",
17
+ "sediment-core==0.3.0",
18
+ "sediment-derive==0.3.0",
19
+ "sediment-export==0.3.0",
20
+ "sediment-api==0.3.0",
21
21
  "httpx>=0.27",
22
22
  ]
23
23
 
@@ -37,6 +37,9 @@ build-backend = "hatchling.build"
37
37
  [tool.hatch.build.targets.wheel]
38
38
  packages = ["sediment_cli"]
39
39
 
40
+ [tool.hatch.build.hooks.custom]
41
+ path = "hatch_build.py"
42
+
40
43
  [tool.uv.sources]
41
44
  sediment-core = { workspace = true }
42
45
  sediment-derive = { workspace = true }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sediment
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,204 @@
1
+ # sediment-pi
2
+
3
+ The pi-harness shim captures agent work and retrieves evidence from a previous
4
+ Session. Capture follows the Sediment client capture contract
5
+ (`docs/agents/capture-clients.md`):
6
+
7
+ | seam | behavior |
8
+ |---|---|
9
+ | Inference calls | `before_provider_headers` stamps the live Session identifier on requests for the configured provider and API. |
10
+ | decisions | edit/write `tool_execution_end` (no error) → `sediment.tool_decision` record to `POST $SEDIMENT_OTLP_ENDPOINT/v1/logs`. Always `accept` / `explicit=false` — stock pi has no human approval gesture, and a gesture we cannot observe is absent, never guessed at. |
11
+ | attribution | edit/write `tool_execution_end` (error or not) → `sediment mark --tool pi` with `{session_id, cwd}` on stdin |
12
+ | transcripts | When `SEDIMENT_PI_TRANSCRIPTS=1`, `session_shutdown` (except `reason: "reload"`, which tears down the extension runtime, not the Session) → `sediment transcript --agent pi` with `{session_id, transcript_path}` on stdin |
13
+
14
+ Capture failures never throw into the harness. Each channel reports failures
15
+ independently; Attribution doesn't prove a missing Decision or Edit observation.
16
+ With `SEDIMENT_DELIVERY_DIR`, the shared Python helper retains prepared Decision
17
+ and opted-in transcript payloads before delivery. Without it, delivery is best-effort.
18
+
19
+ ## Install
20
+
21
+ Use pi 0.84.1 or later with Node 24. Node 22.18 or later in the Node 22
22
+ release line is also supported. The shim rejects other Node release lines.
23
+ The shim has no runtime package dependencies and doesn't require `npm install`.
24
+ Your pi installation is a separate prerequisite: inventory its packages and
25
+ support status before installing it in your deployment.
26
+
27
+ The `sediment-cli` wheel includes the extension's TypeScript source, package
28
+ manifest, README, and MIT license. Its SPDX headers remain intact. No build or
29
+ dependency installation runs on the developer machine. Start pi once to create
30
+ `~/.pi/agent`, then run:
31
+
32
+ ```
33
+ sediment install <repo>
34
+ ```
35
+
36
+ The command registers the packaged extension in `~/.pi/agent/settings.json`
37
+ (`extensions` list) alongside the Git hooks and detected agent hooks. Editable
38
+ source installations register `shims/pi/` instead. `uninstall --agents` removes
39
+ the entry. With pi present, `doctor` reports `FAIL` if the extension is missing
40
+ or unregistered. Keep `sediment` on PATH when you start pi.
41
+
42
+ ## Opt in to Edit observations
43
+
44
+ Transcript capture sends applied edit text and observed file text. Decision
45
+ capture and Attribution don't authorize that content capture.
46
+
47
+ To opt in, run `sediment install --transcripts <repo>`.
48
+ Load the generated environment before you restart pi.
49
+
50
+ If you use `--no-env`, set the endpoint, token, and explicit content opt-in in
51
+ the environment that starts pi:
52
+
53
+ ```bash
54
+ export SEDIMENT_OTLP_ENDPOINT=https://sediment-api.example.com
55
+ export SEDIMENT_INGEST_TOKEN='<deployment token>'
56
+ export SEDIMENT_PI_TRANSCRIPTS=1
57
+ ```
58
+
59
+ An existing pi installation that supplies only an endpoint and token continues
60
+ to send Developer decisions and mark Attribution. To continue Edit observations,
61
+ you must opt in to transcript content with `SEDIMENT_PI_TRANSCRIPTS=1`.
62
+ An unset variable or any other value disables transcript extraction.
63
+
64
+ ## Retrieve context from one previous Session
65
+
66
+ If the operator enables a fixed source Session on the API, supply its restricted
67
+ retrieval credential to the agent environment:
68
+
69
+ ```bash
70
+ export SEDIMENT_RETRIEVAL_ENDPOINT=https://sediment-api.example.com
71
+ export SEDIMENT_RETRIEVAL_TOKEN='<Session retrieval token>'
72
+ ```
73
+
74
+ The extension registers `sediment_retrieve_context` with `query` and optional
75
+ `max_bytes` arguments. The agent chooses its question. The tool uses English/code
76
+ keywords to select exact captured parts from the configured previous Session.
77
+ The response preserves occurrence references and reports unknown capture
78
+ completeness. An empty result doesn't prove that an event never happened.
79
+
80
+ The endpoint must be an API base URL. Remote hosts require HTTPS; HTTP accepts
81
+ only literal localhost or loopback addresses. The tool rejects redirects, sends
82
+ one request, and combines pi cancellation with a 35-second deadline. Its complete
83
+ JSON result defaults to 16,384 bytes, with a permitted range of 4,096–65,536 bytes.
84
+ The limit counts bytes, not model tokens. The tool returns whole parts without
85
+ summarizing or clipping them.
86
+ Keyword tools accept complete scan coverage through 16,384 parts; source limits
87
+ and refusal behavior follow the [keyword retrieval contract](../../docs/operate/resume-with-evidence.md).
88
+
89
+ Both settings are independent of capture enrollment. Neither falls back to an
90
+ ingest token, operator login, or workspace configuration. If both are absent,
91
+ the extension registers no retrieval tool. Incomplete or invalid configuration
92
+ logs a content-free reason and leaves capture active. The extension reports
93
+ request failures as content-free tool errors and doesn't retry automatically.
94
+
95
+ Keep operator credentials, deployment configuration, database credentials, and
96
+ old transcripts outside the agent's execution environment. Use a separate
97
+ container or operating-system account for that boundary. Keep the model and
98
+ gateway inside the customer perimeter as well. Historical roles and tool calls
99
+ remain evidence; the extension doesn't execute them. Retrieved text can contain
100
+ instructions, so ordinary harness tool controls still apply.
101
+
102
+ This tool adds no model dependency. It doesn't establish cost savings or a
103
+ continuation benefit by itself. The
104
+ [controlled continuation procedure](../../docs/operate/resume-with-evidence.md)
105
+ defines the validation boundary.
106
+
107
+ The same endpoint/token pair also registers `sediment_list_context_sessions`,
108
+ `sediment_evidence_inventory`, `sediment_evidence_manifest`, and
109
+ `sediment_read_evidence`. These tools enumerate the configured grant and fetch
110
+ explicit occurrences without a keyword query. An external selector judges which
111
+ evidence is useful. Exact reads preserve repeated occurrences and can include
112
+ readable reasoning. Every request rechecks authorization and Quarantine.
113
+ Fetch accepts at most 32 distinct references in a 64 KiB request and a 1 MiB
114
+ response. The tools forward validated original JSON text to preserve large
115
+ integers. See [Select exact evidence independently](../../docs/operate/resume-with-evidence.md#select-exact-evidence-independently).
116
+
117
+ ## Environment
118
+
119
+ If the API authorizes several Sessions, set `SEDIMENT_RETRIEVAL_DISCOVERY=true`
120
+ with the independent retrieval endpoint/token pair. The extension registers
121
+ `sediment_discover_context` and requires `session_id` on `sediment_retrieve_context`.
122
+ Discover with task keywords, select a returned Session, then request its evidence.
123
+ An optional complete repository-qualified commit prioritizes an observed
124
+ relationship; it cannot expand the authorized set. The operator's Session list
125
+ stays on the API. See [Discover a previous Session](../../docs/operate/resume-with-evidence.md#discover-a-previous-session).
126
+ Absent or `false` preserves the fixed-Session tool. Other flag values disable
127
+ retrieval with a safe configuration diagnostic.
128
+
129
+ - `SEDIMENT_RETRIEVAL_ENDPOINT` / `SEDIMENT_RETRIEVAL_TOKEN` — independent
130
+ opt-in pair for Session retrieval tools. The endpoint is an API base
131
+ URL; capture credentials never supply retrieval authority.
132
+ - `SEDIMENT_RETRIEVAL_DISCOVERY` — `true` enables candidate discovery and explicit
133
+ Session selection; absent or `false` retains the fixed-Session schema.
134
+ - `SEDIMENT_PROVIDER_ID` / `SEDIMENT_PROVIDER_API` — provider and API that receive
135
+ the Session header (defaults `sediment` / `anthropic-messages`). The native
136
+ header hook reads the Session identifier for each request. It replaces stale
137
+ Session headers and preserves all other headers. If the identifier is absent
138
+ or invalid for an HTTP header, the shim omits it and logs a content-free
139
+ diagnostic. Decision capture and Attribution continue independently.
140
+ - `SEDIMENT_OTLP_ENDPOINT` — ingest base URL. Required for decision capture;
141
+ unset means not opted in (there is deliberately no
142
+ `OTEL_EXPORTER_OTLP_ENDPOINT` fallback — see the contract doc). Remote hosts
143
+ require HTTPS. Plain HTTP accepts only literal `localhost`, IPv4 loopback,
144
+ or `[::1]`; authenticated POSTs reject redirects.
145
+ - `SEDIMENT_INGEST_TOKEN` — bearer token (falls back to an `Authorization`
146
+ header in `OTEL_EXPORTER_OTLP_HEADERS`, only ever sent to the explicit
147
+ endpoint above).
148
+ - `SEDIMENT_PI_TRANSCRIPTS` — explicit content opt-in. Only the exact value
149
+ `1` enables transcript extraction. It doesn't change decision capture,
150
+ Attribution, or gateway Session identity.
151
+ - `SEDIMENT_EXTRACT_ON_SETTLE` — a nonempty value also extracts on
152
+ `agent_settled`, provided `SEDIMENT_PI_TRANSCRIPTS=1`. Set it only for hosts
153
+ that use one Session per task. An interactive Session can settle before its
154
+ final edit; first-write-wins storage would retain that earlier observation.
155
+ A runtime reload never triggers transcript extraction.
156
+ - `OTEL_RESOURCE_ATTRIBUTES` — `user.id` is forwarded as resource identity.
157
+ - `SEDIMENT_SCRIPT_DIR` — fallback directory containing
158
+ `sediment_attribution.py`, `sediment_transcript.py`, and `sediment_delivery.py`.
159
+ Copy the delivery implementation from `cli/sediment_cli/delivery.py`, not the
160
+ checkout shim. The shim prefers an
161
+ installed `sediment` executable on `PATH`. Without one, each source-checkout
162
+ script resolves independently, so a missing transcript client doesn't
163
+ disable attribution.
164
+ - `SEDIMENT_PYTHON` — interpreter for the Python clients (default
165
+ `python3`; stdlib-only, any 3.12+ works).
166
+ - `SEDIMENT_DELIVERY_DIR` — opt-in private prepared-payload storage. Run
167
+ `sediment delivery replay --watch` under your existing process supervisor.
168
+ Startup requests a bounded drain; it doesn't install a background service.
169
+ See [Sender replay operations](../../docs/capture/local-capture.md#preserve-prepared-payloads-through-outages)
170
+ for content consent, limits, retention, active credentials, and recovery.
171
+
172
+ Child processes have a bounded deadline. On macOS and Linux, the runner stops
173
+ its owned process group, including descendants. Spawn, stdin, timeout, and
174
+ nonzero-exit failures produce content-free channel diagnostics. The runner drains
175
+ stderr without relaying its contents. A successful enqueue requires a matching
176
+ structured acknowledgment; exit zero alone doesn't prove durable acceptance.
177
+ Pi explicitly requests the shared helper's storage-fault fallback. If private
178
+ storage is unsafe, unavailable, or busy, one direct attempt preserves the prepared
179
+ Decision. A validated direct acknowledgment reports `best_effort` and its storage
180
+ reason. Capacity and identity declines remain failures. Repair storage even when
181
+ the direct send succeeds; that send has no durable recovery guarantee.
182
+
183
+ ## Develop
184
+
185
+ ```
186
+ npm ci
187
+ npm test # node --test (type stripping; Node 24 or Node 22.18+)
188
+ npm run typecheck # tsc --noEmit
189
+ ```
190
+
191
+ The `shims` workflow builds and installs Sediment wheels. Its tests load the
192
+ registered extension through pi's native loader outside the checkout and verify
193
+ capture through the installed command. Packaging, registration, and shared Python
194
+ delivery changes also trigger this workflow. To exercise the same tests locally,
195
+ set `SEDIMENT_PI_TEST_PYTHON` to the installed environment's Python executable
196
+ and `SEDIMENT_PI_TEST_INSTALLED_BIN` to its `bin` directory before `npm test`.
197
+
198
+ The lockfile pins pi 0.86.1 (`@earendil-works/pi-coding-agent`) as a development
199
+ dependency. The native retrieval test exercises its extension registration,
200
+ argument validation, agent loop, and model-visible tool result with a scripted
201
+ model stream. It makes no paid inference request and downloads no package during
202
+ the test. Run these native checks on Node 24; pi's own minimum is Node 22.19.
203
+ Runtime extension imports remain standard-library imports; pi types are erased.
204
+ Source installation still needs no shim `node_modules` directory.
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // sediment-pi — the pi-harness shim for Sediment capture.
3
+ //
4
+ // Closes pi's capture gaps client-side, per docs/agents/capture-clients.md:
5
+ // decisions — edit-tool executions emit sediment.tool_decision records
6
+ // to $SEDIMENT_OTLP_ENDPOINT/v1/logs, through the Python
7
+ // delivery helper when buffering is enrolled (accepted,
8
+ // explicit=false: stock pi has no human approval gesture)
9
+ // attribution — edit-tool executions invoke sediment_attribution.py
10
+ // `mark --tool pi` (the stamper is tool-agnostic)
11
+ // transcripts — session_shutdown invokes sediment_transcript.py
12
+ // `--agent pi` (the pair is the fact; ADR 0007)
13
+ // completions — the native provider-header event attaches the current
14
+ // Session to requests for the configured gateway
15
+ //
16
+ // All capture is best-effort: a failure degrades the signal to the
17
+ // notes/jaccard backstop and must never break the agent.
18
+
19
+ import { accessSync, constants, existsSync } from "node:fs";
20
+ import { delimiter, dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ import {
24
+ register,
25
+ type CaptureProgram,
26
+ type Deps,
27
+ type PiLike,
28
+ } from "./lib/register.ts";
29
+ import { runCaptureProcess } from "./lib/process.ts";
30
+ import { registerRetrieval } from "./lib/retrieval.ts";
31
+
32
+ const POST_TIMEOUT_MS = 10_000;
33
+ const MAX_ACKNOWLEDGMENT_BYTES = 16_384;
34
+
35
+ async function post(
36
+ url: string,
37
+ payload: unknown,
38
+ token: string | null,
39
+ ): Promise<void> {
40
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
41
+ if (token) headers.Authorization = `Bearer ${token}`;
42
+ const response = await fetch(url, {
43
+ method: "POST",
44
+ headers,
45
+ body: JSON.stringify(payload),
46
+ redirect: "error",
47
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS),
48
+ });
49
+ if (response.status !== 200 || !response.body) {
50
+ await response.body?.cancel();
51
+ throw new Error("invalid_acknowledgment");
52
+ }
53
+ const reader = response.body.getReader();
54
+ const chunks: Uint8Array[] = [];
55
+ let bytes = 0;
56
+ try {
57
+ for (;;) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ bytes += value.length;
61
+ if (bytes > MAX_ACKNOWLEDGMENT_BYTES) {
62
+ throw new Error("invalid_acknowledgment");
63
+ }
64
+ chunks.push(value);
65
+ }
66
+ const acknowledgment = JSON.parse(
67
+ new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)),
68
+ );
69
+ // OTLP acknowledgment describes delivery, never the number of Facts.
70
+ if (
71
+ !acknowledgment || Array.isArray(acknowledgment) ||
72
+ typeof acknowledgment !== "object" || Object.keys(acknowledgment).length !== 0
73
+ ) {
74
+ throw new Error("invalid_acknowledgment");
75
+ }
76
+ } finally {
77
+ await reader.cancel();
78
+ }
79
+ }
80
+
81
+ function runScript(
82
+ script: string | CaptureProgram,
83
+ args: string[],
84
+ stdin: unknown,
85
+ captureOutput = false,
86
+ ): Promise<string> {
87
+ // ponytail: PATH python3 (overridable via SEDIMENT_PYTHON) — the scripts
88
+ // are stdlib-only, so any 3.12+ interpreter runs them; the stamper's hook
89
+ // fragments pin sys.executable instead because hooks fire in bare envs.
90
+ const python = process.env.SEDIMENT_PYTHON ?? "python3";
91
+ const executable = typeof script === "string" ? python : script.executable;
92
+ const commandArgs =
93
+ typeof script === "string"
94
+ ? [script, ...args]
95
+ : [...script.arguments, ...args];
96
+ return runCaptureProcess(executable, commandArgs, stdin, captureOutput);
97
+ }
98
+
99
+ function findSediment(): string | null {
100
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
101
+ if (!dir) continue;
102
+ const candidate = join(dir, "sediment");
103
+ try {
104
+ accessSync(candidate, constants.X_OK);
105
+ return candidate;
106
+ } catch {
107
+ // Try the next PATH entry.
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function resolveScripts(): Deps["scripts"] {
114
+ const sediment = findSediment();
115
+ if (sediment) {
116
+ return {
117
+ attribution: { executable: sediment, arguments: [] },
118
+ transcript: { executable: sediment, arguments: ["transcript"] },
119
+ delivery: { executable: sediment, arguments: ["delivery"] },
120
+ };
121
+ }
122
+ // Default: installed in place, <repo>/shims/pi/index.ts → <repo>/scripts.
123
+ const dir =
124
+ process.env.SEDIMENT_SCRIPT_DIR ||
125
+ join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts");
126
+ const attribution = join(dir, "sediment_attribution.py");
127
+ const transcript = join(dir, "sediment_transcript.py");
128
+ const delivery = join(dir, "sediment_delivery.py");
129
+ const resolved = {
130
+ attribution: existsSync(attribution) ? attribution : null,
131
+ transcript: existsSync(transcript) ? transcript : null,
132
+ delivery: existsSync(delivery) ? delivery : null,
133
+ };
134
+ if (!resolved.attribution)
135
+ console.error("sediment-pi: attribution capture degraded (script not found)");
136
+ if (!resolved.transcript)
137
+ console.error("sediment-pi: transcript capture degraded (script not found)");
138
+ return resolved.attribution || resolved.transcript || resolved.delivery
139
+ ? resolved : null;
140
+ }
141
+
142
+ export default function (pi: PiLike & Parameters<typeof registerRetrieval>[0]) {
143
+ const version = /^(22|24)\.(\d+)\.\d+$/.exec(process.versions.node);
144
+ if (!version || (version[1] === "22" && Number(version[2]) < 18)) {
145
+ console.error("sediment-pi: unsupported Node runtime; use Node 24 or Node 22.18+");
146
+ return;
147
+ }
148
+ registerRetrieval(pi, process.env);
149
+ register(pi, {
150
+ post,
151
+ runScript,
152
+ scripts: resolveScripts(),
153
+ env: process.env,
154
+ now: Date.now,
155
+ });
156
+ }
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The Sediment client capture contract (docs/agents/capture-clients.md) —
3
+ // pure builders and env resolution. No I/O here; the wiring lives in
4
+ // lib/register.ts. The shapes emitted here are pinned against the golden
5
+ // server-side conformance fixture
6
+ // (packages/capture/tests/fixtures/otlp/sediment/tool_decision.json).
7
+
8
+ import { dirname } from "node:path";
9
+
10
+ export const DECISION_EVENT = "sediment.tool_decision";
11
+
12
+ // The contract scopes decisions to edit tools; reads stay out.
13
+ export const EDIT_TOOLS: ReadonlySet<string> = new Set(["edit", "write"]);
14
+
15
+ export type AnyValue = { stringValue: string } | { boolValue: boolean };
16
+
17
+ export interface OtlpAttribute {
18
+ key: string;
19
+ value: AnyValue;
20
+ }
21
+
22
+ export interface OtlpRecord {
23
+ body: { stringValue: string };
24
+ timeUnixNano: string;
25
+ attributes: OtlpAttribute[];
26
+ }
27
+
28
+ export interface LogsPayload {
29
+ resourceLogs: [
30
+ {
31
+ resource: { attributes: OtlpAttribute[] };
32
+ scopeLogs: [{ logRecords: OtlpRecord[] }];
33
+ },
34
+ ];
35
+ }
36
+
37
+ export interface DecisionInput {
38
+ agent: string;
39
+ sessionId: string;
40
+ callId: string;
41
+ toolName: string;
42
+ decision: "accept" | "reject";
43
+ explicit: boolean;
44
+ filePath: string;
45
+ timeUnixNano: string;
46
+ }
47
+
48
+ export function buildDecisionRecord(input: DecisionInput): OtlpRecord {
49
+ const strings: Record<string, string> = {
50
+ agent: input.agent,
51
+ "session.id": input.sessionId,
52
+ tool_use_id: input.callId,
53
+ tool_name: input.toolName,
54
+ decision: input.decision,
55
+ file_path: input.filePath,
56
+ };
57
+ const attributes: OtlpAttribute[] = Object.entries(strings).map(
58
+ ([key, value]) => ({ key, value: { stringValue: value } }),
59
+ );
60
+ // explicit must stay a real bool on the wire — the server requires it.
61
+ attributes.push({ key: "explicit", value: { boolValue: input.explicit } });
62
+ return {
63
+ body: { stringValue: DECISION_EVENT },
64
+ timeUnixNano: input.timeUnixNano,
65
+ attributes,
66
+ };
67
+ }
68
+
69
+ export function buildLogsPayload(
70
+ records: OtlpRecord[],
71
+ resourceAttrs: Record<string, string>,
72
+ ): LogsPayload {
73
+ return {
74
+ resourceLogs: [
75
+ {
76
+ resource: {
77
+ attributes: Object.entries(resourceAttrs).map(([key, value]) => ({
78
+ key,
79
+ value: { stringValue: value },
80
+ })),
81
+ },
82
+ scopeLogs: [{ logRecords: records }],
83
+ },
84
+ ],
85
+ };
86
+ }
87
+
88
+ export interface Env {
89
+ [key: string]: string | undefined;
90
+ }
91
+
92
+ /**
93
+ * The ingest endpoint, or null when not opted in. Deliberately NO
94
+ * OTEL_EXPORTER_OTLP_ENDPOINT fallback: /v1/logs is the standard OTLP path,
95
+ * so any collector in the machine's generic telemetry config would silently
96
+ * accept decision records bound for Sediment. Explicit opt-in only (same
97
+ * posture as the Python clients).
98
+ */
99
+ export function resolveEndpoint(env: Env): string | null {
100
+ // WHATWG URL parsing tolerates surrounding whitespace, so trim before the
101
+ // slash-strip — mirrors the Python sibling (cli/sediment_cli/transcript.py).
102
+ const base = (env.SEDIMENT_OTLP_ENDPOINT ?? "").trim().replace(/\/+$/, "");
103
+ if (!base) return null;
104
+ let endpoint: URL;
105
+ try {
106
+ endpoint = new URL(base);
107
+ } catch {
108
+ return null;
109
+ }
110
+ const host = endpoint.hostname.toLowerCase();
111
+ const ipv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(host);
112
+ const loopback =
113
+ host === "localhost" || host === "::1" || host === "[::1]" || ipv4Loopback;
114
+ if (
115
+ !["http:", "https:"].includes(endpoint.protocol) ||
116
+ endpoint.username ||
117
+ endpoint.password ||
118
+ endpoint.search ||
119
+ endpoint.hash ||
120
+ !["/", "/v1/logs"].includes(endpoint.pathname) ||
121
+ (endpoint.protocol === "http:" && !loopback)
122
+ ) {
123
+ return null;
124
+ }
125
+ return base.endsWith("/v1/logs") ? base : `${base}/v1/logs`;
126
+ }
127
+
128
+ /** Pairs of an OTLP-spec list: `Key=url-encoded-value,Key2=...`. */
129
+ function* pairs(list: string | undefined): Generator<[string, string]> {
130
+ for (const part of (list ?? "").split(",")) {
131
+ const eq = part.indexOf("=");
132
+ if (eq >= 0) yield [part.slice(0, eq).trim(), part.slice(eq + 1).trim()];
133
+ }
134
+ }
135
+
136
+ export function resolveToken(env: Env): string | null {
137
+ const token = env.SEDIMENT_INGEST_TOKEN;
138
+ if (token) return token;
139
+ // Safe to read the machine's generic OTLP headers here: the token is only
140
+ // ever sent to the explicit sediment endpoint above.
141
+ for (const [key, value] of pairs(env.OTEL_EXPORTER_OTLP_HEADERS)) {
142
+ if (key.toLowerCase() !== "authorization") continue;
143
+ const decoded = decodeURIComponent(value);
144
+ const space = decoded.indexOf(" ");
145
+ const scheme = space < 0 ? decoded : decoded.slice(0, space);
146
+ const credential = space < 0 ? "" : decoded.slice(space + 1).trim();
147
+ if (scheme.toLowerCase() === "bearer" && credential) return credential;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ export function resourceUserId(env: Env): string | null {
153
+ for (const [key, value] of pairs(env.OTEL_RESOURCE_ATTRIBUTES)) {
154
+ if (key === "user.id" && value) return value;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * The decision a tool_execution_end carries, or null when there is none to
161
+ * emit. Stock pi has no human approval gesture, so an applied edit is an
162
+ * implicit accept; an errored execution never touched the file and emits
163
+ * nothing (absent, never guessed at).
164
+ */
165
+ export function decisionFromToolEnd(
166
+ toolName: string,
167
+ isError: boolean,
168
+ ): { decision: "accept"; explicit: false } | null {
169
+ if (!EDIT_TOOLS.has(toolName) || isError) return null;
170
+ return { decision: "accept", explicit: false };
171
+ }
172
+
173
+ /** file_path from tool args; "" when unobservable (the reject convention). */
174
+ export function filePathFromArgs(args: unknown): string {
175
+ if (typeof args !== "object" || args === null) return "";
176
+ const path = (args as Record<string, unknown>).path;
177
+ return typeof path === "string" ? path : "";
178
+ }
179
+
180
+ /**
181
+ * The cwd `mark` should run in: the edited file's directory when the path is
182
+ * absolute, else the session cwd. ACP agents edit clones beneath the
183
+ * workspace root — the session cwd is not a git repo, so `mark` there heals
184
+ * nothing. Any directory inside the repo works: git walks up to the repo
185
+ * root.
186
+ */
187
+ export function markCwd(filePath: string, sessionCwd: string): string {
188
+ return filePath.startsWith("/") ? dirname(filePath) : sessionCwd;
189
+ }