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,195 @@
1
+ """GitHub Copilot CLI adapter.
2
+
3
+ Config: ~/.copilot/mcp-config.json — JSON with a top-level `mcpServers`
4
+ section (same key as Claude / Gemini, contrary to older bundles that
5
+ shipped an unused `servers` key).
6
+
7
+ Instructions: Copilot doesn't have a stable single "global" file. Its
8
+ conventionPaths table (from the CLI bundle):
9
+
10
+ AGENTS.md in cwd and walked-up parents
11
+ CLAUDE.md in cwd, parents, and `.claude/` subdir
12
+ GEMINI.md in cwd and parents
13
+ copilot-instructions.md in `.github/` subdir, walked up
14
+
15
+ Walking up from any project under `~`, Copilot reaches `~/.claude/CLAUDE.md`
16
+ (which our setup manages) and picks it up automatically. We also write
17
+ the canonically-named `~/.copilot/copilot-instructions.md` as a stable
18
+ location user-level symlinks/projects can point at.
19
+
20
+ Transcripts: ~/.copilot/session-store.db — a sqlite database. Schema:
21
+ sessions(id, cwd, repository, host_type, branch, summary, created_at, updated_at)
22
+ turns(id, session_id, turn_index, user_message, assistant_response, timestamp)
23
+
24
+ Each row in `turns` corresponds to ONE user/assistant exchange. We
25
+ split it into two NormalizedMessage records during ingest so the rest
26
+ of the pipeline (FTS, semantic, skill scan) doesn't have to know
27
+ about the merged-turn shape.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import shutil
33
+ import sqlite3
34
+ from datetime import datetime
35
+ from pathlib import Path
36
+ from typing import Iterator
37
+
38
+ from .base import CLIAdapter, NormalizedMessage
39
+
40
+
41
+ def _ts(s: str) -> int:
42
+ try:
43
+ return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
44
+ except Exception:
45
+ try:
46
+ # Copilot stores timestamps via sqlite's datetime('now')
47
+ # which yields 'YYYY-MM-DD HH:MM:SS' (no T, no tz).
48
+ return int(datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp())
49
+ except Exception:
50
+ import time
51
+ return int(time.time())
52
+
53
+
54
+ class CopilotAdapter(CLIAdapter):
55
+ name = "copilot"
56
+
57
+ def __init__(self) -> None:
58
+ self.config_path = Path("~/.copilot/mcp-config.json").expanduser()
59
+ self.session_db = Path("~/.copilot/session-store.db").expanduser()
60
+ self._instructions = Path("~/.copilot/copilot-instructions.md").expanduser()
61
+ # Copilot loads hooks from `~/.copilot/hooks.json` (or
62
+ # `~/.copilot/hooks/hooks.json`). The bundle's schema accepts
63
+ # the same Claude-Code-compatible shape (`_vsCodeCompat` mode),
64
+ # so we reuse the helper but write to a dedicated file rather
65
+ # than into mcp-config.json.
66
+ self._hooks_path = Path("~/.copilot/hooks.json").expanduser()
67
+
68
+ def instructions_path(self):
69
+ return self._instructions
70
+
71
+ def hooks_supported(self) -> bool:
72
+ return True
73
+
74
+ def register_hooks(self, specs, dry_run=False) -> str:
75
+ from ._hook_helpers import install_claude_style_hooks
76
+ return install_claude_style_hooks(
77
+ self._hooks_path, specs, dry_run=dry_run,
78
+ )
79
+
80
+ def is_installed(self) -> bool:
81
+ if self.config_path.exists() or self.session_db.exists():
82
+ return True
83
+ return (
84
+ shutil.which("copilot") is not None
85
+ or shutil.which("gh") is not None
86
+ )
87
+
88
+ # ----- MCP registration ---------------------------------------------
89
+ def register_mcp_server(
90
+ self, name, command, args, env, dry_run=False
91
+ ) -> str:
92
+ cfg: dict
93
+ if self.config_path.exists():
94
+ try:
95
+ cfg = json.loads(self.config_path.read_text())
96
+ except json.JSONDecodeError:
97
+ return "copilot: malformed mcp-config.json — refused"
98
+ else:
99
+ cfg = {}
100
+ # Copilot v1.0.43+ schema validates the top-level `mcpServers`
101
+ # key (same as Claude/Gemini). Older bundles shipped with
102
+ # `servers` documented; the validator now rejects that file.
103
+ # If we see a legacy `servers` block, migrate its contents into
104
+ # `mcpServers` AND drop the legacy key so the file is valid.
105
+ legacy = cfg.pop("servers", None)
106
+ if isinstance(legacy, dict):
107
+ cfg.setdefault("mcpServers", {})
108
+ for k, v in legacy.items():
109
+ cfg["mcpServers"].setdefault(k, v)
110
+ servers = cfg.setdefault("mcpServers", {})
111
+ entry = {
112
+ "command": command,
113
+ "args": list(args),
114
+ }
115
+ if env:
116
+ entry["env"] = dict(env)
117
+ existing = servers.get(name)
118
+ if existing == entry and legacy is None:
119
+ return "copilot: already current"
120
+ servers[name] = entry
121
+ if not dry_run:
122
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
123
+ self.config_path.write_text(json.dumps(cfg, indent=2))
124
+ if legacy is not None:
125
+ return f"copilot: migrated legacy 'servers' → 'mcpServers' + {'added' if not existing else 'updated'} {name}"
126
+ return f"copilot: {'would ' if dry_run else ''}{'update' if existing else 'add'}"
127
+
128
+ def unregister_mcp_server(self, name, dry_run=False) -> str:
129
+ if not self.config_path.exists():
130
+ return "copilot: nothing to remove"
131
+ cfg = json.loads(self.config_path.read_text())
132
+ servers = (cfg.get("mcpServers") or cfg.get("servers") or {})
133
+ if name not in servers:
134
+ return "copilot: not present"
135
+ if dry_run:
136
+ return f"copilot: would remove {name}"
137
+ servers.pop(name)
138
+ self.config_path.write_text(json.dumps(cfg, indent=2))
139
+ return f"copilot: removed {name}"
140
+
141
+ # ----- Transcript ingestion -----------------------------------------
142
+ # Copilot stores transcripts in a single sqlite DB rather than per-
143
+ # session jsonl files. We treat the DB itself as the single
144
+ # "transcript file" — ingest's mtime-based incremental scheme works
145
+ # because Copilot updates the file as new turns land.
146
+ def session_dir(self):
147
+ return self.session_db.parent
148
+
149
+ def transcript_files(self) -> list[Path]:
150
+ if self.session_db.exists():
151
+ return [self.session_db]
152
+ return []
153
+
154
+ def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
155
+ if not fp.exists():
156
+ return
157
+ try:
158
+ conn = sqlite3.connect(f"file:{fp}?mode=ro&immutable=0", uri=True)
159
+ except sqlite3.OperationalError:
160
+ return
161
+ try:
162
+ rows = conn.execute(
163
+ "SELECT session_id, turn_index, user_message, "
164
+ "assistant_response, timestamp FROM turns "
165
+ "ORDER BY session_id, turn_index"
166
+ ).fetchall()
167
+ except sqlite3.OperationalError:
168
+ conn.close()
169
+ return
170
+ conn.close()
171
+ for sess_id, turn_idx, user_msg, asst_msg, ts in rows:
172
+ created = _ts(ts or "")
173
+ if user_msg:
174
+ yield NormalizedMessage(
175
+ uuid=f"copilot:{sess_id}:{turn_idx}:u",
176
+ session_id=sess_id,
177
+ role="user",
178
+ content=user_msg,
179
+ model="",
180
+ created_at=created,
181
+ raw={"turn_index": turn_idx},
182
+ )
183
+ if asst_msg:
184
+ yield NormalizedMessage(
185
+ uuid=f"copilot:{sess_id}:{turn_idx}:a",
186
+ session_id=sess_id,
187
+ role="assistant",
188
+ content=asst_msg,
189
+ model="",
190
+ created_at=created,
191
+ raw={"turn_index": turn_idx},
192
+ )
193
+
194
+
195
+ ADAPTER = CopilotAdapter()
@@ -0,0 +1,169 @@
1
+ """Google Gemini CLI adapter.
2
+
3
+ Config: ~/.gemini/settings.json with an `mcpServers` section (same
4
+ shape as Claude Code's, JSON).
5
+
6
+ Transcripts: ~/.gemini/tmp/<user>/chats/session-<ts>-<id>.jsonl
7
+ Each line is a JSON object. First line is session-meta
8
+ ({"sessionId", "projectHash", "startTime", ...}). Subsequent lines:
9
+ {"id": <msg-uuid>, "timestamp": ..., "type": "info"|"user"|"model"|...,
10
+ "content": <string or structured>}
11
+ We pick `type in {"user", "model"}` as turns; map "model" → "assistant".
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import getpass
16
+ import json
17
+ import shutil
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Iterator
21
+
22
+ from .base import CLIAdapter, NormalizedMessage
23
+
24
+
25
+ def _ts(s: str) -> int:
26
+ try:
27
+ return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
28
+ except Exception:
29
+ import time
30
+ return int(time.time())
31
+
32
+
33
+ def _coerce_text(content) -> str:
34
+ """Gemini content may be: a plain string, a list of segments, or a
35
+ dict with text/parts. Coerce to a single string."""
36
+ if isinstance(content, str):
37
+ return content
38
+ if isinstance(content, dict):
39
+ if isinstance(content.get("text"), str):
40
+ return content["text"]
41
+ if isinstance(content.get("parts"), list):
42
+ return "\n".join(_coerce_text(p) for p in content["parts"])
43
+ if isinstance(content, list):
44
+ return "\n".join(_coerce_text(x) for x in content if x is not None)
45
+ return ""
46
+
47
+
48
+ class GeminiAdapter(CLIAdapter):
49
+ name = "gemini"
50
+
51
+ def __init__(self) -> None:
52
+ self.config_path = Path("~/.gemini/settings.json").expanduser()
53
+ self._instructions = Path("~/.gemini/GEMINI.md").expanduser()
54
+ # The "chats" tmp tree is per-OS-user; resolve at instance time.
55
+ self.chats_root = Path(
56
+ f"~/.gemini/tmp/{getpass.getuser()}/chats"
57
+ ).expanduser()
58
+
59
+ def instructions_path(self):
60
+ return self._instructions
61
+
62
+ def hooks_supported(self) -> bool:
63
+ return True
64
+
65
+ def register_hooks(self, specs, dry_run=False) -> str:
66
+ # Gemini reads `settings.hooks` in the same shape as Claude
67
+ # Code (the bundle even ships `gemini hooks migrate --from-claude`),
68
+ # so the Claude-style helper Just Works for the same file.
69
+ from ._hook_helpers import install_claude_style_hooks
70
+ return install_claude_style_hooks(
71
+ self.config_path, specs, dry_run=dry_run,
72
+ )
73
+
74
+ def is_installed(self) -> bool:
75
+ if self.config_path.exists() or self.chats_root.parent.exists():
76
+ return True
77
+ return shutil.which("gemini") is not None
78
+
79
+ # ----- MCP registration ---------------------------------------------
80
+ def register_mcp_server(
81
+ self, name, command, args, env, dry_run=False
82
+ ) -> str:
83
+ cfg: dict
84
+ if self.config_path.exists():
85
+ try:
86
+ cfg = json.loads(self.config_path.read_text())
87
+ except json.JSONDecodeError:
88
+ return "gemini: malformed settings.json — refused"
89
+ else:
90
+ cfg = {}
91
+ servers = cfg.setdefault("mcpServers", {})
92
+ entry = {
93
+ "command": command,
94
+ "args": list(args),
95
+ }
96
+ if env:
97
+ entry["env"] = dict(env)
98
+ existing = servers.get(name)
99
+ if existing == entry:
100
+ return "gemini: already current"
101
+ servers[name] = entry
102
+ if not dry_run:
103
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
104
+ self.config_path.write_text(json.dumps(cfg, indent=2))
105
+ return f"gemini: {'would ' if dry_run else ''}{'update' if existing else 'add'}"
106
+
107
+ def unregister_mcp_server(self, name, dry_run=False) -> str:
108
+ if not self.config_path.exists():
109
+ return "gemini: nothing to remove"
110
+ cfg = json.loads(self.config_path.read_text())
111
+ servers = (cfg.get("mcpServers") or {})
112
+ if name not in servers:
113
+ return "gemini: not present"
114
+ if dry_run:
115
+ return f"gemini: would remove {name}"
116
+ servers.pop(name)
117
+ self.config_path.write_text(json.dumps(cfg, indent=2))
118
+ return f"gemini: removed {name}"
119
+
120
+ # ----- Transcript ingestion -----------------------------------------
121
+ def session_dir(self):
122
+ return self.chats_root
123
+
124
+ def transcript_files(self) -> list[Path]:
125
+ if not self.chats_root.exists():
126
+ return []
127
+ return list(self.chats_root.glob("session-*.jsonl"))
128
+
129
+ def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
130
+ sess_id = ""
131
+ try:
132
+ with fp.open("r", encoding="utf-8", errors="replace") as f:
133
+ for line in f:
134
+ line = line.strip()
135
+ if not line:
136
+ continue
137
+ try:
138
+ obj = json.loads(line)
139
+ except json.JSONDecodeError:
140
+ continue
141
+ # session-meta line: first record carries sessionId.
142
+ if "sessionId" in obj and "projectHash" in obj:
143
+ sess_id = obj.get("sessionId") or ""
144
+ continue
145
+ typ = obj.get("type")
146
+ if typ == "model":
147
+ role = "assistant"
148
+ elif typ == "user":
149
+ role = "user"
150
+ else:
151
+ continue
152
+ uuid = obj.get("id")
153
+ if not uuid:
154
+ continue
155
+ text = _coerce_text(obj.get("content", ""))
156
+ yield NormalizedMessage(
157
+ uuid=uuid,
158
+ session_id=sess_id,
159
+ role=role,
160
+ content=text,
161
+ model=obj.get("model") or "",
162
+ created_at=_ts(obj.get("timestamp", "")),
163
+ raw=obj,
164
+ )
165
+ except OSError:
166
+ return
167
+
168
+
169
+ ADAPTER = GeminiAdapter()
@@ -0,0 +1,144 @@
1
+ """VS Code adapter — registers thread-keeper in VS Code's user-level
2
+ MCP host (Code 1.93+).
3
+
4
+ Modern VS Code ships built-in MCP host support, consumed by every
5
+ MCP-aware extension running in the same window: GitHub Copilot Chat,
6
+ the Claude IDE extension, the OpenAI Codex extension, Continue, Cline,
7
+ and others. All of them read the same user-level file:
8
+
9
+ macOS: ~/Library/Application Support/Code/User/mcp.json
10
+ Linux: ~/.config/Code/User/mcp.json
11
+ Windows: %APPDATA%/Code/User/mcp.json
12
+
13
+ Schema (note `servers`, NOT `mcpServers` — VS Code chose its own key):
14
+
15
+ {
16
+ "inputs": [ … prompt-string inputs for secrets … ],
17
+ "servers": {
18
+ "<name>": {
19
+ "type": "stdio" | "http" | "sse",
20
+ "command": "...",
21
+ "args": [...],
22
+ "env": {...}
23
+ }
24
+ }
25
+ }
26
+
27
+ Hooks: VS Code has no SessionStart-style shell hook the way Claude Code
28
+ or Gemini does. Instructions file: per-workspace `.github/instructions/*`
29
+ or `copilot-instructions.md`, not a single global file we can write. So
30
+ this adapter is MCP-registration-only — no hooks, no instructions, no
31
+ transcript ingest.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import shutil
38
+ import sys
39
+ from pathlib import Path
40
+ from typing import Iterator
41
+
42
+ from .base import CLIAdapter, NormalizedMessage
43
+
44
+
45
+ def _default_config_path() -> Path:
46
+ """Per-OS default location for VS Code's user-level mcp.json.
47
+
48
+ Overridable via VSCODE_MCP_JSON env var (used by tests)."""
49
+ env = os.environ.get("VSCODE_MCP_JSON")
50
+ if env:
51
+ return Path(env).expanduser()
52
+ if sys.platform == "darwin":
53
+ return Path(
54
+ "~/Library/Application Support/Code/User/mcp.json"
55
+ ).expanduser()
56
+ if sys.platform == "win32":
57
+ appdata = os.environ.get("APPDATA") or "~/AppData/Roaming"
58
+ return Path(appdata).expanduser() / "Code" / "User" / "mcp.json"
59
+ # linux / freebsd / etc.
60
+ return Path("~/.config/Code/User/mcp.json").expanduser()
61
+
62
+
63
+ def _vscode_user_dir() -> Path:
64
+ """The User/ directory itself — used to probe install presence even
65
+ when mcp.json hasn't been created yet."""
66
+ return _default_config_path().parent
67
+
68
+
69
+ class VSCodeAdapter(CLIAdapter):
70
+ name = "vscode"
71
+
72
+ def __init__(self) -> None:
73
+ self.config_path = _default_config_path()
74
+
75
+ # ----------------------------- detection -----------------------------
76
+ def is_installed(self) -> bool:
77
+ # mcp.json already exists OR VS Code is on disk in some form
78
+ # (User/ profile dir, .app bundle, `code` on PATH).
79
+ if self.config_path.exists() or _vscode_user_dir().exists():
80
+ return True
81
+ if sys.platform == "darwin":
82
+ if Path("/Applications/Visual Studio Code.app").exists():
83
+ return True
84
+ return shutil.which("code") is not None
85
+
86
+ # ----------------------------- mcp -----------------------------------
87
+ def register_mcp_server(
88
+ self, name, command, args, env, dry_run=False
89
+ ) -> str:
90
+ cfg: dict
91
+ if self.config_path.exists():
92
+ try:
93
+ cfg = json.loads(self.config_path.read_text())
94
+ except json.JSONDecodeError:
95
+ return "vscode: malformed mcp.json — refused"
96
+ else:
97
+ cfg = {}
98
+ servers = cfg.setdefault("servers", {})
99
+ entry: dict = {
100
+ "type": "stdio",
101
+ "command": command,
102
+ "args": list(args),
103
+ }
104
+ if env:
105
+ entry["env"] = dict(env)
106
+ existing = servers.get(name)
107
+ if existing == entry:
108
+ return "vscode: already current"
109
+ servers[name] = entry
110
+ if not dry_run:
111
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
112
+ self.config_path.write_text(json.dumps(cfg, indent=2))
113
+ return f"vscode: {'would ' if dry_run else ''}{'update' if existing else 'add'}"
114
+
115
+ def unregister_mcp_server(self, name, dry_run=False) -> str:
116
+ if not self.config_path.exists():
117
+ return "vscode: nothing to remove"
118
+ try:
119
+ cfg = json.loads(self.config_path.read_text())
120
+ except json.JSONDecodeError:
121
+ return "vscode: malformed mcp.json — refused"
122
+ servers = (cfg.get("servers") or {})
123
+ if name not in servers:
124
+ return "vscode: not present"
125
+ if dry_run:
126
+ return f"vscode: would remove {name}"
127
+ servers.pop(name)
128
+ self.config_path.write_text(json.dumps(cfg, indent=2))
129
+ return f"vscode: removed {name}"
130
+
131
+ # ----------------------------- transcripts ---------------------------
132
+ # VS Code itself doesn't keep a unified MCP transcript on disk —
133
+ # individual extensions handle chat history their own way (Copilot
134
+ # via GitHub services; Claude IDE via ~/.claude/projects; etc.).
135
+ # The Claude-IDE side is already covered by the claude_code adapter,
136
+ # so there's nothing for the vscode adapter to ingest on top.
137
+ def transcript_files(self) -> list[Path]:
138
+ return []
139
+
140
+ def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
141
+ return iter(())
142
+
143
+
144
+ ADAPTER = VSCodeAdapter()