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.
- threadkeeper/__init__.py +8 -0
- threadkeeper/_mcp.py +6 -0
- threadkeeper/_setup.py +299 -0
- threadkeeper/adapters/__init__.py +40 -0
- threadkeeper/adapters/_hook_helpers.py +72 -0
- threadkeeper/adapters/base.py +152 -0
- threadkeeper/adapters/claude_code.py +178 -0
- threadkeeper/adapters/claude_desktop.py +128 -0
- threadkeeper/adapters/codex.py +259 -0
- threadkeeper/adapters/copilot.py +195 -0
- threadkeeper/adapters/gemini.py +169 -0
- threadkeeper/adapters/vscode.py +144 -0
- threadkeeper/brief.py +735 -0
- threadkeeper/config.py +216 -0
- threadkeeper/curator.py +390 -0
- threadkeeper/db.py +474 -0
- threadkeeper/embeddings.py +232 -0
- threadkeeper/extract_daemon.py +125 -0
- threadkeeper/helpers.py +101 -0
- threadkeeper/i18n.py +342 -0
- threadkeeper/identity.py +237 -0
- threadkeeper/ingest.py +507 -0
- threadkeeper/lessons.py +170 -0
- threadkeeper/nudges.py +257 -0
- threadkeeper/process_health.py +202 -0
- threadkeeper/review_prompts.py +207 -0
- threadkeeper/search_proxy.py +160 -0
- threadkeeper/server.py +55 -0
- threadkeeper/shadow_review.py +358 -0
- threadkeeper/skill_watcher.py +96 -0
- threadkeeper/spawn_budget.py +246 -0
- threadkeeper/tools/__init__.py +2 -0
- threadkeeper/tools/concepts.py +111 -0
- threadkeeper/tools/consolidate.py +222 -0
- threadkeeper/tools/core_memory.py +109 -0
- threadkeeper/tools/correlation.py +116 -0
- threadkeeper/tools/curator.py +121 -0
- threadkeeper/tools/dialectic.py +359 -0
- threadkeeper/tools/dialog.py +131 -0
- threadkeeper/tools/distill.py +184 -0
- threadkeeper/tools/extract.py +411 -0
- threadkeeper/tools/graph.py +183 -0
- threadkeeper/tools/invariants.py +177 -0
- threadkeeper/tools/lessons.py +110 -0
- threadkeeper/tools/missed_spawns.py +142 -0
- threadkeeper/tools/peers.py +579 -0
- threadkeeper/tools/pickup.py +148 -0
- threadkeeper/tools/probes.py +251 -0
- threadkeeper/tools/process_health.py +90 -0
- threadkeeper/tools/session.py +34 -0
- threadkeeper/tools/shadow_review.py +106 -0
- threadkeeper/tools/skills.py +856 -0
- threadkeeper/tools/spawn.py +871 -0
- threadkeeper/tools/style.py +44 -0
- threadkeeper/tools/threads.py +299 -0
- threadkeeper-0.4.0.dist-info/METADATA +351 -0
- threadkeeper-0.4.0.dist-info/RECORD +61 -0
- threadkeeper-0.4.0.dist-info/WHEEL +5 -0
- threadkeeper-0.4.0.dist-info/entry_points.txt +2 -0
- threadkeeper-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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}"
|