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 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
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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()