claimkeep 0.2.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ravshan Nuraliev
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,118 @@
1
+ Metadata-Version: 2.1
2
+ Name: claimkeep
3
+ Version: 0.2.0
4
+ Summary: Continuous memory for Claude Code context compaction.
5
+ Author: Ravshan Nuraliev
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+
11
+ # ClaimKeep
12
+
13
+ Continuous memory for Claude Code. When the context window compacts, the summary keeps the gist
14
+ and drops the specifics — numbers, paths, ids, and decisions that were later reversed. ClaimKeep
15
+ runs before compaction, takes the agent's own confidence-marked statements **verbatim** instead of
16
+ paraphrasing them, and re-injects them afterwards. It augments native compaction rather than
17
+ replacing it, so it is never worse than the default.
18
+
19
+ The idea it rests on: a calibration marker such as `Ship Friday [C:80%]` turns any factual sentence
20
+ into a claim the agent already selected and already rated. No guessing what mattered. A marker-free
21
+ regex floor still catches paths, ids, and decision lines when a transcript has no markers at all.
22
+ The brief contract is frozen and documented in [docs/BRIEF_SCHEMA.md](docs/BRIEF_SCHEMA.md).
23
+
24
+ Measured in production, not on a benchmark: **at least 326 compactions survived on two independent
25
+ platforms — 283 of them carried facts forward (86.8%), with one confirmed loss.**
26
+ Codex platform: 237 compactions, 84.4% carried facts, 2842 claims retained, one agent.
27
+ Claude Code fleet: 89 compactions, 93.3% carried facts, one real loss in 89 (98.9% clean).
28
+ "At least" is literal: only 5 of the 7 fleet agents write the counters, so the fleet figure is a
29
+ floor rather than a total. Loss is graded on the fleet side only — the Codex side counts
30
+ compactions and claims but does not classify a zero. Measurement windows are 19 and 8 days, ending
31
+ 2026-08-10; the mechanism has been running longer than the instrumentation that counts it.
32
+
33
+ Read the figures above as a property of this setup rather than of the tool on its own: every agent
34
+ measured here already carries calibration markers in its system prompt, and marker density is what
35
+ the mechanism feeds on. A clean install, with no markers in the prompt, is a different environment;
36
+ that second figure is being measured separately and is not in this README yet. Until it is, treat
37
+ these numbers as an instrumented-fleet result, not as what a fresh install should expect.
38
+
39
+ Method and defensible lift numbers are in the paper, *"Continuous Memory for Multi-Agent
40
+ Infrastructure: A Calibration-Density Law for Surviving Context Compaction"* (Ravshan Nuraliev,
41
+ 2026) — <https://zenodo.org/records/20819013>. Please cite the Zenodo record if you use ClaimKeep.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ claude plugin marketplace add rushnur88/claimkeep
47
+ claude plugin install claimkeep
48
+ ```
49
+
50
+ Two commands, and that is the whole install — no `pip install` step, no build, no dependencies:
51
+ the hooks run the bundled package straight from the plugin directory. Requirements are Claude Code
52
+ and Python 3.9+.
53
+
54
+ If you would rather have the CLI on your PATH as well, `pip install .` or `npm install -g .` both
55
+ work, and the hooks will prefer the installed binary when they find one.
56
+
57
+ Note that a memory layer reads your transcript. ClaimKeep runs a secret and PII redaction pass
58
+ before any text enters a brief (API keys, tokens, private-key blocks, JWTs, bearer tokens,
59
+ `key=value` secrets, emails), on by default via `Config.redact`. It targets well-known shapes and
60
+ is defense in depth, not a guarantee — it is not a reason to paste credentials into a session.
61
+
62
+ ## Verify it works
63
+
64
+ Run the hook by hand against the bundled sample transcript. This is exactly what Claude Code runs
65
+ on `PreCompact`:
66
+
67
+ ```bash
68
+ CLAIMKEEP_BRIEF_DIR=/tmp/ck-check ./scripts/precompact.sh <<'EOF'
69
+ {"transcript_path": "examples/sample_transcript.jsonl", "session_id": "verify-001"}
70
+ EOF
71
+ ```
72
+
73
+ It prints the path of the brief it wrote. Confirm the brief exists and holds a claim:
74
+
75
+ ```bash
76
+ cat /tmp/ck-check/*.json
77
+ ```
78
+
79
+ You should see a `claims` array with `Ship ClaimKeep package Friday` at confidence `0.8`, plus a
80
+ `supplement` section with the ids, paths, and decision lines the floor picked up.
81
+
82
+ Then check the other half — re-injection:
83
+
84
+ ```bash
85
+ CLAIMKEEP_BRIEF_DIR=/tmp/ck-check CLAUDE_HOOK_EVENT_NAME=PostCompact ./scripts/postcompact.sh <<< '{}'
86
+ ```
87
+
88
+ It emits the `hookSpecificOutput.additionalContext` payload Claude Code feeds back into the fresh
89
+ window. If both commands produce output, the plugin is wired correctly.
90
+
91
+ Both hooks are fail-open on purpose — a memory layer must never block compaction, so they always
92
+ exit `0`. That means a broken install cannot stall your session, but it also means you should run
93
+ the two checks above once rather than assume silence equals success. Errors go to stderr.
94
+
95
+ Full test suite: `python3 -m unittest discover -s tests` (92 tests, standard library only).
96
+
97
+ ## See what it did
98
+
99
+ `stats` reports across every brief you have stored, not just the last one:
100
+
101
+ ```bash
102
+ claimkeep stats # human-readable
103
+ claimkeep stats --json # same numbers, machine-readable
104
+ ```
105
+
106
+ It answers the question a single brief cannot: is the layer still earning its keep. Two lines matter
107
+ most. **Retractions** counts claims that overturn an earlier statement — a memory layer that keeps a
108
+ refuted claim and drops its refutation is worse than no memory at all. **Confidence-marked** is the
109
+ share of claims that arrived already carrying a `[C:NN%]` marker; when that share falls, the
110
+ convention is eroding and the calibration harvester quietly runs out of input.
111
+
112
+ If your briefs came from a different collector, `stats` reports retractions as *not measurable*
113
+ rather than as zero. A zero you cannot distinguish from "never happened" is the failure mode this
114
+ package is built around, and the report is not allowed to produce one.
115
+
116
+ ---
117
+
118
+ Developed by Ravshan Nuraliev. MIT licensed.
@@ -0,0 +1,108 @@
1
+ # ClaimKeep
2
+
3
+ Continuous memory for Claude Code. When the context window compacts, the summary keeps the gist
4
+ and drops the specifics — numbers, paths, ids, and decisions that were later reversed. ClaimKeep
5
+ runs before compaction, takes the agent's own confidence-marked statements **verbatim** instead of
6
+ paraphrasing them, and re-injects them afterwards. It augments native compaction rather than
7
+ replacing it, so it is never worse than the default.
8
+
9
+ The idea it rests on: a calibration marker such as `Ship Friday [C:80%]` turns any factual sentence
10
+ into a claim the agent already selected and already rated. No guessing what mattered. A marker-free
11
+ regex floor still catches paths, ids, and decision lines when a transcript has no markers at all.
12
+ The brief contract is frozen and documented in [docs/BRIEF_SCHEMA.md](docs/BRIEF_SCHEMA.md).
13
+
14
+ Measured in production, not on a benchmark: **at least 326 compactions survived on two independent
15
+ platforms — 283 of them carried facts forward (86.8%), with one confirmed loss.**
16
+ Codex platform: 237 compactions, 84.4% carried facts, 2842 claims retained, one agent.
17
+ Claude Code fleet: 89 compactions, 93.3% carried facts, one real loss in 89 (98.9% clean).
18
+ "At least" is literal: only 5 of the 7 fleet agents write the counters, so the fleet figure is a
19
+ floor rather than a total. Loss is graded on the fleet side only — the Codex side counts
20
+ compactions and claims but does not classify a zero. Measurement windows are 19 and 8 days, ending
21
+ 2026-08-10; the mechanism has been running longer than the instrumentation that counts it.
22
+
23
+ Read the figures above as a property of this setup rather than of the tool on its own: every agent
24
+ measured here already carries calibration markers in its system prompt, and marker density is what
25
+ the mechanism feeds on. A clean install, with no markers in the prompt, is a different environment;
26
+ that second figure is being measured separately and is not in this README yet. Until it is, treat
27
+ these numbers as an instrumented-fleet result, not as what a fresh install should expect.
28
+
29
+ Method and defensible lift numbers are in the paper, *"Continuous Memory for Multi-Agent
30
+ Infrastructure: A Calibration-Density Law for Surviving Context Compaction"* (Ravshan Nuraliev,
31
+ 2026) — <https://zenodo.org/records/20819013>. Please cite the Zenodo record if you use ClaimKeep.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ claude plugin marketplace add rushnur88/claimkeep
37
+ claude plugin install claimkeep
38
+ ```
39
+
40
+ Two commands, and that is the whole install — no `pip install` step, no build, no dependencies:
41
+ the hooks run the bundled package straight from the plugin directory. Requirements are Claude Code
42
+ and Python 3.9+.
43
+
44
+ If you would rather have the CLI on your PATH as well, `pip install .` or `npm install -g .` both
45
+ work, and the hooks will prefer the installed binary when they find one.
46
+
47
+ Note that a memory layer reads your transcript. ClaimKeep runs a secret and PII redaction pass
48
+ before any text enters a brief (API keys, tokens, private-key blocks, JWTs, bearer tokens,
49
+ `key=value` secrets, emails), on by default via `Config.redact`. It targets well-known shapes and
50
+ is defense in depth, not a guarantee — it is not a reason to paste credentials into a session.
51
+
52
+ ## Verify it works
53
+
54
+ Run the hook by hand against the bundled sample transcript. This is exactly what Claude Code runs
55
+ on `PreCompact`:
56
+
57
+ ```bash
58
+ CLAIMKEEP_BRIEF_DIR=/tmp/ck-check ./scripts/precompact.sh <<'EOF'
59
+ {"transcript_path": "examples/sample_transcript.jsonl", "session_id": "verify-001"}
60
+ EOF
61
+ ```
62
+
63
+ It prints the path of the brief it wrote. Confirm the brief exists and holds a claim:
64
+
65
+ ```bash
66
+ cat /tmp/ck-check/*.json
67
+ ```
68
+
69
+ You should see a `claims` array with `Ship ClaimKeep package Friday` at confidence `0.8`, plus a
70
+ `supplement` section with the ids, paths, and decision lines the floor picked up.
71
+
72
+ Then check the other half — re-injection:
73
+
74
+ ```bash
75
+ CLAIMKEEP_BRIEF_DIR=/tmp/ck-check CLAUDE_HOOK_EVENT_NAME=PostCompact ./scripts/postcompact.sh <<< '{}'
76
+ ```
77
+
78
+ It emits the `hookSpecificOutput.additionalContext` payload Claude Code feeds back into the fresh
79
+ window. If both commands produce output, the plugin is wired correctly.
80
+
81
+ Both hooks are fail-open on purpose — a memory layer must never block compaction, so they always
82
+ exit `0`. That means a broken install cannot stall your session, but it also means you should run
83
+ the two checks above once rather than assume silence equals success. Errors go to stderr.
84
+
85
+ Full test suite: `python3 -m unittest discover -s tests` (92 tests, standard library only).
86
+
87
+ ## See what it did
88
+
89
+ `stats` reports across every brief you have stored, not just the last one:
90
+
91
+ ```bash
92
+ claimkeep stats # human-readable
93
+ claimkeep stats --json # same numbers, machine-readable
94
+ ```
95
+
96
+ It answers the question a single brief cannot: is the layer still earning its keep. Two lines matter
97
+ most. **Retractions** counts claims that overturn an earlier statement — a memory layer that keeps a
98
+ refuted claim and drops its refutation is worse than no memory at all. **Confidence-marked** is the
99
+ share of claims that arrived already carrying a `[C:NN%]` marker; when that share falls, the
100
+ convention is eroding and the calibration harvester quietly runs out of input.
101
+
102
+ If your briefs came from a different collector, `stats` reports retractions as *not measurable*
103
+ rather than as zero. A zero you cannot distinguish from "never happened" is the failure mode this
104
+ package is built around, and the report is not allowed to produce one.
105
+
106
+ ---
107
+
108
+ Developed by Ravshan Nuraliev. MIT licensed.
@@ -0,0 +1,18 @@
1
+ """ClaimKeep public API."""
2
+
3
+ from .brief import Brief, Claim, Supplement, make_id, normalize
4
+ from .config import default_config
5
+ from . import harvesters
6
+
7
+ __version__ = "0.2.0"
8
+
9
+ __all__ = [
10
+ "__version__",
11
+ "Brief",
12
+ "Claim",
13
+ "Supplement",
14
+ "normalize",
15
+ "make_id",
16
+ "default_config",
17
+ "harvesters",
18
+ ]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,215 @@
1
+ """Brief schema v1 primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import hashlib
8
+ import unicodedata
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, Iterable, List, Optional
11
+
12
+
13
+ SCHEMA_VERSION = 1
14
+
15
+
16
+ def normalize(text: str) -> str:
17
+ """Normalize text for deterministic id hashing only."""
18
+ return re.sub(r"\s+", " ", unicodedata.normalize("NFC", text).casefold().strip())
19
+
20
+
21
+ def make_id(source_harvester: str, middle: str, text: str) -> str:
22
+ payload = source_harvester + "|" + middle + "|" + normalize(text)
23
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:16]
24
+
25
+
26
+ @dataclass
27
+ class Claim:
28
+ text: str
29
+ confidence: Optional[float]
30
+ topic: str
31
+ source_harvester: str
32
+ ts: Optional[str] = None
33
+ source_span: Optional[str] = None
34
+ id: Optional[str] = None
35
+ # Supersession chain. A later claim on the same topic does NOT delete the
36
+ # earlier one: the earlier one is marked superseded_by and kept. Dropping it
37
+ # silently makes a retraction indistinguishable from a fact never stated,
38
+ # and the reader cannot tell which of two conflicting facts is live.
39
+ superseded_by: Optional[str] = None
40
+ supersedes: Optional[str] = None
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.confidence is not None:
44
+ self.confidence = max(0.0, min(1.0, float(self.confidence)))
45
+ if self.id is None:
46
+ self.id = make_id(self.source_harvester, self.topic, self.text)
47
+
48
+ @property
49
+ def is_active(self) -> bool:
50
+ """True when nothing later has superseded this claim."""
51
+ return self.superseded_by is None
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ return {
55
+ "id": self.id,
56
+ "text": self.text,
57
+ "confidence": self.confidence,
58
+ "topic": self.topic,
59
+ "source_harvester": self.source_harvester,
60
+ "ts": self.ts,
61
+ "source_span": self.source_span,
62
+ "superseded_by": self.superseded_by,
63
+ "supersedes": self.supersedes,
64
+ }
65
+
66
+ @classmethod
67
+ def from_dict(cls, data: Dict[str, Any]) -> "Claim":
68
+ return cls(
69
+ id=str(data["id"]),
70
+ text=str(data["text"]),
71
+ confidence=None if data.get("confidence") is None else float(data["confidence"]),
72
+ topic=str(data["topic"]),
73
+ source_harvester=str(data["source_harvester"]),
74
+ ts=data.get("ts"),
75
+ source_span=data.get("source_span"),
76
+ superseded_by=data.get("superseded_by"),
77
+ supersedes=data.get("supersedes"),
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class Supplement:
83
+ text: str
84
+ kind: str
85
+ source_harvester: str
86
+ id: Optional[str] = None
87
+
88
+ def __post_init__(self) -> None:
89
+ if self.kind not in {"id", "path", "decision"}:
90
+ raise ValueError("supplement kind must be one of: id, path, decision")
91
+ if self.id is None:
92
+ self.id = make_id(self.source_harvester, self.kind, self.text)
93
+
94
+ def to_dict(self) -> Dict[str, Any]:
95
+ return {
96
+ "id": self.id,
97
+ "text": self.text,
98
+ "kind": self.kind,
99
+ "source_harvester": self.source_harvester,
100
+ }
101
+
102
+ @classmethod
103
+ def from_dict(cls, data: Dict[str, Any]) -> "Supplement":
104
+ return cls(
105
+ id=str(data["id"]),
106
+ text=str(data["text"]),
107
+ kind=str(data["kind"]),
108
+ source_harvester=str(data["source_harvester"]),
109
+ )
110
+
111
+
112
+ @dataclass
113
+ class Brief:
114
+ claims: List[Claim] = field(default_factory=list)
115
+ supplement: List[Supplement] = field(default_factory=list)
116
+ created_utc: Optional[str] = None
117
+ source: Optional[Dict[str, Any]] = None
118
+ open_threads: List[str] = field(default_factory=list)
119
+ last_user_ask: Optional[str] = None
120
+ narrative: List[str] = field(default_factory=list)
121
+ schema_version: int = SCHEMA_VERSION
122
+
123
+ def __post_init__(self) -> None:
124
+ if self.schema_version != SCHEMA_VERSION:
125
+ raise ValueError("unsupported schema_version")
126
+ self.claims = self._dedup_claims(self.claims)
127
+ self.supplement = self._dedup_supplement(self.supplement)
128
+
129
+ def add_claim(self, claim: Claim) -> None:
130
+ self.claims.append(claim)
131
+ self.claims = self._dedup_claims(self.claims)
132
+
133
+ def add_supplement(self, supplement: Supplement) -> None:
134
+ self.supplement.append(supplement)
135
+ self.supplement = self._dedup_supplement(self.supplement)
136
+
137
+ @property
138
+ def active_claims(self) -> List[Claim]:
139
+ return [claim for claim in self.claims if claim.is_active]
140
+
141
+ @staticmethod
142
+ def _dedup_claims(claims: Iterable[Claim]) -> List[Claim]:
143
+ """Collapse exact repeats, mark same-topic history as superseded.
144
+
145
+ Two passes. Identical ids are the same claim restated, so only the last
146
+ occurrence survives. Different claims on the same topic are a change of
147
+ position over time: the newest stays active and every earlier one is
148
+ kept with superseded_by pointing at it, newest carrying supersedes back.
149
+ """
150
+ latest_by_id: Dict[str, tuple[int, Claim]] = {}
151
+ for index, claim in enumerate(claims):
152
+ latest_by_id[str(claim.id)] = (index, claim)
153
+ ordered = [item for item in sorted(latest_by_id.values(), key=lambda item: item[0])]
154
+
155
+ by_topic: Dict[str, List[Claim]] = {}
156
+ for _, claim in ordered:
157
+ by_topic.setdefault(claim.topic, []).append(claim)
158
+
159
+ for topic_claims in by_topic.values():
160
+ newest = topic_claims[-1]
161
+ newest.superseded_by = None
162
+ if len(topic_claims) > 1:
163
+ newest.supersedes = topic_claims[-2].id
164
+ for earlier in topic_claims[:-1]:
165
+ earlier.superseded_by = newest.id
166
+ return [claim for _, claim in ordered]
167
+
168
+ @staticmethod
169
+ def _dedup_supplement(supplements: Iterable[Supplement]) -> List[Supplement]:
170
+ by_id: Dict[str, Supplement] = {}
171
+ for item in supplements:
172
+ by_id[str(item.id)] = item
173
+ return list(by_id.values())
174
+
175
+ def to_dict(self) -> Dict[str, Any]:
176
+ return {
177
+ "schema_version": self.schema_version,
178
+ "created_utc": self.created_utc,
179
+ "source": self.source,
180
+ "claims": [claim.to_dict() for claim in self.claims],
181
+ "supplement": [item.to_dict() for item in self.supplement],
182
+ "open_threads": list(self.open_threads),
183
+ "last_user_ask": self.last_user_ask,
184
+ "narrative": list(self.narrative),
185
+ }
186
+
187
+ def to_json(self) -> str:
188
+ return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
189
+
190
+ @classmethod
191
+ def from_dict(cls, data: Dict[str, Any]) -> "Brief":
192
+ if data.get("schema_version") != SCHEMA_VERSION:
193
+ raise ValueError("unsupported schema_version")
194
+ for key in ("claims", "supplement"):
195
+ if key not in data:
196
+ raise ValueError("missing required key: " + key)
197
+ return cls(
198
+ schema_version=SCHEMA_VERSION,
199
+ created_utc=data.get("created_utc"),
200
+ source=data.get("source"),
201
+ claims=[Claim.from_dict(item) for item in data.get("claims", [])],
202
+ supplement=[Supplement.from_dict(item) for item in data.get("supplement", [])],
203
+ open_threads=list(data.get("open_threads", [])),
204
+ last_user_ask=data.get("last_user_ask"),
205
+ narrative=list(data.get("narrative", [])),
206
+ )
207
+
208
+ @classmethod
209
+ def from_json(cls, text: str) -> "Brief":
210
+ return cls.from_dict(json.loads(text))
211
+
212
+ def render(self) -> str:
213
+ from .rehydrate import render
214
+
215
+ return render(self)