ph-clm 0.9.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.
@@ -0,0 +1,31 @@
1
+ # Build and environment
2
+ .venv/
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ __pycache__/
7
+ *.py[cod]
8
+ jjt/
9
+ w2/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ # Written by the guest's subprocess collectors and combined at the end of the
17
+ # run; see `conftest.GUEST_COVERAGE_RC`.
18
+ .coverage-guest*
19
+ htmlcov/
20
+ # Dropped at the repo root by pytest-textual-snapshot when a snapshot test
21
+ # fails. The reference snapshots under `__snapshots__/` are the committed
22
+ # expectation; this is the diff viewer for a run that did not match one.
23
+ snapshot_report.html
24
+
25
+ # Reference checkouts of the upstream projects this port reads from. Vendored
26
+ # locally so the plans' citations are verifiable; never part of this repo.
27
+ sources/
28
+
29
+ # Local scratch
30
+ .ph/
31
+ *.local.yaml
ph_clm-0.9.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Charles Tabor
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.
ph_clm-0.9.0/PKG-INFO ADDED
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.5
2
+ Name: ph-clm
3
+ Version: 0.9.0
4
+ Summary: pH plugin: the model edits its own context — tombstone, replace and rewrite sections of the surface, with the originals kept in the log.
5
+ Project-URL: Homepage, https://github.com/chastabor/pH
6
+ Project-URL: Repository, https://github.com/chastabor/pH
7
+ Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
8
+ Project-URL: Issues, https://github.com/chastabor/pH/issues
9
+ Author: Charles Tabor
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,clm,context-management,llm
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: ph-core==0.9.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # ph-clm
28
+
29
+ *The model edits its own context: tombstone, replace and rewrite sections of what
30
+ it sees, with every original kept in the log.*
31
+
32
+ After [Context Language Models][clm]: a model keeps the context it works best with
33
+ when it may edit that context itself. In pH an edit never rewrites the log. Each
34
+ one is a surface `replace` — the mechanism compaction and paste offload already
35
+ use — so the model's view changes, the log keeps the originals, and the person's
36
+ transcript still shows the conversation they had.
37
+
38
+ ## Sections
39
+
40
+ The model's context is cut into **sections** where no tool call is outstanding: a
41
+ user message, an assistant reply, or an assistant message with every result it
42
+ asked for. Each is named by the seq of its first event (`S412`), which never
43
+ changes, so an id read on one call is good on the next.
44
+
45
+ ## Tools
46
+
47
+ | tool | does |
48
+ |---|---|
49
+ | `context_sections` | the map: each section's id, kind, size, and how much follows it |
50
+ | `context_tombstone` | replace a run of sections with a one-line marker saying why |
51
+ | `context_replace` | replace a run of sections with text the model wrote — a summary, or a rewrite |
52
+ | `context_rewrite` | change one passage inside one section's tool output or reply |
53
+ | `context_restore` | undo a section's latest edit, one level: a rewrite exactly, a removed or replaced run as the text it held |
54
+ | `context_recall` | read back the originals a revised section stands for |
55
+ | `context_diff` | a unified diff of a section against the originals it stands for, after the edits that led there |
56
+
57
+ Every edit reports what it costs: a provider's prefix cache cannot serve anything
58
+ after the first change, so the sections *after* an edit are read again once.
59
+
60
+ ## Readouts
61
+
62
+ When a tool result takes the context past a share of its window, that result ends
63
+ with one line giving the context's size and how to free space. Each share is reported
64
+ once, and again only after an edit brings the context back below it. The shares are
65
+ the `clm-context` row's `remindAt`, `[0.5, 0.75]` by default; `[]` turns readouts off.
66
+ With compaction mounted, the last reminder comes `lastMargin` (0.2) below its trigger
67
+ instead, 65% under the default 0.85, so the model has room to act before a summary is
68
+ written for it. Under Code Mode the readout spells the call out, keywords and all.
69
+
70
+ ## The context file
71
+
72
+ The `clm-mirror` row writes the model's context to a file in the agent's workspace
73
+ scratch before each request, one `[[SECTION S<id>]]` block per section. The model
74
+ edits it with whatever it already uses: `edit`, `sed`, Python in a cell. When the call
75
+ that touched the file finishes, its changes land through the same editor the tools
76
+ use, in one batch, before the call's result. The result carries the receipt.
77
+
78
+ | in the file | becomes |
79
+ |---|---|
80
+ | a section deleted | a tombstone |
81
+ | a tool result's text changed | that result rewritten in place |
82
+ | a step's result lines removed | the step replaced by the text left |
83
+ | a message's text changed | a replacement (a reply is rewritten in place) |
84
+ | no section lines left | one replacement for everything editable |
85
+
86
+ A revised section's header line says what it stands for. The text under it is only
87
+ what the model may edit.
88
+
89
+ A reorder, a damaged header, an edit to a protected section, or a file written before
90
+ the context was last revised is refused. The call's result says why, and the file is
91
+ rewritten.
92
+
93
+ The file is a whole conversation, so it is kept private and short-lived:
94
+ - its directory is 0700 and the file 0600;
95
+ - it is deleted when the session leaves the store;
96
+ - a link put in its place is replaced, never read through.
97
+
98
+ Only an edit a tool call makes is applied. A file changed outside any call (in an
99
+ editor, or by another process) is recorded as declined and rewritten before the next
100
+ request.
101
+
102
+ ```bash
103
+ phern --profile rlm-clm
104
+ ```
105
+
106
+ [clm]: https://arxiv.org/abs/2609.37725
ph_clm-0.9.0/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # ph-clm
2
+
3
+ *The model edits its own context: tombstone, replace and rewrite sections of what
4
+ it sees, with every original kept in the log.*
5
+
6
+ After [Context Language Models][clm]: a model keeps the context it works best with
7
+ when it may edit that context itself. In pH an edit never rewrites the log. Each
8
+ one is a surface `replace` — the mechanism compaction and paste offload already
9
+ use — so the model's view changes, the log keeps the originals, and the person's
10
+ transcript still shows the conversation they had.
11
+
12
+ ## Sections
13
+
14
+ The model's context is cut into **sections** where no tool call is outstanding: a
15
+ user message, an assistant reply, or an assistant message with every result it
16
+ asked for. Each is named by the seq of its first event (`S412`), which never
17
+ changes, so an id read on one call is good on the next.
18
+
19
+ ## Tools
20
+
21
+ | tool | does |
22
+ |---|---|
23
+ | `context_sections` | the map: each section's id, kind, size, and how much follows it |
24
+ | `context_tombstone` | replace a run of sections with a one-line marker saying why |
25
+ | `context_replace` | replace a run of sections with text the model wrote — a summary, or a rewrite |
26
+ | `context_rewrite` | change one passage inside one section's tool output or reply |
27
+ | `context_restore` | undo a section's latest edit, one level: a rewrite exactly, a removed or replaced run as the text it held |
28
+ | `context_recall` | read back the originals a revised section stands for |
29
+ | `context_diff` | a unified diff of a section against the originals it stands for, after the edits that led there |
30
+
31
+ Every edit reports what it costs: a provider's prefix cache cannot serve anything
32
+ after the first change, so the sections *after* an edit are read again once.
33
+
34
+ ## Readouts
35
+
36
+ When a tool result takes the context past a share of its window, that result ends
37
+ with one line giving the context's size and how to free space. Each share is reported
38
+ once, and again only after an edit brings the context back below it. The shares are
39
+ the `clm-context` row's `remindAt`, `[0.5, 0.75]` by default; `[]` turns readouts off.
40
+ With compaction mounted, the last reminder comes `lastMargin` (0.2) below its trigger
41
+ instead, 65% under the default 0.85, so the model has room to act before a summary is
42
+ written for it. Under Code Mode the readout spells the call out, keywords and all.
43
+
44
+ ## The context file
45
+
46
+ The `clm-mirror` row writes the model's context to a file in the agent's workspace
47
+ scratch before each request, one `[[SECTION S<id>]]` block per section. The model
48
+ edits it with whatever it already uses: `edit`, `sed`, Python in a cell. When the call
49
+ that touched the file finishes, its changes land through the same editor the tools
50
+ use, in one batch, before the call's result. The result carries the receipt.
51
+
52
+ | in the file | becomes |
53
+ |---|---|
54
+ | a section deleted | a tombstone |
55
+ | a tool result's text changed | that result rewritten in place |
56
+ | a step's result lines removed | the step replaced by the text left |
57
+ | a message's text changed | a replacement (a reply is rewritten in place) |
58
+ | no section lines left | one replacement for everything editable |
59
+
60
+ A revised section's header line says what it stands for. The text under it is only
61
+ what the model may edit.
62
+
63
+ A reorder, a damaged header, an edit to a protected section, or a file written before
64
+ the context was last revised is refused. The call's result says why, and the file is
65
+ rewritten.
66
+
67
+ The file is a whole conversation, so it is kept private and short-lived:
68
+ - its directory is 0700 and the file 0600;
69
+ - it is deleted when the session leaves the store;
70
+ - a link put in its place is replaced, never read through.
71
+
72
+ Only an edit a tool call makes is applied. A file changed outside any call (in an
73
+ editor, or by another process) is recorded as declined and rewritten before the next
74
+ request.
75
+
76
+ ```bash
77
+ phern --profile rlm-clm
78
+ ```
79
+
80
+ [clm]: https://arxiv.org/abs/2609.37725
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ph-clm"
7
+ version = "0.9.0"
8
+ description = "pH plugin: the model edits its own context — tombstone, replace and rewrite sections of the surface, with the originals kept in the log."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Charles Tabor" }]
14
+ keywords = ["agent", "llm", "context-management", "clm"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Operating System :: MacOS",
23
+ "Topic :: Software Development",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Typing :: Typed",
26
+ ]
27
+ # Nothing but the core: every edit is a surface `replace` ph-core already folds,
28
+ # so a log written with this package opens and derives the same context on an
29
+ # install without it.
30
+ dependencies = ["ph-core==0.9.0"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/chastabor/pH"
34
+ Repository = "https://github.com/chastabor/pH"
35
+ Documentation = "https://github.com/chastabor/pH/blob/main/docs/README.md"
36
+ Issues = "https://github.com/chastabor/pH/issues"
37
+
38
+ [project.entry-points."ph.bundles"]
39
+ clm = "ph_clm:BUNDLE"
40
+
41
+ [project.entry-points."ph.plugins"]
42
+ clm-context = "ph_clm.tools:apply"
43
+ clm-mirror = "ph_clm.mirror:apply"
44
+
45
+ [tool.uv.sources]
46
+ ph-core = { workspace = true }
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/ph_clm"]
@@ -0,0 +1,27 @@
1
+ """pH's CLM bundle: the model manages its own context.
2
+
3
+ After *Context Language Models* (Shao et al., 2026): a model keeps the context it
4
+ works best with when it may edit that context itself. Here an edit is never a
5
+ rewrite of the log. Every one is a surface `replace` ph-core already folds — a
6
+ **tombstone** or a **replace** stands in for a run of whole sections, a
7
+ **rewrite** changes one result or reply in place — so the model's view changes,
8
+ the log keeps every original, and `transcript()` still shows the person what
9
+ happened. `context_diff` and `context_recall` read the originals back, and
10
+ `context_restore` undoes a section's latest edit.
11
+
12
+ Ideas from the paper's harness and from `pi-clm`; no code from either — the CLM
13
+ repository is CC BY-NC, and this package is MIT.
14
+
15
+ @module ph_clm
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from . import kinds as kinds
23
+
24
+ BUNDLE = Path(__file__).parent / "bundle.yaml"
25
+ """The rows the `clm` bundle layers over a profile."""
26
+
27
+ __all__ = ["BUNDLE", "kinds"]
@@ -0,0 +1,120 @@
1
+ """Context readouts: a line on a tool result when the context crosses a share of its
2
+ window, so the model knows to edit before the provider's limit, or compaction, decides
3
+ for it.
4
+
5
+ **On the result, not beside it.** A readout rides on the tool result whose output
6
+ crossed the threshold, appended in `tools/post-execute` as the context file's receipt
7
+ is (pi-clm's `sizeTrailer` does the same). Everything the model is shown is logged
8
+ (I3), and a notice message per threshold would be one more surface node to edit away.
9
+
10
+ **Measured as compaction measures** (`TokenMeter.baseline`): the provider's count of
11
+ the last request — system prompt and tool schemas included — moved by what the
12
+ surface did since, plus the result about to be logged, against the window that request
13
+ was routed with. A receipt's context figures are the same number. No window, no
14
+ readouts: there is no share to report.
15
+
16
+ **The last reminder comes a margin below compaction's trigger**, read from the engine
17
+ (`CompactionSeam.trigger_fraction`), so the model always has room to act before a
18
+ summary is written for it. A fixed last share did not: on a 16k slot, 75% left one
19
+ file read before compaction's 85%, and a live run spent it on a malformed call and
20
+ lost the sections it meant to edit to the summary (`Shares.of`).
21
+
22
+ **Once per crossing, and again after an edit brings the context back down.** Per
23
+ session, `Readouts` keeps how many thresholds the context had reached; a readout fires
24
+ when a result raises that, and each reading sets it, so an edit that frees space lowers
25
+ it and the next crossing is reported again. In memory: after a restart the first
26
+ reading sets it without a readout, so a resumed session is not told again what it was
27
+ told before the restart.
28
+
29
+ @module ph_clm.budget
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Callable, Sequence
35
+ from dataclasses import dataclass, field
36
+
37
+ from ph.text import thousands
38
+
39
+ __all__ = ["Readouts", "Shares"]
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Shares:
44
+ """The thresholds one reading is judged against, ascending, as shares of its
45
+ window — and the share at which compaction summarizes on its own, when an engine
46
+ says."""
47
+
48
+ thresholds: tuple[float, ...]
49
+ window: int
50
+ compacts_at: float | None
51
+
52
+ @classmethod
53
+ def of(
54
+ cls, remind_at: Sequence[float], margin: float, compacts_at: float | None, window: int
55
+ ) -> Shares:
56
+ """`remind_at`, with the last reminder moved to `margin` below compaction's
57
+ trigger: any configured share at or past that point gives way to it. Unchanged
58
+ with no engine, or a margin that would put it at or below zero; empty stays
59
+ empty, which is readouts off."""
60
+ shares = sorted(remind_at)
61
+ # Rounded, so 0.85 - 0.2 is the 0.65 it means rather than 0.6499999999999999.
62
+ last = None if compacts_at is None else round(compacts_at - margin, 6)
63
+ if shares and last is not None and last > 0:
64
+ shares = [*(share for share in shares if share < last), last]
65
+ return cls(tuple(shares), window, compacts_at)
66
+
67
+ def reached(self, tokens: int) -> int:
68
+ """How many thresholds `tokens` is at or past."""
69
+ return sum(1 for share in self.thresholds if tokens >= share * self.window)
70
+
71
+ def line(self, tokens: int, how: str) -> str:
72
+ """What a result carries when the context reached `tokens`: its size, what to
73
+ do, and when the next reminder comes — or that this is the last, and what
74
+ happens after it."""
75
+ level = self.reached(tokens)
76
+ share = tokens * 100 // self.window
77
+ head = f"[context: ~{thousands(tokens)} of {thousands(self.window)} tokens ({share}%)."
78
+ if level >= len(self.thresholds):
79
+ after = (
80
+ ""
81
+ if self.compacts_at is None
82
+ else f" At {round(self.compacts_at * 100)}% older context is summarized for you."
83
+ )
84
+ return f"{head} This is the last reminder: free space now — {how}.{after}]"
85
+ following = round(self.thresholds[level] * 100)
86
+ return (
87
+ f"{head} Free space when it suits you — {how}. "
88
+ f"The next reminder comes at {following}%.]"
89
+ )
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class Readouts:
94
+ """Which thresholds each session's context has reached, and when that rises."""
95
+
96
+ _reached: dict[str, int] = field(default_factory=dict)
97
+
98
+ def crossed(
99
+ self, session_id: str, shares: Shares, before: int, most: int, measure: Callable[[], int]
100
+ ) -> int | None:
101
+ """When a result takes the context from `before` tokens to at most `most`: the
102
+ tokens after, if that reached a threshold this session had not; `None`
103
+ otherwise.
104
+
105
+ `measure` counts the tokens after exactly, and is asked only when the answer
106
+ needs it — when `most` could reach a threshold `before` had not, or when a
107
+ readout fires. Tokenizing a long result is the costly part, and most results
108
+ cross nothing.
109
+ """
110
+ floor = shares.reached(before)
111
+ known = self._reached.get(session_id, floor)
112
+ after = measure() if shares.reached(most) > floor else None
113
+ level = floor if after is None else shares.reached(after)
114
+ self._reached[session_id] = level
115
+ if level <= known:
116
+ return None
117
+ return measure() if after is None else after
118
+
119
+ def forget(self, session_id: str) -> None:
120
+ self._reached.pop(session_id, None)
@@ -0,0 +1,14 @@
1
+ # ph-clm: the model edits its own context, over any profile.
2
+ #
3
+ # Two rows: the section map with its tools, and the context file. Every edit is a
4
+ # surface `replace`, so compaction keeps working beside it — it summarizes the
5
+ # surface, revisions included — and stays the backstop when a session outgrows
6
+ # its window anyway.
7
+
8
+ - id: clm-context
9
+ name: clm-context
10
+
11
+ # The context as a file the model edits with its ordinary tools, read back after
12
+ # each call and landed through the same editor the tools use (`CLM`).
13
+ - id: clm-mirror
14
+ name: clm-mirror