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,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)