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,131 @@
1
+ """Live cross-session dialog log tail window.
2
+
3
+ Opens a Terminal window that follows the shared dialog log so the user can
4
+ watch broadcast/whisper/question/answer traffic between concurrent claude
5
+ sessions in real time.
6
+ """
7
+
8
+ import shlex
9
+ import sqlite3
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from .._mcp import mcp
15
+ from ..config import TASK_LOG_DIR, DIALOG_LOG, SEMANTIC_AVAILABLE
16
+ from ..db import get_db
17
+ from ..helpers import fmt_age, q
18
+ from ..identity import _ensure_session
19
+ from ..embeddings import _dialog_cosine_search, _fts_search, _rrf_combine
20
+ from ..ingest import _ingest_all
21
+
22
+
23
+ @mcp.tool()
24
+ def open_dialog_window() -> str:
25
+ """Open a Terminal window that tails the live cross-session signal log.
26
+
27
+ Every broadcast/whisper/question/answer is appended to the log in real
28
+ time; this lets the user see the dialog between concurrent claude
29
+ sessions as it happens. The window stays open until you close it (it's
30
+ a `tail -F`, no exit). Title: 'thread-keeper-dialog'."""
31
+ TASK_LOG_DIR.mkdir(parents=True, exist_ok=True)
32
+ DIALOG_LOG.touch(exist_ok=True)
33
+ script_path = TASK_LOG_DIR / "dialog-tail.command"
34
+ tag = "thread-keeper-dialog"
35
+ script = (
36
+ "#!/bin/bash\n"
37
+ f"printf '\\033]0;{tag}\\007'\n"
38
+ "echo '── thread-keeper: live cross-session dialog ──'\n"
39
+ f"echo ' log: {DIALOG_LOG}'\n"
40
+ "echo ' ctrl+c or close window to stop'\n"
41
+ "echo\n"
42
+ f"exec tail -n 50 -F {shlex.quote(str(DIALOG_LOG))}\n"
43
+ )
44
+ script_path.write_text(script)
45
+ script_path.chmod(0o755)
46
+ try:
47
+ subprocess.Popen(["open", "-a", "Terminal", str(script_path)])
48
+ except (FileNotFoundError, OSError) as e:
49
+ return f"ERR open_failed={e}"
50
+ return f"opened tailing {DIALOG_LOG}"
51
+
52
+
53
+ @mcp.tool()
54
+ def dialog_search(query: str, k: int = 5, role: str = "",
55
+ mode: str = "hybrid") -> str:
56
+ """Search ingested Claude Code transcripts.
57
+
58
+ mode='hybrid' (default) combines semantic and FTS5 keyword via RRF.
59
+ mode='semantic' is pure cosine. mode='fts' is pure FTS5 keyword."""
60
+ conn = get_db()
61
+ _ensure_session(conn)
62
+ role = role.strip().lower()
63
+ mode = mode.strip().lower()
64
+ if mode not in ("hybrid", "semantic", "fts"):
65
+ return f"ERR bad_mode={mode} (use hybrid|semantic|fts)"
66
+ over_fetch = max(k * 5, 20)
67
+ sem_hits: list[dict] = []
68
+ fts_hits: list[dict] = []
69
+ if mode in ("hybrid", "semantic") and SEMANTIC_AVAILABLE:
70
+ sem_hits = _dialog_cosine_search(conn, query, over_fetch)
71
+ if mode in ("hybrid", "fts"):
72
+ fts_hits = _fts_search(conn, query, over_fetch)
73
+ if role:
74
+ sem_hits = [h for h in sem_hits if h.get("role") == role]
75
+ fts_hits = [h for h in fts_hits if h.get("role") == role]
76
+ if mode == "hybrid":
77
+ hits = _rrf_combine([sem_hits, fts_hits], top_n=k)
78
+ elif mode == "semantic":
79
+ hits = sem_hits[:k]
80
+ else:
81
+ hits = fts_hits[:k]
82
+ if not hits:
83
+ if not SEMANTIC_AVAILABLE and not fts_hits:
84
+ return _legacy_like_fallback(conn, query, k, role)
85
+ return f"no_matches (mode={mode})"
86
+ now = int(time.time())
87
+ lines = []
88
+ for h in hits:
89
+ snip = h["content"][:240].replace("\n", " ⏎ ")
90
+ ago = fmt_age(now - h["created_at"])
91
+ sess = (h["session_id"] or "-")[:8]
92
+ score_part = f"s={h['score']:.2f} " if h.get("score") is not None else ""
93
+ lines.append(f"{h['role']}@{sess} {score_part}{ago}_ago {q(snip)}")
94
+ return "\n".join(lines)
95
+
96
+
97
+ def _legacy_like_fallback(conn: sqlite3.Connection, query: str,
98
+ k: int, role: str) -> str:
99
+ pattern = f"%{query}%"
100
+ where = "content LIKE ?"
101
+ params: list = [pattern]
102
+ if role:
103
+ where += " AND role = ?"
104
+ params.append(role)
105
+ params.append(k)
106
+ rows = conn.execute(
107
+ f"SELECT * FROM dialog_messages WHERE {where} "
108
+ f"ORDER BY created_at DESC LIMIT ?", params,
109
+ ).fetchall()
110
+ if not rows:
111
+ return "no_matches (no_embeddings, used LIKE)"
112
+ now = int(time.time())
113
+ return "\n".join(
114
+ f"{r['role']}@{(r['session_id'] or '-')[:8]} "
115
+ f"{fmt_age(now - r['created_at'])}_ago "
116
+ f"{q(r['content'][:240].replace(chr(10), ' '))}"
117
+ for r in rows
118
+ )
119
+
120
+
121
+ @mcp.tool()
122
+ def ingest(max_msgs: int = 5000) -> str:
123
+ """Ingest new Claude Code transcripts. Auto-runs on session start; call
124
+ manually for backfill or after long absence."""
125
+ conn = get_db()
126
+ _ensure_session(conn)
127
+ new_msgs, files = _ingest_all(conn, max_msgs=max_msgs)
128
+ total = conn.execute(
129
+ "SELECT COUNT(*) c FROM dialog_messages"
130
+ ).fetchone()["c"]
131
+ return f"ingested new={new_msgs} files_seen={files} total_indexed={total}"
@@ -0,0 +1,184 @@
1
+ """Distillation MCP tools.
2
+
3
+ Extracted from server.py. Provides the distillation channel — content
4
+ worth carrying forward across sessions: insights, patterns, anti-patterns,
5
+ fixes, terminology, concepts. Other sessions can vote on a distillate;
6
+ high-vote items are exported to a curated jsonl bucket.
7
+ """
8
+
9
+ import json as _json
10
+ import sqlite3
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from .._mcp import mcp
16
+ from ..db import get_db
17
+ from ..config import TASK_LOG_DIR
18
+ from ..helpers import fmt_age, q, gen_distill_id
19
+ from ..identity import _ensure_session, _detect_self_cid, _emit
20
+
21
+
22
+ DISTILL_KINDS = ("insight", "pattern", "anti-pattern", "fix",
23
+ "terminology", "concept")
24
+
25
+
26
+ @mcp.tool()
27
+ def distill(content: str, kind: str = "insight",
28
+ confidence: str = "medium", source_thread: str = "") -> str:
29
+ """Mark content as worth carrying forward (distillation channel).
30
+
31
+ `kind` ∈ {insight, pattern, anti-pattern, fix, terminology, concept}.
32
+ `confidence` ∈ {low, medium, high}. `source_thread` optional. Other
33
+ sessions can vote on it via vote_distill; export_distillates emits
34
+ a curated jsonl bucket."""
35
+ if kind not in DISTILL_KINDS:
36
+ return f"ERR bad_kind={kind} (valid: {','.join(DISTILL_KINDS)})"
37
+ if confidence not in ("low", "medium", "high"):
38
+ return f"ERR bad_confidence={confidence}"
39
+ if not content.strip():
40
+ return "ERR empty_content"
41
+ conn = get_db()
42
+ _ensure_session(conn)
43
+ src = source_thread.strip() or None
44
+ if src and not conn.execute(
45
+ "SELECT 1 FROM threads WHERE id=?", (src,)
46
+ ).fetchone():
47
+ return f"ERR source_thread_not_found={src}"
48
+ cid = _detect_self_cid()
49
+ pid = gen_distill_id(conn)
50
+ now_t = int(time.time())
51
+ conn.execute(
52
+ "INSERT INTO distill (id, content, kind, confidence, source_thread, "
53
+ "source_cid, created_at) VALUES (?,?,?,?,?,?,?)",
54
+ (pid, content, kind, confidence, src, cid, now_t),
55
+ )
56
+ # Auto-vote +1 from author (still bounded by uniqueness)
57
+ if cid:
58
+ conn.execute(
59
+ "INSERT INTO distill_votes (distill_id, voter_cid, weight, "
60
+ "voted_at) VALUES (?,?,?,?)",
61
+ (pid, cid, 1.0, now_t),
62
+ )
63
+ conn.execute(
64
+ "UPDATE distill SET vote_sum=1.0, vote_count=1 WHERE id=?",
65
+ (pid,),
66
+ )
67
+ _emit(conn, "distill", target=pid, summary=content[:140])
68
+ conn.commit()
69
+ return f"ok id={pid} kind={kind} conf={confidence} vote=1.0"
70
+
71
+
72
+ @mcp.tool()
73
+ def vote_distill(distill_id: str, weight: float) -> str:
74
+ """Vote on a distillate, weight ∈ [-1, +1]. One vote per cid; re-voting
75
+ overwrites your previous vote. Updates aggregate vote_sum/vote_count."""
76
+ try:
77
+ w = float(weight)
78
+ except (TypeError, ValueError):
79
+ return "ERR weight_not_numeric"
80
+ if w < -1 or w > 1:
81
+ return f"ERR weight_out_of_range={w}"
82
+ conn = get_db()
83
+ _ensure_session(conn)
84
+ cid = _detect_self_cid()
85
+ if not cid:
86
+ return "ERR cannot_detect_self_cid"
87
+ did = distill_id.strip()
88
+ if not conn.execute("SELECT 1 FROM distill WHERE id=?", (did,)).fetchone():
89
+ return f"ERR distill_not_found={did}"
90
+ now_t = int(time.time())
91
+ # upsert vote
92
+ conn.execute(
93
+ "INSERT INTO distill_votes (distill_id, voter_cid, weight, voted_at) "
94
+ "VALUES (?,?,?,?) ON CONFLICT(distill_id, voter_cid) DO UPDATE SET "
95
+ "weight=excluded.weight, voted_at=excluded.voted_at",
96
+ (did, cid, w, now_t),
97
+ )
98
+ # recompute aggregates
99
+ agg = conn.execute(
100
+ "SELECT SUM(weight) s, COUNT(*) c FROM distill_votes WHERE distill_id=?",
101
+ (did,),
102
+ ).fetchone()
103
+ conn.execute(
104
+ "UPDATE distill SET vote_sum=?, vote_count=? WHERE id=?",
105
+ (agg["s"] or 0.0, agg["c"] or 0, did),
106
+ )
107
+ _emit(conn, "vote_distill", target=did, summary=f"w={w}")
108
+ conn.commit()
109
+ return f"ok id={did} vote_sum={agg['s']:.2f} count={agg['c']}"
110
+
111
+
112
+ @mcp.tool()
113
+ def pending_distillates(min_vote: float = 1.0, k: int = 10) -> str:
114
+ """List distillates with vote_sum >= min_vote, not yet exported."""
115
+ conn = get_db()
116
+ rows = conn.execute(
117
+ "SELECT id, kind, confidence, content, vote_sum, vote_count, "
118
+ "source_thread, created_at "
119
+ "FROM distill WHERE vote_sum >= ? AND exported_at IS NULL "
120
+ "ORDER BY vote_sum DESC, created_at DESC LIMIT ?",
121
+ (float(min_vote), max(1, int(k))),
122
+ ).fetchall()
123
+ if not rows:
124
+ return f"no_pending min_vote={min_vote}"
125
+ now_t = int(time.time())
126
+ lines = [f"pending n={len(rows)} min_vote={min_vote}"]
127
+ for r in rows:
128
+ snip = r["content"][:160].replace("\n", " ")
129
+ if len(r["content"]) > 160:
130
+ snip += "…"
131
+ lines.append(
132
+ f" {r['id']} {r['kind']:<13} conf={r['confidence']} "
133
+ f"votes={r['vote_sum']:.1f}/{r['vote_count']} "
134
+ f"src={r['source_thread'] or '-'} age="
135
+ f"{fmt_age(now_t - r['created_at'])}_ago"
136
+ )
137
+ lines.append(f" {snip}")
138
+ return "\n".join(lines)
139
+
140
+
141
+ @mcp.tool()
142
+ def export_distillates(min_vote: float = 1.0,
143
+ output_path: str = "") -> str:
144
+ """Write distillates with vote_sum >= min_vote to a jsonl bucket.
145
+ Marks them exported_at so the same item isn't re-exported next call.
146
+ Default output: /tmp/thread-keeper-tasks/distillates.jsonl."""
147
+ out_path = Path(output_path.strip()) if output_path.strip() else (
148
+ TASK_LOG_DIR / "distillates.jsonl"
149
+ )
150
+ out_path.parent.mkdir(parents=True, exist_ok=True)
151
+ conn = get_db()
152
+ rows = conn.execute(
153
+ "SELECT id, kind, confidence, content, vote_sum, vote_count, "
154
+ "source_thread, source_cid, created_at "
155
+ "FROM distill WHERE vote_sum >= ? AND exported_at IS NULL "
156
+ "ORDER BY created_at",
157
+ (float(min_vote),),
158
+ ).fetchall()
159
+ if not rows:
160
+ return f"nothing_to_export min_vote={min_vote}"
161
+ now_t = int(time.time())
162
+ written = 0
163
+ with out_path.open("a", encoding="utf-8") as fp:
164
+ for r in rows:
165
+ obj = {
166
+ "id": r["id"], "kind": r["kind"],
167
+ "confidence": r["confidence"],
168
+ "content": r["content"],
169
+ "vote_sum": r["vote_sum"], "vote_count": r["vote_count"],
170
+ "source_thread": r["source_thread"],
171
+ "source_cid": r["source_cid"],
172
+ "created_at": r["created_at"],
173
+ "exported_at": now_t,
174
+ }
175
+ fp.write(_json.dumps(obj, ensure_ascii=False) + "\n")
176
+ written += 1
177
+ ids = [r["id"] for r in rows]
178
+ conn.execute(
179
+ f"UPDATE distill SET exported_at=? WHERE id IN "
180
+ f"({','.join('?' * len(ids))})",
181
+ (now_t, *ids),
182
+ )
183
+ conn.commit()
184
+ return f"exported n={written} → {out_path} (now total: {out_path.stat().st_size} bytes)"