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.
- claimkeep-0.2.0/LICENSE +21 -0
- claimkeep-0.2.0/PKG-INFO +118 -0
- claimkeep-0.2.0/README.md +108 -0
- claimkeep-0.2.0/claimkeep/__init__.py +18 -0
- claimkeep-0.2.0/claimkeep/__main__.py +5 -0
- claimkeep-0.2.0/claimkeep/brief.py +215 -0
- claimkeep-0.2.0/claimkeep/cli.py +335 -0
- claimkeep-0.2.0/claimkeep/config.py +86 -0
- claimkeep-0.2.0/claimkeep/harvesters/__init__.py +40 -0
- claimkeep-0.2.0/claimkeep/harvesters/atomic.py +494 -0
- claimkeep-0.2.0/claimkeep/harvesters/base.py +20 -0
- claimkeep-0.2.0/claimkeep/harvesters/calibration.py +45 -0
- claimkeep-0.2.0/claimkeep/harvesters/lessons.py +88 -0
- claimkeep-0.2.0/claimkeep/harvesters/regex_floor.py +62 -0
- claimkeep-0.2.0/claimkeep/harvesters/retraction.py +145 -0
- claimkeep-0.2.0/claimkeep/lessons.py +108 -0
- claimkeep-0.2.0/claimkeep/prompt.py +40 -0
- claimkeep-0.2.0/claimkeep/redact.py +43 -0
- claimkeep-0.2.0/claimkeep/rehydrate.py +106 -0
- claimkeep-0.2.0/claimkeep/retrieve.py +288 -0
- claimkeep-0.2.0/claimkeep/select.py +149 -0
- claimkeep-0.2.0/claimkeep/stats.py +199 -0
- claimkeep-0.2.0/claimkeep.egg-info/PKG-INFO +118 -0
- claimkeep-0.2.0/claimkeep.egg-info/SOURCES.txt +28 -0
- claimkeep-0.2.0/claimkeep.egg-info/dependency_links.txt +1 -0
- claimkeep-0.2.0/claimkeep.egg-info/entry_points.txt +2 -0
- claimkeep-0.2.0/claimkeep.egg-info/top_level.txt +1 -0
- claimkeep-0.2.0/pyproject.toml +19 -0
- claimkeep-0.2.0/setup.cfg +4 -0
- claimkeep-0.2.0/tests/test_smoke.py +189 -0
claimkeep-0.2.0/LICENSE
ADDED
|
@@ -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.
|
claimkeep-0.2.0/PKG-INFO
ADDED
|
@@ -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,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)
|