claimkeep 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claimkeep/__init__.py +18 -0
- claimkeep/__main__.py +5 -0
- claimkeep/brief.py +215 -0
- claimkeep/cli.py +335 -0
- claimkeep/config.py +86 -0
- claimkeep/harvesters/__init__.py +40 -0
- claimkeep/harvesters/atomic.py +494 -0
- claimkeep/harvesters/base.py +20 -0
- claimkeep/harvesters/calibration.py +45 -0
- claimkeep/harvesters/lessons.py +88 -0
- claimkeep/harvesters/regex_floor.py +62 -0
- claimkeep/harvesters/retraction.py +145 -0
- claimkeep/lessons.py +108 -0
- claimkeep/prompt.py +40 -0
- claimkeep/redact.py +43 -0
- claimkeep/rehydrate.py +106 -0
- claimkeep/retrieve.py +288 -0
- claimkeep/select.py +149 -0
- claimkeep/stats.py +199 -0
- claimkeep-0.2.0.dist-info/LICENSE +21 -0
- claimkeep-0.2.0.dist-info/METADATA +118 -0
- claimkeep-0.2.0.dist-info/RECORD +25 -0
- claimkeep-0.2.0.dist-info/WHEEL +5 -0
- claimkeep-0.2.0.dist-info/entry_points.txt +2 -0
- claimkeep-0.2.0.dist-info/top_level.txt +1 -0
claimkeep/__init__.py
ADDED
|
@@ -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
|
+
]
|
claimkeep/__main__.py
ADDED
claimkeep/brief.py
ADDED
|
@@ -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)
|
claimkeep/cli.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Command line interface for ClaimKeep."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime as _datetime
|
|
7
|
+
import glob
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .brief import Brief, Claim, Supplement
|
|
15
|
+
from .config import default_config
|
|
16
|
+
from .harvesters import get_harvester
|
|
17
|
+
from .harvesters.lessons import LessonHarvester
|
|
18
|
+
from .lessons import Lesson, LessonStore
|
|
19
|
+
from .prompt import marker_instruction
|
|
20
|
+
from .redact import redact
|
|
21
|
+
from .rehydrate import postcompact_payload
|
|
22
|
+
from .retrieve import recall
|
|
23
|
+
from .select import apply_budget
|
|
24
|
+
from .stats import collect as collect_stats, render as render_stats
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_iso() -> str:
|
|
28
|
+
return _datetime.datetime.now(_datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_text(obj: Dict[str, Any]) -> Optional[str]:
|
|
32
|
+
for key in ("text", "content"):
|
|
33
|
+
value = obj.get(key)
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
return value
|
|
36
|
+
message = obj.get("message")
|
|
37
|
+
if isinstance(message, str):
|
|
38
|
+
return message
|
|
39
|
+
if isinstance(message, dict):
|
|
40
|
+
content = message.get("content")
|
|
41
|
+
if isinstance(content, str):
|
|
42
|
+
return content
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
parts: List[str] = []
|
|
45
|
+
for item in content:
|
|
46
|
+
if isinstance(item, str):
|
|
47
|
+
parts.append(item)
|
|
48
|
+
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
49
|
+
parts.append(item["text"])
|
|
50
|
+
if parts:
|
|
51
|
+
return "\n".join(parts)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_transcript(path: str) -> List[str]:
|
|
56
|
+
units: List[str] = []
|
|
57
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
58
|
+
for line in handle:
|
|
59
|
+
try:
|
|
60
|
+
obj = json.loads(line)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
continue
|
|
63
|
+
if not isinstance(obj, dict):
|
|
64
|
+
continue
|
|
65
|
+
text = _extract_text(obj)
|
|
66
|
+
if text:
|
|
67
|
+
units.append(text)
|
|
68
|
+
return units
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_hook_stdin() -> Dict[str, Any]:
|
|
72
|
+
try:
|
|
73
|
+
raw = sys.stdin.read()
|
|
74
|
+
if not raw.strip():
|
|
75
|
+
return {}
|
|
76
|
+
data = json.loads(raw)
|
|
77
|
+
return data if isinstance(data, dict) else {}
|
|
78
|
+
except (OSError, json.JSONDecodeError):
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _newest_brief(brief_dir: str) -> Optional[str]:
|
|
83
|
+
paths = glob.glob(os.path.join(brief_dir, "*.json"))
|
|
84
|
+
if not paths:
|
|
85
|
+
return None
|
|
86
|
+
return max(paths, key=os.path.getmtime)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _carry_lessons(
|
|
90
|
+
claims: List[Claim], config: Any, created_utc: str, source: Dict[str, Any]
|
|
91
|
+
) -> List[Claim]:
|
|
92
|
+
"""Persist lessons found this session, then carry the store's newest forward.
|
|
93
|
+
|
|
94
|
+
Lessons are the one thing that must not be scoped to a single brief: a rule
|
|
95
|
+
learned on Monday is worthless if it is gone by Friday. Everything else in
|
|
96
|
+
the brief describes this session; lessons describe how to work in the next.
|
|
97
|
+
Failure here is never fatal — a broken store must not cost the whole brief.
|
|
98
|
+
"""
|
|
99
|
+
if not getattr(config, "lessons_enabled", True):
|
|
100
|
+
return claims
|
|
101
|
+
prefix = LessonHarvester.TOPIC + ":"
|
|
102
|
+
fresh = [claim for claim in claims if claim.topic.startswith(prefix)]
|
|
103
|
+
try:
|
|
104
|
+
store = LessonStore(config.expanded_lessons_path())
|
|
105
|
+
store.append([Lesson(text=claim.text, ts=created_utc, session=source.get("session"))
|
|
106
|
+
for claim in fresh])
|
|
107
|
+
carried = store.recent(int(getattr(config, "lessons_in_brief", 0) or 0))
|
|
108
|
+
except OSError:
|
|
109
|
+
return claims
|
|
110
|
+
|
|
111
|
+
known = {claim.text.casefold() for claim in fresh}
|
|
112
|
+
for lesson in carried:
|
|
113
|
+
if lesson.text.casefold() in known:
|
|
114
|
+
continue
|
|
115
|
+
known.add(lesson.text.casefold())
|
|
116
|
+
claims.append(
|
|
117
|
+
Claim(
|
|
118
|
+
text=lesson.text,
|
|
119
|
+
confidence=None,
|
|
120
|
+
topic=prefix + lesson.text.casefold()[:40],
|
|
121
|
+
source_harvester=LessonHarvester.name,
|
|
122
|
+
ts=lesson.ts,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return claims
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_brief(transcript: List[str], created_utc: str, source: Dict[str, Any]) -> Brief:
|
|
129
|
+
config = default_config()
|
|
130
|
+
if getattr(config, "redact", True):
|
|
131
|
+
transcript = [redact(unit) for unit in transcript]
|
|
132
|
+
claims: List[Claim] = []
|
|
133
|
+
supplements: List[Supplement] = []
|
|
134
|
+
# harvest_enabled=False yields an empty (naive) brief — the control arm for
|
|
135
|
+
# a control-vs-treatment evaluation.
|
|
136
|
+
if getattr(config, "harvest_enabled", True):
|
|
137
|
+
for name in config.harvesters:
|
|
138
|
+
harvester = get_harvester(name)()
|
|
139
|
+
for item in harvester.harvest(transcript, config):
|
|
140
|
+
if isinstance(item, Claim):
|
|
141
|
+
claims.append(item)
|
|
142
|
+
elif isinstance(item, Supplement):
|
|
143
|
+
supplements.append(item)
|
|
144
|
+
claims = _carry_lessons(claims, config, created_utc, source)
|
|
145
|
+
brief = Brief(created_utc=created_utc, source=source, claims=claims, supplement=supplements)
|
|
146
|
+
# Bound the brief before it is written. Without this the brief is the whole
|
|
147
|
+
# harvest and cannot be re-injected into the window it is meant to restore.
|
|
148
|
+
return apply_budget(brief, int(getattr(config, "budget_chars", 0) or 0))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _probe_log(brief: Brief, source: Dict[str, Any], created_utc: str) -> None:
|
|
152
|
+
"""Append one JSONL record per PreCompact when CLAIMKEEP_PROBE_LOG is set.
|
|
153
|
+
|
|
154
|
+
Records the full reinjected brief, the harvest_enabled flag, and a
|
|
155
|
+
session/corpus/timestamp header so control and treatment runs over the same
|
|
156
|
+
corpus produce machine-distinguishable artifacts. Best-effort; never raises.
|
|
157
|
+
"""
|
|
158
|
+
path = os.environ.get("CLAIMKEEP_PROBE_LOG")
|
|
159
|
+
if not path:
|
|
160
|
+
return
|
|
161
|
+
try:
|
|
162
|
+
record = {
|
|
163
|
+
"ts": created_utc,
|
|
164
|
+
"session_id": source.get("session"),
|
|
165
|
+
"corpus_id": os.environ.get("CLAIMKEEP_CORPUS_ID"),
|
|
166
|
+
"harvest_enabled": bool(getattr(default_config(), "harvest_enabled", True)),
|
|
167
|
+
"brief": brief.to_dict(),
|
|
168
|
+
}
|
|
169
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
170
|
+
if parent:
|
|
171
|
+
os.makedirs(parent, exist_ok=True)
|
|
172
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
173
|
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
174
|
+
except Exception:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cmd_version(_args: argparse.Namespace) -> int:
|
|
179
|
+
print(__version__)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _cmd_markers(_args: argparse.Namespace) -> int:
|
|
184
|
+
"""Print the confidence-marker instruction for an agent's system prompt.
|
|
185
|
+
|
|
186
|
+
The calibration harvester is only as good as the markers it finds, so the
|
|
187
|
+
plugin ships the convention instead of assuming it.
|
|
188
|
+
"""
|
|
189
|
+
print(marker_instruction())
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
194
|
+
"""Report what the layer actually did, counted from the stored briefs."""
|
|
195
|
+
report = collect_stats(default_config())
|
|
196
|
+
print(json.dumps(report, ensure_ascii=False, indent=2) if args.json else render_stats(report), end="")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cmd_recall(args: argparse.Namespace) -> int:
|
|
201
|
+
"""Search every stored brief and lesson, not just the most recent one."""
|
|
202
|
+
config = default_config()
|
|
203
|
+
rows = recall(args.query, config, limit=args.limit, budget_chars=args.budget)
|
|
204
|
+
if args.json:
|
|
205
|
+
print(json.dumps(
|
|
206
|
+
[{"text": row["doc"].text, "kind": row["doc"].kind, "id": row["doc"].id,
|
|
207
|
+
"ts": row["doc"].ts, "superseded": row["doc"].superseded,
|
|
208
|
+
"score": row["score"], "bm25": row["bm25"], "source": row["doc"].source}
|
|
209
|
+
for row in rows],
|
|
210
|
+
ensure_ascii=False, indent=2))
|
|
211
|
+
return 0
|
|
212
|
+
if not rows:
|
|
213
|
+
print("no match")
|
|
214
|
+
return 0
|
|
215
|
+
for row in rows:
|
|
216
|
+
doc = row["doc"]
|
|
217
|
+
flag = " [superseded]" if doc.superseded else ""
|
|
218
|
+
print(f"- ({doc.kind}, {row['score']}){flag} {doc.text}")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cmd_lessons(args: argparse.Namespace) -> int:
|
|
223
|
+
config = default_config()
|
|
224
|
+
store = LessonStore(config.expanded_lessons_path())
|
|
225
|
+
if args.add:
|
|
226
|
+
written = store.append([Lesson(text=args.add, ts=_now_iso())])
|
|
227
|
+
print("stored" if written else "already stored")
|
|
228
|
+
return 0
|
|
229
|
+
lessons = store.recent(args.limit)
|
|
230
|
+
if args.json:
|
|
231
|
+
print(json.dumps([lesson.to_dict() for lesson in lessons], ensure_ascii=False, indent=2))
|
|
232
|
+
return 0
|
|
233
|
+
if not lessons:
|
|
234
|
+
print("no lessons stored yet")
|
|
235
|
+
return 0
|
|
236
|
+
for lesson in lessons:
|
|
237
|
+
stamp = lesson.ts or "unknown time"
|
|
238
|
+
print(f"- [{stamp}] {lesson.text}")
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _cmd_precompact(args: argparse.Namespace) -> int:
|
|
243
|
+
try:
|
|
244
|
+
hook = {} if args.transcript else _read_hook_stdin()
|
|
245
|
+
transcript_path = args.transcript or hook.get("transcript_path")
|
|
246
|
+
if not transcript_path:
|
|
247
|
+
return 0
|
|
248
|
+
transcript = _read_transcript(str(transcript_path))
|
|
249
|
+
created_utc = args.now or _now_iso()
|
|
250
|
+
source = {
|
|
251
|
+
"agent": str(hook.get("agent", "claude-code")),
|
|
252
|
+
"session": hook.get("session_id") or hook.get("sessionId") or hook.get("session"),
|
|
253
|
+
}
|
|
254
|
+
brief = _build_brief(transcript, created_utc, source)
|
|
255
|
+
out = args.out
|
|
256
|
+
if not out:
|
|
257
|
+
config = default_config()
|
|
258
|
+
brief_dir = config.expanded_brief_dir()
|
|
259
|
+
os.makedirs(brief_dir, exist_ok=True)
|
|
260
|
+
stamp = created_utc.replace(":", "").replace("-", "")
|
|
261
|
+
session = source.get("session") or "session"
|
|
262
|
+
out = os.path.join(brief_dir, f"{stamp}-{session}.json")
|
|
263
|
+
else:
|
|
264
|
+
parent = os.path.dirname(os.path.abspath(out))
|
|
265
|
+
if parent:
|
|
266
|
+
os.makedirs(parent, exist_ok=True)
|
|
267
|
+
with open(out, "w", encoding="utf-8") as handle:
|
|
268
|
+
handle.write(brief.to_json())
|
|
269
|
+
_probe_log(brief, source, created_utc)
|
|
270
|
+
print(out)
|
|
271
|
+
return 0
|
|
272
|
+
except Exception:
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _cmd_postcompact(args: argparse.Namespace) -> int:
|
|
277
|
+
try:
|
|
278
|
+
brief_path = args.brief
|
|
279
|
+
if not brief_path:
|
|
280
|
+
brief_path = _newest_brief(default_config().expanded_brief_dir())
|
|
281
|
+
if not brief_path:
|
|
282
|
+
return 0
|
|
283
|
+
with open(brief_path, "r", encoding="utf-8") as handle:
|
|
284
|
+
brief = Brief.from_json(handle.read())
|
|
285
|
+
print(json.dumps(postcompact_payload(brief, args.event), ensure_ascii=False))
|
|
286
|
+
return 0
|
|
287
|
+
except Exception:
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
292
|
+
parser = argparse.ArgumentParser(prog="claimkeep")
|
|
293
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
294
|
+
|
|
295
|
+
version = sub.add_parser("version")
|
|
296
|
+
version.set_defaults(func=_cmd_version)
|
|
297
|
+
|
|
298
|
+
markers = sub.add_parser("markers", help="print the confidence-marker instruction")
|
|
299
|
+
markers.set_defaults(func=_cmd_markers)
|
|
300
|
+
|
|
301
|
+
stats_cmd = sub.add_parser("stats", help="report what the layer did across every stored brief")
|
|
302
|
+
stats_cmd.add_argument("--json", action="store_true")
|
|
303
|
+
stats_cmd.set_defaults(func=_cmd_stats)
|
|
304
|
+
|
|
305
|
+
recall_cmd = sub.add_parser("recall", help="search every stored brief and lesson")
|
|
306
|
+
recall_cmd.add_argument("query")
|
|
307
|
+
recall_cmd.add_argument("--limit", type=int, default=10)
|
|
308
|
+
recall_cmd.add_argument("--budget", type=int, default=0, help="cap the result set in characters")
|
|
309
|
+
recall_cmd.add_argument("--json", action="store_true")
|
|
310
|
+
recall_cmd.set_defaults(func=_cmd_recall)
|
|
311
|
+
|
|
312
|
+
lessons = sub.add_parser("lessons", help="list or add durable lessons")
|
|
313
|
+
lessons.add_argument("--limit", type=int, default=20)
|
|
314
|
+
lessons.add_argument("--add", help="store a lesson verbatim")
|
|
315
|
+
lessons.add_argument("--json", action="store_true")
|
|
316
|
+
lessons.set_defaults(func=_cmd_lessons)
|
|
317
|
+
|
|
318
|
+
precompact = sub.add_parser("precompact")
|
|
319
|
+
precompact.add_argument("--transcript")
|
|
320
|
+
precompact.add_argument("--out")
|
|
321
|
+
precompact.add_argument("--now")
|
|
322
|
+
precompact.set_defaults(func=_cmd_precompact)
|
|
323
|
+
|
|
324
|
+
postcompact = sub.add_parser("postcompact")
|
|
325
|
+
postcompact.add_argument("--brief")
|
|
326
|
+
postcompact.add_argument("--event", choices=("SessionStart", "PostCompact"), default="SessionStart")
|
|
327
|
+
postcompact.set_defaults(func=_cmd_postcompact)
|
|
328
|
+
|
|
329
|
+
return parser
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
333
|
+
parser = build_parser()
|
|
334
|
+
args = parser.parse_args(argv)
|
|
335
|
+
return int(args.func(args))
|
claimkeep/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration for ClaimKeep harvesters and storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_BRIEF_DIR = "~/.claude/plugins/data/claimkeep/briefs"
|
|
12
|
+
DEFAULT_LESSONS_PATH = "~/.claude/plugins/data/claimkeep/lessons.jsonl"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Config:
|
|
17
|
+
# `atomic` runs first and carries the load on ordinary prose: measured on
|
|
18
|
+
# LongMemEval it lifted R@10 from 0.456 to 0.926. The other three stay
|
|
19
|
+
# because they catch what prose rules cannot — markers, paths, ids, lessons —
|
|
20
|
+
# and that is most of what an agent transcript is made of.
|
|
21
|
+
harvesters: List[str] = field(
|
|
22
|
+
default_factory=lambda: ["retraction", "atomic", "calibration", "regex_floor", "lessons"]
|
|
23
|
+
)
|
|
24
|
+
# Accepts both the bare marker `[C:80%]` and the extended form that carries
|
|
25
|
+
# an evidence pointer, `[C:80%, basis: read the file]`. The extended form is
|
|
26
|
+
# what long-running agents actually emit; a regex that only matched the bare
|
|
27
|
+
# form silently harvested zero claims from real transcripts.
|
|
28
|
+
calibration_marker_regex: str = r"\[C:\s*(\d{1,3})\s*%[^\]]*\]"
|
|
29
|
+
floor_paths: bool = True
|
|
30
|
+
floor_ids: bool = True
|
|
31
|
+
floor_decisions: bool = True
|
|
32
|
+
redact: bool = True
|
|
33
|
+
harvest_enabled: bool = True
|
|
34
|
+
brief_dir: str = DEFAULT_BRIEF_DIR
|
|
35
|
+
# Character budget for the assembled brief. The brief is re-injected into
|
|
36
|
+
# the context window the compaction just freed, so it must be bounded or it
|
|
37
|
+
# defeats its own purpose. 0 disables the cap (raw harvest, evaluation only).
|
|
38
|
+
# 12000 chars is roughly the size of a native compaction summary.
|
|
39
|
+
budget_chars: int = 12000
|
|
40
|
+
# Lessons outlive the session: harvested lessons are appended to an
|
|
41
|
+
# append-only store and the most recent ones are carried into every later
|
|
42
|
+
# brief, so a rule learned on Monday is still in front of the agent on Friday.
|
|
43
|
+
lessons_enabled: bool = True
|
|
44
|
+
lessons_path: str = DEFAULT_LESSONS_PATH
|
|
45
|
+
lessons_in_brief: int = 10
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_file(cls, path: str) -> "Config":
|
|
49
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
50
|
+
data = json.load(handle)
|
|
51
|
+
cfg = cls()
|
|
52
|
+
for key, value in data.items():
|
|
53
|
+
if hasattr(cfg, key):
|
|
54
|
+
setattr(cfg, key, value)
|
|
55
|
+
return cfg
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_env(cls) -> "Config":
|
|
59
|
+
cfg = cls()
|
|
60
|
+
if os.environ.get("CLAIMKEEP_BRIEF_DIR"):
|
|
61
|
+
cfg.brief_dir = os.environ["CLAIMKEEP_BRIEF_DIR"]
|
|
62
|
+
harvest = os.environ.get("CLAIMKEEP_HARVEST")
|
|
63
|
+
if harvest is not None and harvest.strip().lower() in ("0", "false", "off", "no"):
|
|
64
|
+
cfg.harvest_enabled = False
|
|
65
|
+
budget = os.environ.get("CLAIMKEEP_BUDGET_CHARS")
|
|
66
|
+
if budget is not None:
|
|
67
|
+
try:
|
|
68
|
+
cfg.budget_chars = max(0, int(budget.strip()))
|
|
69
|
+
except ValueError:
|
|
70
|
+
pass
|
|
71
|
+
if os.environ.get("CLAIMKEEP_LESSONS_PATH"):
|
|
72
|
+
cfg.lessons_path = os.environ["CLAIMKEEP_LESSONS_PATH"]
|
|
73
|
+
lessons = os.environ.get("CLAIMKEEP_LESSONS")
|
|
74
|
+
if lessons is not None and lessons.strip().lower() in ("0", "false", "off", "no"):
|
|
75
|
+
cfg.lessons_enabled = False
|
|
76
|
+
return cfg
|
|
77
|
+
|
|
78
|
+
def expanded_brief_dir(self) -> str:
|
|
79
|
+
return os.path.abspath(os.path.expanduser(self.brief_dir))
|
|
80
|
+
|
|
81
|
+
def expanded_lessons_path(self) -> str:
|
|
82
|
+
return os.path.abspath(os.path.expanduser(self.lessons_path))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def default_config() -> Config:
|
|
86
|
+
return Config.from_env()
|