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,579 @@
|
|
|
1
|
+
"""Cross-session channel: collective awareness across concurrent claude windows.
|
|
2
|
+
|
|
3
|
+
Identity is conversation_id (jsonl stem). Use peers() to discover who's live,
|
|
4
|
+
broadcast() to talk to all, whisper() to address one, inbox() to read mail.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json as _json
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .._mcp import mcp
|
|
13
|
+
from ..db import get_db
|
|
14
|
+
from ..helpers import fmt_age, q
|
|
15
|
+
from .. import identity
|
|
16
|
+
from ..identity import (
|
|
17
|
+
_ensure_session,
|
|
18
|
+
_ensure_cursor,
|
|
19
|
+
_detect_self_cid,
|
|
20
|
+
_emit,
|
|
21
|
+
_heartbeat,
|
|
22
|
+
)
|
|
23
|
+
from ..brief import _append_dialog_log
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
def whoami() -> str:
|
|
28
|
+
"""Return this conversation's detected conversation_id + how we know.
|
|
29
|
+
|
|
30
|
+
Resolution order:
|
|
31
|
+
- 'forced': THREADKEEPER_FORCE_CID env (set by spawn() for children)
|
|
32
|
+
- 'ppid': walk up process tree → claude --resume/--session-id <uuid>
|
|
33
|
+
- 'mtime': fallback heuristic (latest jsonl mtime; flaps under
|
|
34
|
+
concurrent peer activity)
|
|
35
|
+
"""
|
|
36
|
+
cid = _detect_self_cid()
|
|
37
|
+
if not cid:
|
|
38
|
+
return "no_cid_detected"
|
|
39
|
+
via = identity._self_cid_via or "?"
|
|
40
|
+
note = {
|
|
41
|
+
"forced": "via env THREADKEEPER_FORCE_CID (stable)",
|
|
42
|
+
"ppid": "via ppid walk to claude CLI args (stable)",
|
|
43
|
+
"mtime": "via latest-jsonl-mtime (heuristic; may flap)",
|
|
44
|
+
}.get(via, f"via {via}")
|
|
45
|
+
return f"cid={cid} ({note})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def peers(window_min: int = 5) -> str:
|
|
50
|
+
"""List concurrent claude conversations active in the last `window_min`.
|
|
51
|
+
|
|
52
|
+
Activity inferred from dialog_messages (ingested live). For each peer
|
|
53
|
+
returns: cid, last user message snippet, age, message count. Self is
|
|
54
|
+
marked with `*`. Empty if you're alone."""
|
|
55
|
+
conn = get_db()
|
|
56
|
+
_ensure_session(conn)
|
|
57
|
+
self_cid = _detect_self_cid()
|
|
58
|
+
now_t = int(time.time())
|
|
59
|
+
cutoff = now_t - (window_min * 60)
|
|
60
|
+
rows = conn.execute(
|
|
61
|
+
"SELECT session_id, role, content, created_at FROM dialog_messages "
|
|
62
|
+
"WHERE created_at > ? AND session_id IS NOT NULL AND session_id != '' "
|
|
63
|
+
"ORDER BY created_at DESC LIMIT 200",
|
|
64
|
+
(cutoff,),
|
|
65
|
+
).fetchall()
|
|
66
|
+
by_sess: dict[str, dict] = {}
|
|
67
|
+
for r in rows:
|
|
68
|
+
sid = r["session_id"]
|
|
69
|
+
d = by_sess.setdefault(
|
|
70
|
+
sid, {"last_user": None, "last_any_at": 0, "msgs": 0}
|
|
71
|
+
)
|
|
72
|
+
d["msgs"] += 1
|
|
73
|
+
if r["created_at"] > d["last_any_at"]:
|
|
74
|
+
d["last_any_at"] = r["created_at"]
|
|
75
|
+
if r["role"] == "user" and d["last_user"] is None:
|
|
76
|
+
content = r["content"]
|
|
77
|
+
if content.startswith("[tool_result]") or content.startswith("[Image"):
|
|
78
|
+
alt = conn.execute(
|
|
79
|
+
"SELECT content, created_at FROM dialog_messages "
|
|
80
|
+
"WHERE session_id=? AND role='user' "
|
|
81
|
+
"AND content NOT LIKE '[tool_result]%' "
|
|
82
|
+
"AND content NOT LIKE '[Image%' "
|
|
83
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
84
|
+
(sid,),
|
|
85
|
+
).fetchone()
|
|
86
|
+
if alt:
|
|
87
|
+
d["last_user"] = {"content": alt["content"], "created_at": alt["created_at"]}
|
|
88
|
+
else:
|
|
89
|
+
d["last_user"] = dict(r)
|
|
90
|
+
if not by_sess:
|
|
91
|
+
return "no_peers (you alone)"
|
|
92
|
+
items = sorted(
|
|
93
|
+
by_sess.items(), key=lambda x: x[1]["last_any_at"], reverse=True
|
|
94
|
+
)
|
|
95
|
+
lines = []
|
|
96
|
+
for sid, d in items:
|
|
97
|
+
marker = "*" if sid == self_cid else " "
|
|
98
|
+
u = d["last_user"]
|
|
99
|
+
if u:
|
|
100
|
+
snip = u["content"][:80].replace("\n", " ")
|
|
101
|
+
if len(u["content"]) > 80:
|
|
102
|
+
snip += "…"
|
|
103
|
+
u_age = fmt_age(now_t - u["created_at"])
|
|
104
|
+
lines.append(
|
|
105
|
+
f"{marker} {sid[:8]} u={q(snip)} u_age={u_age} msgs={d['msgs']}"
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
lines.append(f"{marker} {sid[:8]} (no user msg) msgs={d['msgs']}")
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def broadcast(content: str) -> str:
|
|
114
|
+
"""Post a message visible to ALL concurrent claude conversations.
|
|
115
|
+
|
|
116
|
+
Other peers see it in their next brief() under `inbox` (unread) and via
|
|
117
|
+
inbox(). Use for: shared insights, status updates, work claims, anything
|
|
118
|
+
you'd want sibling sessions to know."""
|
|
119
|
+
return _post_signal(to_cid="", content=content, kind="broadcast")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
def whisper(to_cid: str, content: str) -> str:
|
|
124
|
+
"""Post a message visible only to the specified conversation_id.
|
|
125
|
+
|
|
126
|
+
Use peers() to discover available cids. The 8-char prefix shown there is
|
|
127
|
+
enough — it'll be matched as prefix. Use whoami() to get your own cid
|
|
128
|
+
(rarely needed; messages from self to self are dropped)."""
|
|
129
|
+
return _post_signal(to_cid=to_cid.strip(), content=content, kind="whisper")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _post_signal(to_cid: str, content: str, kind: str) -> str:
|
|
133
|
+
self_cid = _detect_self_cid()
|
|
134
|
+
if not self_cid:
|
|
135
|
+
return "ERR cannot_detect_self_cid"
|
|
136
|
+
conn = get_db()
|
|
137
|
+
_ensure_session(conn)
|
|
138
|
+
target: Optional[str] = None
|
|
139
|
+
if to_cid:
|
|
140
|
+
if len(to_cid) < 36:
|
|
141
|
+
row = conn.execute(
|
|
142
|
+
"SELECT DISTINCT session_id FROM dialog_messages "
|
|
143
|
+
"WHERE session_id LIKE ? LIMIT 2",
|
|
144
|
+
(to_cid + "%",),
|
|
145
|
+
).fetchall()
|
|
146
|
+
if not row:
|
|
147
|
+
return f"ERR no_peer_matching={to_cid}"
|
|
148
|
+
if len(row) > 1:
|
|
149
|
+
return f"ERR ambiguous_prefix={to_cid} matches={len(row)}"
|
|
150
|
+
target = row[0]["session_id"]
|
|
151
|
+
else:
|
|
152
|
+
target = to_cid
|
|
153
|
+
if target == self_cid:
|
|
154
|
+
return "ERR self_target (whispering to yourself; use note instead)"
|
|
155
|
+
now_t = int(time.time())
|
|
156
|
+
auto_task = None
|
|
157
|
+
try:
|
|
158
|
+
candidate_cids = [self_cid]
|
|
159
|
+
if target:
|
|
160
|
+
candidate_cids.append(target)
|
|
161
|
+
for cc in candidate_cids:
|
|
162
|
+
row = conn.execute(
|
|
163
|
+
"SELECT id FROM tasks WHERE spawned_cid=? "
|
|
164
|
+
"ORDER BY started_at DESC LIMIT 1",
|
|
165
|
+
(cc,),
|
|
166
|
+
).fetchone()
|
|
167
|
+
if row:
|
|
168
|
+
auto_task = row["id"]
|
|
169
|
+
break
|
|
170
|
+
except sqlite3.OperationalError:
|
|
171
|
+
pass
|
|
172
|
+
cur = conn.execute(
|
|
173
|
+
"INSERT INTO signals (from_cid, to_cid, kind, content, created_at, task_id) "
|
|
174
|
+
"VALUES (?,?,?,?,?,?)",
|
|
175
|
+
(self_cid, target, kind, content, now_t, auto_task),
|
|
176
|
+
)
|
|
177
|
+
_emit(conn, f"signal:{kind}", target=target or "*", summary=content)
|
|
178
|
+
_append_dialog_log(self_cid, target, kind, content)
|
|
179
|
+
conn.commit()
|
|
180
|
+
tail = f" task={auto_task}" if auto_task else ""
|
|
181
|
+
if target:
|
|
182
|
+
return f"ok id={cur.lastrowid} -> {target[:8]}{tail}"
|
|
183
|
+
return f"ok id={cur.lastrowid} broadcast{tail}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@mcp.tool()
|
|
187
|
+
def wait(timeout_s: int = 30, kinds: str = "", mark_read: bool = True) -> str:
|
|
188
|
+
"""Block until a new signal arrives for me or `timeout_s` elapses.
|
|
189
|
+
|
|
190
|
+
Returns immediately if there are unread signals. Otherwise polls the
|
|
191
|
+
signals table every 250ms. Use this for realtime turn-based exchange
|
|
192
|
+
with peers: one side waits, the other side broadcasts/whispers/responds.
|
|
193
|
+
|
|
194
|
+
`kinds`: comma-separated filter ('whisper,question,answer,broadcast');
|
|
195
|
+
empty = any. `timeout_s` is clamped to [1, 120] (mcp tool call has its
|
|
196
|
+
own deadline; don't oversleep)."""
|
|
197
|
+
self_cid = _detect_self_cid()
|
|
198
|
+
if not self_cid:
|
|
199
|
+
return "ERR cannot_detect_self_cid"
|
|
200
|
+
timeout_s = max(1, min(int(timeout_s), 120))
|
|
201
|
+
kinds_filter = [k.strip() for k in kinds.split(",") if k.strip()]
|
|
202
|
+
conn = get_db()
|
|
203
|
+
_ensure_session(conn)
|
|
204
|
+
deadline = time.time() + timeout_s
|
|
205
|
+
poll_interval = 0.25
|
|
206
|
+
while True:
|
|
207
|
+
params: list = [self_cid, self_cid]
|
|
208
|
+
kind_clause = ""
|
|
209
|
+
if kinds_filter:
|
|
210
|
+
ph = ",".join(["?"] * len(kinds_filter))
|
|
211
|
+
kind_clause = f" AND kind IN ({ph})"
|
|
212
|
+
params += kinds_filter
|
|
213
|
+
rows = conn.execute(
|
|
214
|
+
f"SELECT id, from_cid, to_cid, kind, content, created_at FROM signals "
|
|
215
|
+
f"WHERE (to_cid = ? OR to_cid IS NULL) AND from_cid != ? "
|
|
216
|
+
f"AND read_at IS NULL{kind_clause} "
|
|
217
|
+
f"ORDER BY created_at ASC LIMIT 10",
|
|
218
|
+
params,
|
|
219
|
+
).fetchall()
|
|
220
|
+
if rows:
|
|
221
|
+
now_t = int(time.time())
|
|
222
|
+
lines = [f"got={len(rows)} cid={self_cid[:8]}"]
|
|
223
|
+
for r in rows:
|
|
224
|
+
ago = fmt_age(now_t - r["created_at"])
|
|
225
|
+
scope = "*" if r["to_cid"] is None else "→me"
|
|
226
|
+
lines.append(
|
|
227
|
+
f" #{r['id']} {scope} from={r['from_cid'][:8]} "
|
|
228
|
+
f"+{r['kind']} {ago}_ago {q(r['content'][:240])}"
|
|
229
|
+
)
|
|
230
|
+
if mark_read:
|
|
231
|
+
conn.execute(
|
|
232
|
+
f"UPDATE signals SET read_at=? "
|
|
233
|
+
f"WHERE id IN ({','.join('?' * len(rows))})",
|
|
234
|
+
(now_t, *[r["id"] for r in rows]),
|
|
235
|
+
)
|
|
236
|
+
conn.commit()
|
|
237
|
+
return "\n".join(lines)
|
|
238
|
+
if time.time() >= deadline:
|
|
239
|
+
return f"timeout after {timeout_s}s (no_signals)"
|
|
240
|
+
time.sleep(poll_interval)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@mcp.tool()
|
|
244
|
+
def ask(to_cid: str, question: str, timeout_s: int = 60) -> str:
|
|
245
|
+
"""Send a question to a peer and wait synchronously for their answer.
|
|
246
|
+
|
|
247
|
+
Mechanics: posts a whisper with kind='question'; blocks until target
|
|
248
|
+
posts a whisper/answer back to me, or `timeout_s` elapses. Use peers()
|
|
249
|
+
to find available cids; 8-char prefix accepted.
|
|
250
|
+
|
|
251
|
+
Note: requires the target to be in a `wait()` loop or actively calling
|
|
252
|
+
inbox()+respond(). If they're idle, you'll just timeout."""
|
|
253
|
+
self_cid = _detect_self_cid()
|
|
254
|
+
if not self_cid:
|
|
255
|
+
return "ERR cannot_detect_self_cid"
|
|
256
|
+
if not to_cid.strip():
|
|
257
|
+
return "ERR empty_to_cid"
|
|
258
|
+
timeout_s = max(1, min(int(timeout_s), 120))
|
|
259
|
+
conn = get_db()
|
|
260
|
+
_ensure_session(conn)
|
|
261
|
+
target = to_cid.strip()
|
|
262
|
+
if len(target) < 36:
|
|
263
|
+
row = conn.execute(
|
|
264
|
+
"SELECT DISTINCT session_id FROM dialog_messages "
|
|
265
|
+
"WHERE session_id LIKE ? LIMIT 2",
|
|
266
|
+
(target + "%",),
|
|
267
|
+
).fetchall()
|
|
268
|
+
if not row:
|
|
269
|
+
return f"ERR no_peer_matching={target}"
|
|
270
|
+
if len(row) > 1:
|
|
271
|
+
return f"ERR ambiguous_prefix={target} matches={len(row)}"
|
|
272
|
+
target = row[0]["session_id"]
|
|
273
|
+
if target == self_cid:
|
|
274
|
+
return "ERR self_target"
|
|
275
|
+
now_t = int(time.time())
|
|
276
|
+
cur = conn.execute(
|
|
277
|
+
"INSERT INTO signals (from_cid, to_cid, kind, content, created_at) "
|
|
278
|
+
"VALUES (?,?,?,?,?)",
|
|
279
|
+
(self_cid, target, "question", question, now_t),
|
|
280
|
+
)
|
|
281
|
+
qid = cur.lastrowid
|
|
282
|
+
_emit(conn, "signal:question", target=target, summary=question)
|
|
283
|
+
conn.commit()
|
|
284
|
+
deadline = time.time() + timeout_s
|
|
285
|
+
while True:
|
|
286
|
+
ans = conn.execute(
|
|
287
|
+
"SELECT id, content, kind, created_at FROM signals "
|
|
288
|
+
"WHERE from_cid=? AND to_cid=? AND kind IN ('answer','whisper') "
|
|
289
|
+
"AND created_at >= ? ORDER BY created_at ASC LIMIT 1",
|
|
290
|
+
(target, self_cid, now_t),
|
|
291
|
+
).fetchone()
|
|
292
|
+
if ans:
|
|
293
|
+
conn.execute(
|
|
294
|
+
"UPDATE signals SET read_at=? WHERE id=?",
|
|
295
|
+
(int(time.time()), ans["id"]),
|
|
296
|
+
)
|
|
297
|
+
conn.commit()
|
|
298
|
+
return (
|
|
299
|
+
f"answer #{ans['id']} ({ans['kind']}) from {target[:8]}: "
|
|
300
|
+
f"{ans['content']}"
|
|
301
|
+
)
|
|
302
|
+
if time.time() >= deadline:
|
|
303
|
+
return (
|
|
304
|
+
f"TIMEOUT qid={qid} target={target[:8]} "
|
|
305
|
+
f"(no whisper/answer reply within {timeout_s}s)"
|
|
306
|
+
)
|
|
307
|
+
time.sleep(0.4)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@mcp.tool()
|
|
311
|
+
def respond(qid: int, content: str) -> str:
|
|
312
|
+
"""Answer a specific question (signals.id) with a directed whisper.
|
|
313
|
+
|
|
314
|
+
Use after seeing a `+question` entry in inbox()/wait(). Marks the
|
|
315
|
+
original question as read and inserts an `answer` whisper to the asker."""
|
|
316
|
+
self_cid = _detect_self_cid()
|
|
317
|
+
if not self_cid:
|
|
318
|
+
return "ERR cannot_detect_self_cid"
|
|
319
|
+
conn = get_db()
|
|
320
|
+
_ensure_session(conn)
|
|
321
|
+
qrow = conn.execute(
|
|
322
|
+
"SELECT from_cid, to_cid, kind FROM signals WHERE id=?", (qid,)
|
|
323
|
+
).fetchone()
|
|
324
|
+
if not qrow:
|
|
325
|
+
return f"ERR question_not_found id={qid}"
|
|
326
|
+
if qrow["to_cid"] != self_cid:
|
|
327
|
+
scope = qrow["to_cid"][:8] if qrow["to_cid"] else "broadcast"
|
|
328
|
+
return f"ERR not_addressed_to_me question.to={scope}"
|
|
329
|
+
now_t = int(time.time())
|
|
330
|
+
cur = conn.execute(
|
|
331
|
+
"INSERT INTO signals (from_cid, to_cid, kind, content, created_at) "
|
|
332
|
+
"VALUES (?,?,?,?,?)",
|
|
333
|
+
(self_cid, qrow["from_cid"], "answer", content, now_t),
|
|
334
|
+
)
|
|
335
|
+
conn.execute("UPDATE signals SET read_at=? WHERE id=?", (now_t, qid))
|
|
336
|
+
_emit(conn, "signal:answer", target=qrow["from_cid"], summary=content)
|
|
337
|
+
_append_dialog_log(self_cid, qrow["from_cid"], "answer", content)
|
|
338
|
+
conn.commit()
|
|
339
|
+
return f"ok id={cur.lastrowid} -> {qrow['from_cid'][:8]}"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@mcp.tool()
|
|
343
|
+
def inbox(unread_only: bool = True, k: int = 20, mark_read: bool = True) -> str:
|
|
344
|
+
"""Read signals addressed to me (whispers + broadcasts).
|
|
345
|
+
|
|
346
|
+
`unread_only=True` (default) returns only what hasn't been seen yet, and
|
|
347
|
+
if `mark_read=True` marks them read on this call. Set both False to
|
|
348
|
+
re-read history."""
|
|
349
|
+
self_cid = _detect_self_cid()
|
|
350
|
+
if not self_cid:
|
|
351
|
+
return "ERR cannot_detect_self_cid"
|
|
352
|
+
conn = get_db()
|
|
353
|
+
_ensure_session(conn)
|
|
354
|
+
where = "(to_cid = ? OR to_cid IS NULL) AND from_cid != ?"
|
|
355
|
+
params: list = [self_cid, self_cid]
|
|
356
|
+
if unread_only:
|
|
357
|
+
where += " AND read_at IS NULL"
|
|
358
|
+
rows = conn.execute(
|
|
359
|
+
f"SELECT id, from_cid, to_cid, kind, content, created_at FROM signals "
|
|
360
|
+
f"WHERE {where} ORDER BY created_at DESC LIMIT ?",
|
|
361
|
+
(*params, k),
|
|
362
|
+
).fetchall()
|
|
363
|
+
if not rows:
|
|
364
|
+
conn.commit()
|
|
365
|
+
return "no_signals"
|
|
366
|
+
now_t = int(time.time())
|
|
367
|
+
lines = [f"got={len(rows)} cid={self_cid[:8]}"]
|
|
368
|
+
for r in rows:
|
|
369
|
+
ago = fmt_age(now_t - r["created_at"])
|
|
370
|
+
scope = "*" if r["to_cid"] is None else "→me"
|
|
371
|
+
lines.append(
|
|
372
|
+
f" #{r['id']} {scope} from={r['from_cid'][:8]} +{r['kind']} "
|
|
373
|
+
f"{ago}_ago {q(r['content'][:200])}"
|
|
374
|
+
)
|
|
375
|
+
if mark_read and unread_only:
|
|
376
|
+
conn.execute(
|
|
377
|
+
f"UPDATE signals SET read_at=? "
|
|
378
|
+
f"WHERE id IN ({','.join('?' * len(rows))})",
|
|
379
|
+
(now_t, *[r["id"] for r in rows]),
|
|
380
|
+
)
|
|
381
|
+
conn.commit()
|
|
382
|
+
return "\n".join(lines)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@mcp.tool()
|
|
386
|
+
def live_status(advance_cursor: bool = True, k: int = 30) -> str:
|
|
387
|
+
"""See what OTHER concurrent Claude sessions did since this session last
|
|
388
|
+
polled. Call when brief() shows live=N where N>0, or proactively when you
|
|
389
|
+
suspect a parallel instance is working on something relevant. Advances
|
|
390
|
+
this session's cursor by default; pass advance_cursor=False to peek
|
|
391
|
+
without consuming."""
|
|
392
|
+
conn = get_db()
|
|
393
|
+
_ensure_session(conn)
|
|
394
|
+
_ensure_cursor(conn)
|
|
395
|
+
cur_row = conn.execute(
|
|
396
|
+
"SELECT last_event_id FROM cursors WHERE session_id=?",
|
|
397
|
+
(identity._session_id,),
|
|
398
|
+
).fetchone()
|
|
399
|
+
cur_id = cur_row["last_event_id"] if cur_row else 0
|
|
400
|
+
rows = conn.execute(
|
|
401
|
+
"SELECT id, session_id, kind, target, summary, created_at FROM events "
|
|
402
|
+
"WHERE id > ? AND session_id != ? ORDER BY id ASC LIMIT ?",
|
|
403
|
+
(cur_id, identity._session_id, k),
|
|
404
|
+
).fetchall()
|
|
405
|
+
_heartbeat(conn)
|
|
406
|
+
conn.execute(
|
|
407
|
+
"DELETE FROM events WHERE created_at < ?",
|
|
408
|
+
(int(time.time()) - 30 * 86400,),
|
|
409
|
+
)
|
|
410
|
+
if not rows:
|
|
411
|
+
conn.commit()
|
|
412
|
+
return "no_fresh_events"
|
|
413
|
+
now = int(time.time())
|
|
414
|
+
lines = [f"fresh={len(rows)}"]
|
|
415
|
+
for e in rows:
|
|
416
|
+
ago = fmt_age(now - e["created_at"])
|
|
417
|
+
target = e["target"] or "-"
|
|
418
|
+
summary = (e["summary"] or "")[:140]
|
|
419
|
+
lines.append(
|
|
420
|
+
f" {e['session_id']}@{target} +{e['kind']} {q(summary)} {ago}_ago"
|
|
421
|
+
)
|
|
422
|
+
if advance_cursor:
|
|
423
|
+
max_seen = rows[-1]["id"]
|
|
424
|
+
conn.execute(
|
|
425
|
+
"UPDATE cursors SET last_event_id=?, updated_at=? WHERE session_id=?",
|
|
426
|
+
(max_seen, now, identity._session_id),
|
|
427
|
+
)
|
|
428
|
+
conn.commit()
|
|
429
|
+
return "\n".join(lines)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@mcp.tool()
|
|
433
|
+
def presence(idle_threshold_min: int = 5) -> str:
|
|
434
|
+
"""List concurrent Claude sessions with heartbeats within threshold
|
|
435
|
+
(default 5 min). Excludes self. Useful for understanding who else is
|
|
436
|
+
currently active before making changes."""
|
|
437
|
+
conn = get_db()
|
|
438
|
+
_ensure_session(conn)
|
|
439
|
+
_heartbeat(conn)
|
|
440
|
+
now = int(time.time())
|
|
441
|
+
threshold = now - (idle_threshold_min * 60)
|
|
442
|
+
rows = conn.execute(
|
|
443
|
+
"SELECT * FROM presence WHERE heartbeat_at >= ? AND session_id != ? "
|
|
444
|
+
"ORDER BY heartbeat_at DESC",
|
|
445
|
+
(threshold, identity._session_id),
|
|
446
|
+
).fetchall()
|
|
447
|
+
conn.commit()
|
|
448
|
+
if not rows:
|
|
449
|
+
return "no_other_active"
|
|
450
|
+
lines = [f"active_others={len(rows)}"]
|
|
451
|
+
for p in rows:
|
|
452
|
+
idle = fmt_age(now - p["heartbeat_at"])
|
|
453
|
+
lines.append(
|
|
454
|
+
f" {p['session_id']} cli={p['client'] or '-'} "
|
|
455
|
+
f"on={p['current_thread'] or '-'} "
|
|
456
|
+
f"last={p['last_action'] or '-'} idle={idle}"
|
|
457
|
+
)
|
|
458
|
+
return "\n".join(lines)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _resolve_parent_cid(conn) -> Optional[str]:
|
|
462
|
+
"""Find this child's parent cid via tasks.parent_cid. Returns None when
|
|
463
|
+
this is not a spawned child (no row links spawned_cid == me)."""
|
|
464
|
+
self_cid = _detect_self_cid()
|
|
465
|
+
if not self_cid:
|
|
466
|
+
return None
|
|
467
|
+
row = conn.execute(
|
|
468
|
+
"SELECT parent_cid FROM tasks WHERE spawned_cid=? "
|
|
469
|
+
"ORDER BY started_at DESC LIMIT 1",
|
|
470
|
+
(self_cid,),
|
|
471
|
+
).fetchone()
|
|
472
|
+
if not row:
|
|
473
|
+
return None
|
|
474
|
+
return row["parent_cid"]
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@mcp.tool()
|
|
478
|
+
def search_via_parent(query: str, k: int = 5,
|
|
479
|
+
scope: str = "notes",
|
|
480
|
+
mode: str = "hybrid",
|
|
481
|
+
timeout_s: int = 30) -> str:
|
|
482
|
+
"""Delegate a semantic search to the parent process (or any peer with
|
|
483
|
+
embeddings loaded). For light children spawned with
|
|
484
|
+
THREADKEEPER_NO_EMBEDDINGS=1, this is how you reach into the shared
|
|
485
|
+
DB's semantic index without loading PyTorch yourself.
|
|
486
|
+
|
|
487
|
+
Mechanism: posts a 'search_request' signal addressed to the parent's
|
|
488
|
+
cid (auto-resolved via tasks.parent_cid; falls back to broadcast if
|
|
489
|
+
none). The parent's search_proxy daemon answers with a 'search_response'
|
|
490
|
+
signal. This tool blocks until reply or timeout_s.
|
|
491
|
+
|
|
492
|
+
`scope`: 'notes' (default) or 'dialog'.
|
|
493
|
+
`mode`: 'hybrid'|'semantic'|'fts' (dialog scope only).
|
|
494
|
+
`k`: top-N results, 1..100.
|
|
495
|
+
|
|
496
|
+
Returns formatted result lines, or 'timeout' if no parent answers."""
|
|
497
|
+
self_cid = _detect_self_cid()
|
|
498
|
+
if not self_cid:
|
|
499
|
+
return "ERR cannot_detect_self_cid"
|
|
500
|
+
conn = get_db()
|
|
501
|
+
_ensure_session(conn)
|
|
502
|
+
|
|
503
|
+
target = _resolve_parent_cid(conn)
|
|
504
|
+
if target == self_cid:
|
|
505
|
+
return "ERR self_target"
|
|
506
|
+
|
|
507
|
+
payload = _json.dumps({
|
|
508
|
+
"query": query, "k": int(k), "scope": scope, "mode": mode,
|
|
509
|
+
})
|
|
510
|
+
now_t = int(time.time())
|
|
511
|
+
cur = conn.execute(
|
|
512
|
+
"INSERT INTO signals (from_cid, to_cid, kind, content, created_at) "
|
|
513
|
+
"VALUES (?, ?, 'search_request', ?, ?)",
|
|
514
|
+
(self_cid, target, payload, now_t),
|
|
515
|
+
)
|
|
516
|
+
request_id = cur.lastrowid
|
|
517
|
+
_append_dialog_log(self_cid, target, "search_request",
|
|
518
|
+
f"q={query[:80]} k={k} scope={scope}")
|
|
519
|
+
conn.commit()
|
|
520
|
+
|
|
521
|
+
deadline = time.time() + max(1, min(int(timeout_s), 120))
|
|
522
|
+
while True:
|
|
523
|
+
resp = conn.execute(
|
|
524
|
+
"SELECT id, from_cid, content FROM signals "
|
|
525
|
+
"WHERE kind='search_response' AND to_cid=? "
|
|
526
|
+
" AND created_at >= ? "
|
|
527
|
+
"ORDER BY id ASC LIMIT 1",
|
|
528
|
+
(self_cid, now_t),
|
|
529
|
+
).fetchone()
|
|
530
|
+
if resp:
|
|
531
|
+
conn.execute(
|
|
532
|
+
"UPDATE signals SET read_at=? WHERE id=?",
|
|
533
|
+
(int(time.time()), resp["id"]),
|
|
534
|
+
)
|
|
535
|
+
conn.commit()
|
|
536
|
+
try:
|
|
537
|
+
body = _json.loads(resp["content"])
|
|
538
|
+
except _json.JSONDecodeError:
|
|
539
|
+
return f"ERR bad_response_payload from={resp['from_cid'][:8]}"
|
|
540
|
+
if body.get("error"):
|
|
541
|
+
return f"ERR remote={body['error']}"
|
|
542
|
+
results = body.get("results") or []
|
|
543
|
+
if not results:
|
|
544
|
+
return "no_matches"
|
|
545
|
+
scope_actual = body.get("scope", scope)
|
|
546
|
+
now2 = int(time.time())
|
|
547
|
+
lines = [
|
|
548
|
+
f"got={len(results)} via={resp['from_cid'][:8]} "
|
|
549
|
+
f"scope={scope_actual}"
|
|
550
|
+
]
|
|
551
|
+
for r in results:
|
|
552
|
+
snip = (r.get("content") or "")[:200].replace("\n", " ⏎ ")
|
|
553
|
+
if scope_actual == "dialog":
|
|
554
|
+
sess = (r.get("session_id") or "-")[:8]
|
|
555
|
+
age = fmt_age(now2 - int(r.get("created_at") or now2))
|
|
556
|
+
role = r.get("role", "?")
|
|
557
|
+
score = r.get("score")
|
|
558
|
+
score_part = (
|
|
559
|
+
f"s={score:.2f} " if isinstance(score, (int, float))
|
|
560
|
+
else ""
|
|
561
|
+
)
|
|
562
|
+
lines.append(f" {role}@{sess} {score_part}{age}_ago {q(snip)}")
|
|
563
|
+
else:
|
|
564
|
+
tid = (r.get("thread_id") or "-")
|
|
565
|
+
kind = r.get("kind") or "?"
|
|
566
|
+
score = r.get("score")
|
|
567
|
+
score_part = (
|
|
568
|
+
f"s={score:.2f} " if isinstance(score, (int, float))
|
|
569
|
+
else ""
|
|
570
|
+
)
|
|
571
|
+
lines.append(f" {tid} {kind} {score_part}{q(snip)}")
|
|
572
|
+
return "\n".join(lines)
|
|
573
|
+
if time.time() >= deadline:
|
|
574
|
+
return (
|
|
575
|
+
f"timeout request_id={request_id} target="
|
|
576
|
+
f"{(target or 'broadcast')[:8]} (no parent with "
|
|
577
|
+
"embeddings answered)"
|
|
578
|
+
)
|
|
579
|
+
time.sleep(0.25)
|