threadkeeper 0.4.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.
Files changed (61) hide show
  1. threadkeeper/__init__.py +8 -0
  2. threadkeeper/_mcp.py +6 -0
  3. threadkeeper/_setup.py +299 -0
  4. threadkeeper/adapters/__init__.py +40 -0
  5. threadkeeper/adapters/_hook_helpers.py +72 -0
  6. threadkeeper/adapters/base.py +152 -0
  7. threadkeeper/adapters/claude_code.py +178 -0
  8. threadkeeper/adapters/claude_desktop.py +128 -0
  9. threadkeeper/adapters/codex.py +259 -0
  10. threadkeeper/adapters/copilot.py +195 -0
  11. threadkeeper/adapters/gemini.py +169 -0
  12. threadkeeper/adapters/vscode.py +144 -0
  13. threadkeeper/brief.py +735 -0
  14. threadkeeper/config.py +216 -0
  15. threadkeeper/curator.py +390 -0
  16. threadkeeper/db.py +474 -0
  17. threadkeeper/embeddings.py +232 -0
  18. threadkeeper/extract_daemon.py +125 -0
  19. threadkeeper/helpers.py +101 -0
  20. threadkeeper/i18n.py +342 -0
  21. threadkeeper/identity.py +237 -0
  22. threadkeeper/ingest.py +507 -0
  23. threadkeeper/lessons.py +170 -0
  24. threadkeeper/nudges.py +257 -0
  25. threadkeeper/process_health.py +202 -0
  26. threadkeeper/review_prompts.py +207 -0
  27. threadkeeper/search_proxy.py +160 -0
  28. threadkeeper/server.py +55 -0
  29. threadkeeper/shadow_review.py +358 -0
  30. threadkeeper/skill_watcher.py +96 -0
  31. threadkeeper/spawn_budget.py +246 -0
  32. threadkeeper/tools/__init__.py +2 -0
  33. threadkeeper/tools/concepts.py +111 -0
  34. threadkeeper/tools/consolidate.py +222 -0
  35. threadkeeper/tools/core_memory.py +109 -0
  36. threadkeeper/tools/correlation.py +116 -0
  37. threadkeeper/tools/curator.py +121 -0
  38. threadkeeper/tools/dialectic.py +359 -0
  39. threadkeeper/tools/dialog.py +131 -0
  40. threadkeeper/tools/distill.py +184 -0
  41. threadkeeper/tools/extract.py +411 -0
  42. threadkeeper/tools/graph.py +183 -0
  43. threadkeeper/tools/invariants.py +177 -0
  44. threadkeeper/tools/lessons.py +110 -0
  45. threadkeeper/tools/missed_spawns.py +142 -0
  46. threadkeeper/tools/peers.py +579 -0
  47. threadkeeper/tools/pickup.py +148 -0
  48. threadkeeper/tools/probes.py +251 -0
  49. threadkeeper/tools/process_health.py +90 -0
  50. threadkeeper/tools/session.py +34 -0
  51. threadkeeper/tools/shadow_review.py +106 -0
  52. threadkeeper/tools/skills.py +856 -0
  53. threadkeeper/tools/spawn.py +871 -0
  54. threadkeeper/tools/style.py +44 -0
  55. threadkeeper/tools/threads.py +299 -0
  56. threadkeeper-0.4.0.dist-info/METADATA +351 -0
  57. threadkeeper-0.4.0.dist-info/RECORD +61 -0
  58. threadkeeper-0.4.0.dist-info/WHEEL +5 -0
  59. threadkeeper-0.4.0.dist-info/entry_points.txt +2 -0
  60. threadkeeper-0.4.0.dist-info/licenses/LICENSE +21 -0
  61. threadkeeper-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,116 @@
1
+ """Task↔signals correlation MCP tools.
2
+
3
+ Extracted from server.py. Lets a session manually attach a signal to a
4
+ spawned task (when auto-tagging at emit-time missed it) and replay a
5
+ spawned task as a chronological thread of signals plus relevant notes.
6
+ """
7
+
8
+ import sqlite3
9
+ import time
10
+
11
+ from .._mcp import mcp
12
+ from ..db import get_db
13
+ from ..helpers import fmt_age, q
14
+ from ..identity import _ensure_session
15
+
16
+
17
+ @mcp.tool()
18
+ def tag_signal(signal_id: int, task_id: str) -> str:
19
+ """Manually attach a signal to a task. Useful when retroactively building
20
+ a task-thread (auto-tagging happens at signal-emit time when the cid
21
+ matches a known spawned_cid)."""
22
+ conn = get_db()
23
+ _ensure_session(conn)
24
+ if not conn.execute(
25
+ "SELECT 1 FROM signals WHERE id=?", (int(signal_id),)
26
+ ).fetchone():
27
+ return f"ERR signal_not_found={signal_id}"
28
+ if not conn.execute(
29
+ "SELECT 1 FROM tasks WHERE id=?", (task_id.strip(),)
30
+ ).fetchone():
31
+ return f"ERR task_not_found={task_id}"
32
+ conn.execute(
33
+ "UPDATE signals SET task_id=? WHERE id=?",
34
+ (task_id.strip(), int(signal_id)),
35
+ )
36
+ conn.commit()
37
+ return f"ok signal={signal_id} → task={task_id}"
38
+
39
+
40
+ @mcp.tool()
41
+ def task_thread(task_id: str, include_notes: bool = True,
42
+ k: int = 50) -> str:
43
+ """Replay a spawned task as a chronological thread: every signal tagged
44
+ with the task_id (or to/from the task's spawned_cid), plus optionally
45
+ notes added during the task window."""
46
+ conn = get_db()
47
+ t = conn.execute(
48
+ "SELECT pid, parent_cid, spawned_cid, prompt, started_at, ended_at "
49
+ "FROM tasks WHERE id=?", (task_id.strip(),)
50
+ ).fetchone()
51
+ if not t:
52
+ return f"ERR task_not_found={task_id}"
53
+ end_t = t["ended_at"] or int(time.time())
54
+ start_t = t["started_at"]
55
+ spawned = t["spawned_cid"]
56
+ parent = t["parent_cid"]
57
+ # signals: explicitly tagged OR (to/from spawned_cid within window)
58
+ query = """
59
+ SELECT id, from_cid, to_cid, kind, content, created_at
60
+ FROM signals
61
+ WHERE (task_id = ?)
62
+ OR (created_at BETWEEN ? AND ?
63
+ AND ((from_cid = ? OR to_cid = ?)
64
+ OR (from_cid = ? AND to_cid = ?)
65
+ OR (from_cid = ? AND to_cid = ?)))
66
+ ORDER BY created_at ASC LIMIT ?
67
+ """
68
+ sigs = conn.execute(
69
+ query,
70
+ (task_id.strip(), start_t - 5, end_t + 60,
71
+ spawned or "_none_", spawned or "_none_",
72
+ spawned or "_none_", parent or "_none_",
73
+ parent or "_none_", spawned or "_none_",
74
+ max(1, int(k))),
75
+ ).fetchall()
76
+ notes_rows = []
77
+ if include_notes and spawned:
78
+ notes_rows = conn.execute(
79
+ "SELECT id, thread_id, kind, content, created_at "
80
+ "FROM notes WHERE session_id LIKE ? AND created_at BETWEEN ? AND ? "
81
+ "ORDER BY created_at ASC",
82
+ (f"%", start_t - 5, end_t + 60),
83
+ ).fetchall()
84
+ # narrow notes to ones actually authored by spawned (cid not session_id)
85
+ # session_id in notes is mcp-internal; we don't have direct cid match.
86
+ # Best heuristic: include notes whose content references the task or
87
+ # whose thread last_touched within window.
88
+ notes_rows = [
89
+ n for n in notes_rows
90
+ if (task_id.strip() in (n["content"] or "")
91
+ or n["thread_id"] in {None, "Tcd1"})
92
+ ][:10]
93
+ lines = [
94
+ f"task={task_id} parent={(parent or '-')[:8]} child={(spawned or '-')[:8]} "
95
+ f"started={fmt_age(int(time.time()) - start_t)}_ago "
96
+ f"{'ended' if t['ended_at'] else 'open'}"
97
+ ]
98
+ if not sigs and not notes_rows:
99
+ lines.append(" (no signals or notes in window)")
100
+ return "\n".join(lines)
101
+ for s in sigs:
102
+ ago = fmt_age(int(time.time()) - s["created_at"])
103
+ snip = (s["content"] or "")[:140].replace("\n", " ")
104
+ scope = "*" if s["to_cid"] is None else "→" + (s["to_cid"][:8])
105
+ lines.append(
106
+ f" sig#{s['id']} {scope} from={s['from_cid'][:8]} "
107
+ f"+{s['kind']} {ago}_ago {q(snip)}"
108
+ )
109
+ for n in notes_rows:
110
+ ago = fmt_age(int(time.time()) - n["created_at"])
111
+ snip = (n["content"] or "")[:140].replace("\n", " ")
112
+ lines.append(
113
+ f" note#{n['id']} thread={n['thread_id'] or '-'} "
114
+ f"+{n['kind']} {ago}_ago {q(snip)}"
115
+ )
116
+ return "\n".join(lines)
@@ -0,0 +1,121 @@
1
+ """MCP tools for the Curator (lessons + skills library audit).
2
+
3
+ The age-based archival pass `curator_run` lives in tools.skills (it
4
+ mutates skill_usage state). These tools are about the *LLM-driven*
5
+ audit pass:
6
+
7
+ curator_review(force=False, dry_run=False)
8
+ Trigger one curator pass NOW. Spawns a slim child with the inventory
9
+ of every lesson + skill, child writes a REPORT.md with KEEP / PATCH
10
+ / CONSOLIDATE / PRUNE recommendations.
11
+
12
+ curator_review_status()
13
+ Diagnostic: env config, last cursor, last 5 passes, latest REPORT
14
+ path.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+
21
+ from .._mcp import mcp
22
+ from ..db import get_db
23
+ from ..identity import _ensure_session
24
+ from ..curator import (
25
+ CURATOR_PROMPT,
26
+ _collect_inventory,
27
+ _last_curator_ts,
28
+ run_curator_pass,
29
+ )
30
+ from ..config import (
31
+ CURATOR_INTERVAL_S,
32
+ CURATOR_MIN_LESSONS,
33
+ CURATOR_REPORTS_DIR,
34
+ CURATOR_DESTRUCTIVE,
35
+ )
36
+
37
+
38
+ @mcp.tool()
39
+ def curator_review(force: bool = False, dry_run: bool = False) -> str:
40
+ """Fire one curator pass.
41
+
42
+ `force=True` runs even when CURATOR_INTERVAL_S=0 (daemon disabled).
43
+ Use for one-shot triage or testing the prompt.
44
+
45
+ `dry_run=True` short-circuits before the spawn — returns the
46
+ inventory that WOULD be passed plus n_lessons/n_skills. No spawn,
47
+ no cursor advance. Use to inspect what the curator child would see
48
+ before paying for the spawn.
49
+ """
50
+ conn = get_db()
51
+ _ensure_session(conn)
52
+ if dry_run:
53
+ inventory, n_lessons, n_skills = _collect_inventory(conn)
54
+ below = n_lessons < CURATOR_MIN_LESSONS
55
+ head = inventory[:2000]
56
+ suffix = "…(truncated for display)" if len(inventory) > 2000 else ""
57
+ return (
58
+ f"dry_run: lessons={n_lessons} skills={n_skills} "
59
+ f"min_lessons={CURATOR_MIN_LESSONS} "
60
+ f"would_spawn={'no (below_threshold)' if below else 'yes'}\n\n"
61
+ f"--- prompt preview ---\n"
62
+ f"{CURATOR_PROMPT[:400]}…\n\n"
63
+ f"--- inventory head ---\n{head}{suffix}"
64
+ )
65
+ return run_curator_pass(force=force)
66
+
67
+
68
+ @mcp.tool()
69
+ def curator_review_status() -> str:
70
+ """Show curator configuration + last 5 passes + latest REPORT path.
71
+
72
+ Sanity-check for whether the daemon is alive, advancing the cursor,
73
+ and producing REPORTs the user can read."""
74
+ conn = get_db()
75
+ _ensure_session(conn)
76
+ floor = _last_curator_ts(conn)
77
+ now = int(time.time())
78
+ age_s = (now - floor) if floor else None
79
+ mode = "destructive" if CURATOR_DESTRUCTIVE else "advisory"
80
+ lines = [
81
+ f"interval_s={CURATOR_INTERVAL_S:.0f} "
82
+ f"min_lessons={CURATOR_MIN_LESSONS} "
83
+ f"mode={mode} "
84
+ f"reports_dir={CURATOR_REPORTS_DIR}",
85
+ f"cursor_ts={floor} (age={age_s}s)" if floor
86
+ else "cursor_ts=0 (no prior pass)",
87
+ "",
88
+ "recent passes (newest first):",
89
+ ]
90
+ try:
91
+ rows = conn.execute(
92
+ "SELECT created_at, summary FROM events "
93
+ "WHERE kind='curator_pass' "
94
+ "ORDER BY id DESC LIMIT 5"
95
+ ).fetchall()
96
+ except Exception:
97
+ rows = []
98
+ if not rows:
99
+ lines.append(" (none)")
100
+ else:
101
+ for r in rows:
102
+ ts = r["created_at"]
103
+ age = now - int(ts) if ts else 0
104
+ snip = (r["summary"] or "")[:120]
105
+ lines.append(f" {age}s_ago {snip}")
106
+
107
+ # Latest REPORT.md the curator wrote, if any.
108
+ try:
109
+ reports = sorted(
110
+ CURATOR_REPORTS_DIR.glob("REPORT-*.md"),
111
+ key=lambda p: p.stat().st_mtime,
112
+ reverse=True,
113
+ )
114
+ except FileNotFoundError:
115
+ reports = []
116
+ lines.append("")
117
+ if reports:
118
+ lines.append(f"latest_report={reports[0]}")
119
+ else:
120
+ lines.append("latest_report=(none yet)")
121
+ return "\n".join(lines)
@@ -0,0 +1,359 @@
1
+ """Dialectic user model — discrete claims about the user backed by
2
+ evidence that accumulates over time. Inspired by Honcho's Theory of Mind.
3
+
4
+ Confidence emerges from evidence rather than being asserted. We use a
5
+ smoothed ratio so that magnitude matters (3 supports < 5 supports), not
6
+ just sign:
7
+
8
+ ratio = (support_count - contradict_count) / (support_count + contradict_count + 3)
9
+
10
+ < -0.2 → 'disputed' (more contradictions than supports by margin)
11
+ < 0.2 → 'low'
12
+ < 0.6 → 'medium'
13
+ else → 'high'
14
+
15
+ A claim with no evidence stays 'low' regardless of age. The smoothing
16
+ constant (3) means a single piece of evidence does not jump to 'high':
17
+ 3 supports lands at medium (3/6=0.5), 5 supports at high (5/8=0.625).
18
+ Heavy contradiction can still drag a claim back: 1 support + 3 contradicts
19
+ → -2/7 → disputed.
20
+
21
+ Tools:
22
+ dialectic_claim — register a new claim with optional initial evidence
23
+ dialectic_evidence — attach evidence (support/contradict) to existing claim
24
+ dialectic_review — list claims by confidence/domain/state
25
+ dialectic_synthesis — terse 'who is this user' rendering for brief()
26
+ dialectic_supersede — retire claim A in favor of claim B (claim_B refines A)
27
+
28
+ Confidence is recomputed on every evidence add. domain is free-text but a
29
+ small enumeration is recommended:
30
+ 'style','workflow','values','context','skills','other'.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import sqlite3
35
+ import time
36
+ from typing import Optional
37
+
38
+ from .._mcp import mcp
39
+ from ..db import get_db
40
+ from ..helpers import fmt_age, q, gen_dialectic_id
41
+ from ..identity import _ensure_session, _detect_self_cid, _emit
42
+
43
+
44
+ VALID_KINDS = ("support", "contradict")
45
+ VALID_CONFIDENCE = ("low", "medium", "high", "disputed")
46
+ VALID_STATE = ("active", "retired", "superseded")
47
+ SUGGESTED_DOMAINS = (
48
+ "style", "workflow", "values", "context", "skills", "other",
49
+ )
50
+ SMOOTHING = 3 # see module docstring for rationale
51
+
52
+
53
+ def _recompute_confidence(conn: sqlite3.Connection, claim_id: str) -> str:
54
+ """Recalculate confidence from current support/contradict counts and
55
+ persist. Returns the new label."""
56
+ row = conn.execute(
57
+ "SELECT support_count, contradict_count FROM user_dialectic WHERE id=?",
58
+ (claim_id,),
59
+ ).fetchone()
60
+ if not row:
61
+ return "low"
62
+ s, c = row["support_count"], row["contradict_count"]
63
+ total = s + c
64
+ if total == 0:
65
+ new_conf = "low"
66
+ else:
67
+ ratio = (s - c) / (total + SMOOTHING)
68
+ if ratio < -0.2:
69
+ new_conf = "disputed"
70
+ elif ratio < 0.2:
71
+ new_conf = "low"
72
+ elif ratio < 0.6:
73
+ new_conf = "medium"
74
+ else:
75
+ new_conf = "high"
76
+ conn.execute(
77
+ "UPDATE user_dialectic SET confidence=? WHERE id=?",
78
+ (new_conf, claim_id),
79
+ )
80
+ return new_conf
81
+
82
+
83
+ def _insert_evidence(conn: sqlite3.Connection, claim_id: str, kind: str,
84
+ quote: str, source: str, weight: float,
85
+ cid: Optional[str], now_t: int) -> int:
86
+ """Write evidence row and bump the claim's counter. Caller commits."""
87
+ cur = conn.execute(
88
+ "INSERT INTO dialectic_evidence (claim_id, kind, source, quote, "
89
+ "weight, created_by_cid, created_at) VALUES (?,?,?,?,?,?,?)",
90
+ (claim_id, kind, source or None, quote or None, float(weight),
91
+ cid, now_t),
92
+ )
93
+ col = "support_count" if kind == "support" else "contradict_count"
94
+ conn.execute(
95
+ f"UPDATE user_dialectic SET {col}={col}+1, last_evidence_at=? "
96
+ "WHERE id=?",
97
+ (now_t, claim_id),
98
+ )
99
+ return cur.lastrowid
100
+
101
+
102
+ @mcp.tool()
103
+ def dialectic_claim(claim: str, domain: str = "", evidence: str = "",
104
+ evidence_kind: str = "support") -> str:
105
+ """Register a new claim about the user. Optionally seed with first
106
+ piece of evidence — pass the supporting (or contradicting) quote in
107
+ `evidence` and set `evidence_kind` to 'support' (default) or
108
+ 'contradict'.
109
+
110
+ `domain` is free-text; recommended values:
111
+ 'style','workflow','values','context','skills','other'.
112
+
113
+ Returns: 'ok id=<UCxxx> conf=<level>'."""
114
+ claim = claim.strip()
115
+ if not claim:
116
+ return "ERR empty_claim"
117
+ if len(claim) > 1000:
118
+ return "ERR claim_too_long max=1000"
119
+ dom = domain.strip() or None
120
+ if dom and len(dom) > 64:
121
+ return "ERR domain_too_long max=64"
122
+ if evidence_kind not in VALID_KINDS:
123
+ return f"ERR bad_kind={evidence_kind} valid={'/'.join(VALID_KINDS)}"
124
+ conn = get_db()
125
+ _ensure_session(conn)
126
+ cid = _detect_self_cid()
127
+ cid_short = cid
128
+ cid_db = cid
129
+ pid = gen_dialectic_id(conn)
130
+ now_t = int(time.time())
131
+ conn.execute(
132
+ "INSERT INTO user_dialectic (id, claim, domain, created_by_cid, "
133
+ "created_at) VALUES (?,?,?,?,?)",
134
+ (pid, claim, dom, cid_db, now_t),
135
+ )
136
+ seed_quote = evidence.strip()
137
+ if seed_quote:
138
+ _insert_evidence(conn, pid, evidence_kind, seed_quote,
139
+ "manual", 1.0, cid_short, now_t)
140
+ new_conf = _recompute_confidence(conn, pid)
141
+ _emit(conn, "dialectic_claim", target=pid, summary=claim[:140])
142
+ conn.commit()
143
+ return f"ok id={pid} conf={new_conf}"
144
+
145
+
146
+ @mcp.tool()
147
+ def dialectic_evidence(claim_id: str, kind: str = "support",
148
+ quote: str = "", source: str = "",
149
+ weight: float = 1.0) -> str:
150
+ """Attach evidence to a claim. `kind`: 'support' | 'contradict'.
151
+
152
+ `source` is a freeform pointer like 'thread:T7f3', 'verbatim:42',
153
+ 'dialog:<uuid>', or 'manual'. `weight` ∈ [0,1] (default 1.0) is
154
+ captured for future reweighting; counts increment by 1 regardless.
155
+
156
+ Bumps support_count or contradict_count and recomputes confidence."""
157
+ claim_id = claim_id.strip()
158
+ if kind not in VALID_KINDS:
159
+ return f"ERR bad_kind={kind} valid={'/'.join(VALID_KINDS)}"
160
+ try:
161
+ w = float(weight)
162
+ except (TypeError, ValueError):
163
+ return f"ERR bad_weight={weight}"
164
+ if w < 0.0 or w > 1.0:
165
+ return f"ERR weight_out_of_range value={w} valid=0..1"
166
+ conn = get_db()
167
+ _ensure_session(conn)
168
+ row = conn.execute(
169
+ "SELECT state FROM user_dialectic WHERE id=?", (claim_id,)
170
+ ).fetchone()
171
+ if not row:
172
+ return f"ERR claim_not_found={claim_id}"
173
+ if row["state"] != "active":
174
+ return f"ERR claim_not_active state={row['state']} id={claim_id}"
175
+ cid = _detect_self_cid()
176
+ now_t = int(time.time())
177
+ eid = _insert_evidence(conn, claim_id, kind, quote.strip(),
178
+ source.strip(), w, cid, now_t)
179
+ new_conf = _recompute_confidence(conn, claim_id)
180
+ _emit(conn, f"dialectic_evidence:{kind}", target=claim_id,
181
+ summary=(quote or "")[:140])
182
+ conn.commit()
183
+ return f"ok evidence_id={eid} claim={claim_id} conf={new_conf}"
184
+
185
+
186
+ @mcp.tool()
187
+ def dialectic_review(min_confidence: str = "low",
188
+ domain: str = "",
189
+ k: int = 20) -> str:
190
+ """List active claims filtered by confidence floor and optional
191
+ domain. Retired/superseded claims are omitted.
192
+
193
+ `min_confidence`: one of 'low','medium','high','disputed'. Note that
194
+ 'disputed' is treated as its own bucket (not ordered against the
195
+ others) — passing `min_confidence='disputed'` returns only disputed.
196
+
197
+ Format: '<id> [conf] domain=<d> support=N contradict=N <claim>'."""
198
+ rank = {"low": 0, "medium": 1, "high": 2}
199
+ if min_confidence not in VALID_CONFIDENCE:
200
+ return f"ERR bad_confidence={min_confidence}"
201
+ try:
202
+ klim = max(1, int(k))
203
+ except (TypeError, ValueError):
204
+ return f"ERR bad_k={k}"
205
+ conn = get_db()
206
+ sql = (
207
+ "SELECT id, claim, domain, support_count, contradict_count, "
208
+ "confidence, last_evidence_at, created_at FROM user_dialectic "
209
+ "WHERE state='active'"
210
+ )
211
+ params: list = []
212
+ dom_filter = domain.strip()
213
+ if dom_filter:
214
+ sql += " AND domain=?"
215
+ params.append(dom_filter)
216
+ sql += " ORDER BY last_evidence_at DESC, created_at DESC"
217
+ rows = conn.execute(sql, tuple(params)).fetchall()
218
+ out: list[str] = []
219
+ for r in rows:
220
+ conf = r["confidence"]
221
+ if min_confidence == "disputed":
222
+ if conf != "disputed":
223
+ continue
224
+ else:
225
+ if conf == "disputed":
226
+ # disputed is not above low/medium/high in normal ranks —
227
+ # surface it only when the caller explicitly asked.
228
+ continue
229
+ if rank.get(conf, 0) < rank[min_confidence]:
230
+ continue
231
+ dom_str = r["domain"] or "-"
232
+ out.append(
233
+ f"{r['id']} [{conf}] domain={dom_str} "
234
+ f"support={r['support_count']} contradict={r['contradict_count']} "
235
+ f"{r['claim']}"
236
+ )
237
+ if len(out) >= klim:
238
+ break
239
+ if not out:
240
+ return (
241
+ f"no_claims (min_confidence={min_confidence}"
242
+ + (f" domain={dom_filter}" if dom_filter else "")
243
+ + ")"
244
+ )
245
+ return "\n".join([f"claims n={len(out)}"] + out)
246
+
247
+
248
+ @mcp.tool()
249
+ def dialectic_synthesis(domain: str = "") -> str:
250
+ """Terse rendering of accumulated beliefs about the user, grouped by
251
+ domain. Used as brief() input. Excludes low/disputed claims and
252
+ non-active states. Returns at most 12 lines.
253
+
254
+ If `domain` is provided, restricts to that domain (no group headers
255
+ in that case)."""
256
+ conn = get_db()
257
+ sql = (
258
+ "SELECT id, claim, domain, confidence, support_count, "
259
+ "contradict_count FROM user_dialectic "
260
+ "WHERE state='active' AND confidence IN ('medium','high')"
261
+ )
262
+ params: list = []
263
+ dom_filter = domain.strip()
264
+ if dom_filter:
265
+ sql += " AND domain=?"
266
+ params.append(dom_filter)
267
+ # high before medium; within each, more evidence first
268
+ sql += (
269
+ " ORDER BY "
270
+ " CASE confidence WHEN 'high' THEN 0 ELSE 1 END, "
271
+ " (support_count - contradict_count) DESC, "
272
+ " domain ASC"
273
+ )
274
+ rows = conn.execute(sql, tuple(params)).fetchall()
275
+ if not rows:
276
+ return "no_synthesis"
277
+ # Group by domain, render with at most 12 total output lines (including
278
+ # group headers when no single-domain filter is active).
279
+ lines: list[str] = []
280
+ if dom_filter:
281
+ for r in rows:
282
+ if len(lines) >= 12:
283
+ break
284
+ tag = "★" if r["confidence"] == "high" else "·"
285
+ lines.append(f" {tag} {r['claim']}")
286
+ else:
287
+ grouped: dict[str, list[sqlite3.Row]] = {}
288
+ order: list[str] = []
289
+ for r in rows:
290
+ key = r["domain"] or "other"
291
+ if key not in grouped:
292
+ grouped[key] = []
293
+ order.append(key)
294
+ grouped[key].append(r)
295
+ for dom in order:
296
+ if len(lines) >= 12:
297
+ break
298
+ lines.append(f"[{dom}]")
299
+ for r in grouped[dom]:
300
+ if len(lines) >= 12:
301
+ break
302
+ tag = "★" if r["confidence"] == "high" else "·"
303
+ lines.append(f" {tag} {r['claim']}")
304
+ return "\n".join(lines)
305
+
306
+
307
+ @mcp.tool()
308
+ def dialectic_supersede(old_claim_id: str, new_claim: str,
309
+ domain: str = "", quote: str = "") -> str:
310
+ """Retire `old_claim_id` and register `new_claim` that refines or
311
+ replaces it. The old claim moves to state='superseded' with
312
+ superseded_by=<new_id>; its evidence is preserved (not deleted).
313
+
314
+ If `quote` is provided, it seeds the new claim with one supporting
315
+ piece of evidence sourced as 'supersede:<old_id>'.
316
+
317
+ If `domain` is empty, the new claim inherits the old claim's domain.
318
+
319
+ Returns: 'ok new=<UCxxx> old=<UCxxx> conf=<level>'."""
320
+ old_id = old_claim_id.strip()
321
+ new_claim = new_claim.strip()
322
+ if not new_claim:
323
+ return "ERR empty_new_claim"
324
+ if len(new_claim) > 1000:
325
+ return "ERR new_claim_too_long max=1000"
326
+ conn = get_db()
327
+ _ensure_session(conn)
328
+ old = conn.execute(
329
+ "SELECT id, domain, state FROM user_dialectic WHERE id=?", (old_id,)
330
+ ).fetchone()
331
+ if not old:
332
+ return f"ERR old_claim_not_found={old_id}"
333
+ if old["state"] != "active":
334
+ return f"ERR old_claim_not_active state={old['state']} id={old_id}"
335
+ dom = domain.strip() or old["domain"] or None
336
+ if dom and len(dom) > 64:
337
+ return "ERR domain_too_long max=64"
338
+ cid = _detect_self_cid()
339
+ pid = gen_dialectic_id(conn)
340
+ now_t = int(time.time())
341
+ conn.execute(
342
+ "INSERT INTO user_dialectic (id, claim, domain, created_by_cid, "
343
+ "created_at) VALUES (?,?,?,?,?)",
344
+ (pid, new_claim, dom, cid, now_t),
345
+ )
346
+ seed_quote = quote.strip()
347
+ if seed_quote:
348
+ _insert_evidence(conn, pid, "support", seed_quote,
349
+ f"supersede:{old_id}", 1.0, cid, now_t)
350
+ new_conf = _recompute_confidence(conn, pid)
351
+ conn.execute(
352
+ "UPDATE user_dialectic SET state='superseded', superseded_by=? "
353
+ "WHERE id=?",
354
+ (pid, old_id),
355
+ )
356
+ _emit(conn, "dialectic_supersede", target=pid,
357
+ summary=f"{old_id}→{pid} {new_claim[:100]}")
358
+ conn.commit()
359
+ return f"ok new={pid} old={old_id} conf={new_conf}"