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.
- daimon_briefing/__init__.py +25 -0
- daimon_briefing/anchor.py +103 -0
- daimon_briefing/briefing.py +274 -0
- daimon_briefing/carry.py +97 -0
- daimon_briefing/cli.py +1119 -0
- daimon_briefing/config.py +406 -0
- daimon_briefing/configure.py +81 -0
- daimon_briefing/harvest.py +189 -0
- daimon_briefing/hooks.py +78 -0
- daimon_briefing/llm.py +239 -0
- daimon_briefing/recall.py +588 -0
- daimon_briefing/render.py +389 -0
- daimon_briefing/scoring.py +79 -0
- daimon_briefing/serializer.py +550 -0
- daimon_briefing/store.py +506 -0
- daimon_briefing/teamsync.py +484 -0
- daimon_briefing/transcript.py +258 -0
- daimon_briefing-0.3.0.dist-info/METADATA +161 -0
- daimon_briefing-0.3.0.dist-info/RECORD +21 -0
- daimon_briefing-0.3.0.dist-info/WHEEL +4 -0
- daimon_briefing-0.3.0.dist-info/entry_points.txt +6 -0
|
@@ -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
|
daimon_briefing/carry.py
ADDED
|
@@ -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
|