daimon-briefing 0.3.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.
@@ -0,0 +1,25 @@
1
+ """Daimon dream-briefing — hermes plugin entrypoint (Slice 1, local-file, no Honcho)."""
2
+
3
+ from pathlib import Path
4
+
5
+ from . import hooks
6
+
7
+ __version__ = "0.2.0"
8
+
9
+
10
+ def register(ctx):
11
+ """Called once at hermes startup. Wires the two hooks and bundles the skill.
12
+
13
+ # VERIFIED website/docs/guides/build-a-hermes-plugin.md:
14
+ # ctx.register_hook("<event>", callback)
15
+ # ctx.register_skill(skill_name: str, skill_md_path: Path)
16
+ """
17
+ ctx.register_hook("on_session_end", hooks.on_session_end)
18
+ ctx.register_hook("pre_llm_call", hooks.pre_llm_call)
19
+
20
+ skills_dir = Path(__file__).parent.parent / "skills"
21
+ if skills_dir.is_dir():
22
+ for child in sorted(skills_dir.iterdir()):
23
+ skill_md = child / "SKILL.md"
24
+ if child.is_dir() and skill_md.exists():
25
+ ctx.register_skill(child.name, skill_md)
@@ -0,0 +1,103 @@
1
+ """Anchor cognitive items to code symbols and detect drift — stdlib only.
2
+
3
+ A symbol is identified by (file, symbol) where symbol is `name` or `Class.method`.
4
+ The fingerprint is a structural hash (`ast.dump` of the def node), so it is stable
5
+ to formatting/comments/line-shift and changes only on real structural edits. No MCP,
6
+ no LLM, no network — resolution and drift checks read the project's own source.
7
+
8
+ Caveat: `ast.dump` output is stable only WITHIN a Python version. A checkpoint anchored
9
+ under one interpreter and checked under another may report a spurious "soft" drift — it
10
+ fails safe (toward verify-before-trusting, never a false "live"), and anchors are normally
11
+ resolved and checked by the same interpreter.
12
+ """
13
+
14
+ import ast
15
+ import hashlib
16
+ from pathlib import Path
17
+
18
+ _DEF = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
19
+
20
+
21
+ def _find_node(tree: ast.AST, symbol: str):
22
+ nodes = getattr(tree, "body", [])
23
+ node = None
24
+ for part in symbol.split("."):
25
+ node = next(
26
+ (n for n in nodes if isinstance(n, _DEF) and n.name == part), None
27
+ )
28
+ if node is None:
29
+ return None
30
+ nodes = getattr(node, "body", [])
31
+ return node
32
+
33
+
34
+ def body_hash_of(source: str, symbol: str) -> str | None:
35
+ try:
36
+ tree = ast.parse(source)
37
+ except SyntaxError:
38
+ return None
39
+ node = _find_node(tree, symbol)
40
+ if node is None:
41
+ return None
42
+ return hashlib.sha256(ast.dump(node).encode("utf-8")).hexdigest()
43
+
44
+
45
+ def resolve(project_root, file: str, symbol: str) -> dict | None:
46
+ """Snapshot an anchor for (file, symbol), or None if it can't be resolved."""
47
+ try:
48
+ source = (Path(project_root) / file).read_text(encoding="utf-8")
49
+ except OSError:
50
+ return None
51
+ h = body_hash_of(source, symbol)
52
+ if h is None:
53
+ return None
54
+ return {
55
+ "qualified_name": f"{file}::{symbol}",
56
+ "file": file,
57
+ "symbol": symbol,
58
+ "body_hash": h,
59
+ }
60
+
61
+
62
+ def check(anchor: dict, project_root) -> str:
63
+ """Classify drift: 'live' (unchanged), 'soft' (body changed), 'hard' (gone/unverifiable).
64
+
65
+ Degrades on a malformed anchor (missing/non-str file or symbol) by returning
66
+ 'hard' — the offline check must never raise on hand-edited checkpoint data."""
67
+ file = anchor.get("file")
68
+ symbol = anchor.get("symbol")
69
+ if not isinstance(file, str) or not isinstance(symbol, str):
70
+ return "hard"
71
+ try:
72
+ source = (Path(project_root) / file).read_text(encoding="utf-8")
73
+ except OSError:
74
+ return "hard"
75
+ h = body_hash_of(source, symbol)
76
+ if h is None:
77
+ return "hard"
78
+ return "live" if h == anchor.get("body_hash") else "soft"
79
+
80
+
81
+ def _all_items(checkpoint: dict):
82
+ wc = checkpoint.get("working_context") or {}
83
+ es = checkpoint.get("epistemic_snapshot") or {}
84
+ for key in ("open_questions", "recent_decisions"):
85
+ yield from (wc.get(key) or [])
86
+ for key in ("strong_beliefs", "uncertainties"):
87
+ yield from (es.get(key) or [])
88
+ active = wc.get("active_topic")
89
+ if isinstance(active, dict):
90
+ yield active
91
+
92
+
93
+ def drifted(checkpoint: dict, project_root) -> list[dict]:
94
+ """Anchored items whose code has drifted (soft/hard); live ones omitted."""
95
+ out = []
96
+ for item in _all_items(checkpoint):
97
+ a = item.get("anchored_to") if isinstance(item, dict) else None
98
+ if not isinstance(a, dict):
99
+ continue
100
+ kind = check(a, project_root)
101
+ if kind != "live":
102
+ out.append({"item": item, "kind": kind, "anchor": a})
103
+ return out
@@ -0,0 +1,274 @@
1
+ """Checkpoint -> 'while you were away' briefing text.
2
+
3
+ Default rendering is a DETERMINISTIC template over the checkpoint JSON — no LLM call.
4
+ Rationale: injection happens on the user's critical path (latency matters), and the
5
+ checkpoint is already the trusted extract (D-006); re-narrating via LLM reintroduces
6
+ generation risk for zero recall gain. LLM rendering is opt-in via DAIMON_LLM_BRIEFING.
7
+
8
+ Ordering is load-bearing: external-state items (the user-acted-outside-AI gap) come
9
+ FIRST under a 'verify before trusting' marker, then open loops, then decisions, then
10
+ beliefs, then uncertainties, then contradictions flagged. Verbatim items are marked
11
+ distinctly from inferred ones.
12
+ """
13
+
14
+ import re
15
+ import time
16
+
17
+ from . import config, llm, scoring
18
+
19
+ _VERBATIM_MARK = "✓ verbatim"
20
+ _INFERRED_MARK = "~ inferred"
21
+
22
+
23
+ def _mark(item) -> str:
24
+ return _VERBATIM_MARK if item.get("trust") == "verbatim" else _INFERRED_MARK
25
+
26
+
27
+ def _line(item) -> str:
28
+ text = item.get("text", "").strip()
29
+ quote = item.get("quote", "").strip()
30
+ base = f'- [{_mark(item)}] {text}'
31
+ if item.get("carried_from"):
32
+ # Epistemic honesty, same philosophy as trust marks: a loop carried
33
+ # from an older session must not read as fresh context (#33 Phase 2).
34
+ base += " [carried]"
35
+ if quote:
36
+ base += f' — "{quote}"'
37
+ return base
38
+
39
+
40
+ def _nonempty(item) -> bool:
41
+ return bool(item and isinstance(item, dict) and item.get("text", "").strip())
42
+
43
+
44
+ def _overflow_note(dropped: int) -> str | None:
45
+ """Marker text when the briefing capped older decisions, or None. Single source
46
+ for both the plain and rich render paths (DRY + one singular/plural rule)."""
47
+ if dropped <= 0:
48
+ return None
49
+ plural = "s" if dropped != 1 else ""
50
+ return f"(+{dropped} earlier decision{plural} — full history in checkpoint)"
51
+
52
+
53
+ def _by_weight(items, item_type, now):
54
+ """Sort a section by #78 effective weight, heaviest first. sorted() is stable,
55
+ so legacy items (no first_seen / no importance -> equal neutral weights) keep
56
+ their serializer order — pre-D-011 checkpoints render exactly as before."""
57
+ return sorted(items, key=lambda i: scoring.effective_weight(i, item_type, now),
58
+ reverse=True)
59
+
60
+
61
+ def build(checkpoint, now=None) -> dict | None:
62
+ """Structured briefing sections, or None if nothing is worth surfacing.
63
+ Deterministic — no LLM; `now` is injectable for tests. Sections order by #78
64
+ effective weight EXCEPT recent_decisions, which stay chronological (the
65
+ serializer's CHRONOLOGY contract; the tail-cap below depends on it)."""
66
+ if not checkpoint or not isinstance(checkpoint, dict):
67
+ return None
68
+ if now is None:
69
+ now = time.time()
70
+
71
+ wc = checkpoint.get("working_context") or {}
72
+ es = checkpoint.get("epistemic_snapshot") or {}
73
+
74
+ open_qs = _by_weight([i for i in (wc.get("open_questions") or []) if _nonempty(i)],
75
+ "open_question", now)
76
+ decisions = [i for i in (wc.get("recent_decisions") or []) if _nonempty(i)]
77
+ beliefs = _by_weight([i for i in (es.get("strong_beliefs") or []) if _nonempty(i)],
78
+ "strong_belief", now)
79
+ uncertainties = _by_weight([i for i in (es.get("uncertainties") or []) if _nonempty(i)],
80
+ "uncertainty", now)
81
+ contradictions = [i for i in (es.get("contradictions_flagged") or []) if _nonempty(i)]
82
+ active = wc.get("active_topic")
83
+
84
+ if not (open_qs or decisions or beliefs or uncertainties or contradictions
85
+ or _nonempty(active)):
86
+ return None
87
+
88
+ # Cap to the most-recent N decisions (tail — recent_decisions is chronological,
89
+ # oldest→newest, per the serializer's CHRONOLOGY instruction). Render-time only:
90
+ # the checkpoint keeps every decision. 0 = unbounded.
91
+ n = config.max_briefing_decisions()
92
+ kept = decisions[-n:] if n and len(decisions) > n else decisions
93
+
94
+ return {
95
+ "external": [i for i in open_qs if i.get("external_state")],
96
+ "open_loops": [i for i in open_qs if not i.get("external_state")],
97
+ "decisions": kept,
98
+ "decisions_overflow": len(decisions) - len(kept),
99
+ "active_topic": active if _nonempty(active) else None,
100
+ "beliefs": beliefs,
101
+ "uncertainties": uncertainties,
102
+ "contradictions": contradictions,
103
+ }
104
+
105
+
106
+ # ---- #79: token budget — section-preserving truncation ----
107
+
108
+ # A bold-labeled section (**Problem:** / **Root Cause:** / **Fix:** ...) plus
109
+ # its immediate continuation line — the load-bearing shape ACB's truncation
110
+ # preserved (hierarchical_content_generator:774), without its per-label list:
111
+ # any **Label:** counts, so user vocabularies survive too.
112
+ _SECTION_RE = re.compile(r"\*\*[^*\n]+:\*\*[^\n]*(?:\n(?![*\s])[^\n]+)?")
113
+
114
+ _TRUNCATION_MARKER = " …[truncated — full text in checkpoint]"
115
+
116
+ # When a briefing is over budget, single items longer than this get
117
+ # section-preserving truncation before anything is dropped outright.
118
+ _ITEM_TRUNCATE_CHARS = 400
119
+
120
+
121
+ def estimate_tokens(text: str) -> int:
122
+ """Honest chars//4 estimate (#79) — no tokenizer dependency, and the error
123
+ margin is fine for a budget whose point is order-of-magnitude control."""
124
+ return len(text) // 4
125
+
126
+
127
+ def truncate_preserving_sections(text: str, max_chars: int) -> str:
128
+ """Cut `text` to max_chars, keeping **Label:** sections over filler: if the
129
+ labeled sections alone fit, they ARE the truncation; only a section-less
130
+ text falls back to a blind head-cut. Always appends a visible marker —
131
+ silent truncation reads as 'this is everything' when it isn't."""
132
+ if len(text) <= max_chars:
133
+ return text
134
+ parts = _SECTION_RE.findall(text)
135
+ if parts:
136
+ key = "\n".join(parts)
137
+ if len(key) + len(_TRUNCATION_MARKER) <= max_chars:
138
+ return key + _TRUNCATION_MARKER
139
+ return text[:max(0, max_chars - len(_TRUNCATION_MARKER))] + _TRUNCATION_MARKER
140
+
141
+
142
+ def _trim_note(dropped: int) -> str:
143
+ plural = "s" if dropped != 1 else ""
144
+ return f" (+{dropped} item{plural} trimmed for budget — full history in checkpoint)"
145
+
146
+
147
+ # Budget drop order (#79): background sections go before actionable ones, and
148
+ # within a section the LOWEST-weight items go first — beliefs/uncertainties are
149
+ # #78-sorted heaviest-first, so their tail is the lightest; decisions are
150
+ # chronological, so their head is the oldest. external / active_topic /
151
+ # contradictions are never dropped: they are the skeleton.
152
+ _DROP_ORDER = (("beliefs", "tail"), ("uncertainties", "tail"),
153
+ ("decisions", "head"), ("open_loops", "tail"))
154
+
155
+
156
+ def render_plain(b: dict) -> str:
157
+ """The deterministic briefing text. Under the #79 budget this is
158
+ BYTE-IDENTICAL to the legacy render(); over it, long items truncate
159
+ (sections preserved) and then whole items drop, lowest value first,
160
+ each cut announced with a trim note."""
161
+ budget = config.brief_max_tokens()
162
+ text = _render_parts(b, {})
163
+ if not budget or estimate_tokens(text) <= budget:
164
+ return text
165
+
166
+ # Stage 1: shorten monster items in place of dropping them.
167
+ b = dict(b)
168
+ for key, _end in _DROP_ORDER:
169
+ b[key] = [
170
+ {**i, "text": truncate_preserving_sections(
171
+ i.get("text", ""), _ITEM_TRUNCATE_CHARS)}
172
+ for i in (b.get(key) or [])
173
+ ]
174
+ trimmed = {key: 0 for key, _ in _DROP_ORDER}
175
+ text = _render_parts(b, trimmed)
176
+
177
+ # Stage 2: drop whole items, least valuable first, until the budget holds
178
+ # or only the skeleton remains.
179
+ for key, end in _DROP_ORDER:
180
+ while estimate_tokens(text) > budget and b.get(key):
181
+ items = list(b[key])
182
+ items.pop(-1 if end == "tail" else 0)
183
+ b[key] = items
184
+ trimmed[key] += 1
185
+ text = _render_parts(b, trimmed)
186
+ if estimate_tokens(text) <= budget:
187
+ break
188
+ return text
189
+
190
+
191
+ def _render_parts(b: dict, trimmed: dict) -> str:
192
+ parts = ["While you were away — here's where we left off."]
193
+
194
+ def _section(header: str, key: str) -> None:
195
+ items = b.get(key) or []
196
+ note = trimmed.get(key, 0)
197
+ if not items and not note:
198
+ return
199
+ parts.append("")
200
+ parts.append(header)
201
+ parts.extend(_line(i) for i in items)
202
+ if key == "decisions":
203
+ overflow = _overflow_note(b.get("decisions_overflow", 0))
204
+ if overflow:
205
+ parts.append(f" {overflow}")
206
+ if note:
207
+ parts.append(_trim_note(note))
208
+
209
+ if b["external"]:
210
+ parts.append("")
211
+ parts.append("VERIFY BEFORE TRUSTING (state may have changed outside this session):")
212
+ parts.extend(_line(i) for i in b["external"])
213
+
214
+ _section("Open loops:", "open_loops")
215
+ _section("Decisions made:", "decisions")
216
+
217
+ if b["active_topic"]:
218
+ parts.append("")
219
+ parts.append(f'Active topic: {b["active_topic"].get("text", "").strip()}')
220
+
221
+ _section("Beliefs held:", "beliefs")
222
+ _section("Was uncertain about:", "uncertainties")
223
+
224
+ # .get(): hand-built b dicts predating #101 may lack the key (defensive,
225
+ # same spirit as decisions_overflow).
226
+ if b.get("contradictions"):
227
+ parts.append("")
228
+ parts.append("Contradictions flagged:")
229
+ parts.extend(_line(i) for i in b["contradictions"])
230
+
231
+ return "\n".join(parts)
232
+
233
+
234
+ def render(checkpoint) -> str | None:
235
+ """Render the briefing, or None if there is nothing worth surfacing.
236
+ LLM rendering is opt-in (DAIMON_LLM_BRIEFING) and falls back to deterministic."""
237
+ b = build(checkpoint)
238
+ if b is None:
239
+ return None
240
+ if config.llm_briefing():
241
+ rendered = _render_llm(checkpoint)
242
+ if rendered:
243
+ return rendered
244
+ return render_plain(b)
245
+
246
+
247
+ # Seeded from research/experiments/track-a/prompts/02-reconstruct.md, tuned for a
248
+ # skimmable briefing rather than a two-part reconstruction.
249
+ _RECONSTRUCT_SYS = """You are resuming a work session. Your only memory of the previous session is the cognitive checkpoint below. You do NOT have the original transcript.
250
+
251
+ Write a <30-second, skimmable "while you were away / here's where we left off" briefing.
252
+ ORDER IT: items flagged external_state FIRST under a clear "verify before trusting" heading
253
+ (their state may have changed outside the session); then open loops; then decisions; then beliefs;
254
+ then any contradictions_flagged (as their own "contradictions flagged" section — omit it when empty).
255
+ Mark each item as verbatim or inferred.
256
+
257
+ CRITICAL: base every claim ONLY on the checkpoint. Do NOT add plausible-sounding detail that is
258
+ not in the checkpoint. If the checkpoint is thin, the briefing should be thin. Do not embellish."""
259
+
260
+
261
+ def _render_llm(checkpoint) -> str | None:
262
+ import json
263
+
264
+ try:
265
+ return llm.chat(
266
+ [
267
+ {"role": "system", "content": _RECONSTRUCT_SYS},
268
+ {"role": "user", "content": "CHECKPOINT:\n" + json.dumps(checkpoint, indent=2)},
269
+ ],
270
+ # temperature comes from config (default 0.0 for determinism;
271
+ # DAIMON_LLM_TEMPERATURE overrides).
272
+ )
273
+ except Exception:
274
+ return None
@@ -0,0 +1,97 @@
1
+ """Deterministic cross-session carry (#33 Phase 2).
2
+
3
+ Multicycle run-01 (LOGBOOK 2026-07-02) proved LLM re-emission loses whole
4
+ items even from lossless input, while exact-copy carry held 1.0 fidelity and
5
+ zero first_seen churn. So carry is CODE: fold the previous checkpoint's
6
+ unresolved items into the new one verbatim, expire by #78 weight, dedup by
7
+ salient-term overlap, label with carried_from. No I/O, no LLM, no env — the
8
+ caller injects clock and knobs (scar: a default wall-clock anywhere silently
9
+ freezes time math under simulation)."""
10
+
11
+ import copy
12
+
13
+ from . import recall, scoring, store
14
+
15
+ # (section, key, scoring TYPE_RULES type). Beliefs regenerate cheaply and
16
+ # active_topic is per-session by definition — neither carries (v1).
17
+ _CARRIED_KINDS = (
18
+ ("working_context", "open_questions", "open_question"),
19
+ ("working_context", "recent_decisions", "recent_decision"),
20
+ ("epistemic_snapshot", "uncertainties", "uncertainty"),
21
+ )
22
+
23
+ _MIN_SHARED = 3 # shared salient terms for same-item
24
+ _MIN_RATIO = 0.6 # or this fraction of the shorter term list
25
+
26
+
27
+ def _same_item(a_text: str, b_text: str) -> bool:
28
+ """Term-overlap identity: the serializer rewords constantly (run-01), so
29
+ exact text misses twins. Shared >=3 salient terms, or >=60% of the shorter
30
+ list, means same item. Short texts (<2 salient terms) never fuzzy-match —
31
+ the exact-text guard still catches identical ones."""
32
+ a = set(recall.salient_terms(a_text))
33
+ b = set(recall.salient_terms(b_text))
34
+ if not a or not b:
35
+ return False
36
+ shared = len(a & b)
37
+ return shared >= _MIN_SHARED or shared / min(len(a), len(b)) >= _MIN_RATIO
38
+
39
+
40
+ def merge(new_cp: dict, prev_cp: dict | None, now: float,
41
+ floor: float = 0.05, cap: int = 8) -> dict:
42
+ """Fold prev_cp's carry-eligible items into a COPY of new_cp.
43
+
44
+ Native items are never dropped or reordered — carry only appends, and (on
45
+ a dedup hit) copies the older first_seen onto the native twin so decay age
46
+ survives rewording. Anachronism guard: healing an old session must not
47
+ swallow a newer checkpoint's state.
48
+
49
+ No-op paths (non-dict inputs, anachronism guard) return new_cp UNCHANGED,
50
+ not a copy — callers reassign the result immediately, so a defensive
51
+ deepcopy there would just be wasted work."""
52
+ if not isinstance(new_cp, dict) or not isinstance(prev_cp, dict):
53
+ return new_cp
54
+ new_epoch = store._created_epoch(new_cp.get("created"))
55
+ prev_epoch = store._created_epoch(prev_cp.get("created"))
56
+ if new_epoch is not None and prev_epoch is not None and new_epoch < prev_epoch:
57
+ return new_cp
58
+
59
+ out = copy.deepcopy(new_cp)
60
+ prev_sid = str(prev_cp.get("session_id") or "")
61
+ for section, key, item_type in _CARRIED_KINDS:
62
+ native = (out.get(section) or {}).get(key)
63
+ if not isinstance(native, list):
64
+ continue
65
+ prev_items = (prev_cp.get(section) or {}).get(key) or []
66
+ native_texts = {i.get("text") for i in native if isinstance(i, dict)}
67
+ carried = []
68
+ for item in prev_items:
69
+ if not isinstance(item, dict) or not str(item.get("text") or "").strip():
70
+ continue
71
+ text = item["text"]
72
+ if text in native_texts:
73
+ continue # exact twin already present (idempotency)
74
+ twin = next((n for n in native if isinstance(n, dict)
75
+ and _same_item(text, str(n.get("text") or ""))), None)
76
+ if twin is not None:
77
+ # Session re-discussed it: the new wording wins, but the item's
78
+ # AGE does not reset (run-01: 8-12 resets/20 cycles killed the
79
+ # #128 overdue boost). Keep the older birth stamp.
80
+ if item.get("first_seen") and not twin.get("first_seen"):
81
+ twin["first_seen"] = item["first_seen"]
82
+ elif item.get("first_seen") and twin.get("first_seen"):
83
+ old = store._created_epoch(item["first_seen"])
84
+ cur = store._created_epoch(twin["first_seen"])
85
+ if old is not None and (cur is None or old < cur):
86
+ twin["first_seen"] = item["first_seen"]
87
+ continue
88
+ if scoring.effective_weight(item, item_type, now) < floor:
89
+ continue # expired — deterministic exit (noise budget)
90
+ kept = copy.deepcopy(item)
91
+ kept.setdefault("carried_from", prev_sid)
92
+ carried.append(kept)
93
+ native_texts.add(text) # two identical prev items must carry once
94
+ carried.sort(key=lambda i: scoring.effective_weight(i, item_type, now),
95
+ reverse=True)
96
+ native.extend(carried[:cap])
97
+ return out