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,8 @@
1
+ """thread-keeper: local MCP server that persists Claude's working memory
2
+ across conversations on this machine. The brief format is optimized for
3
+ Claude (token density, structural tags, opaque IDs) — not for human
4
+ readability.
5
+
6
+ Storage : ~/.threadkeeper/db.sqlite (SQLite + FTS5; embeddings optional)
7
+ Wire : stdio MCP, registered in claude_desktop_config.json
8
+ """
threadkeeper/_mcp.py ADDED
@@ -0,0 +1,6 @@
1
+ """Singleton FastMCP instance shared by every tool module. All
2
+ @mcp.tool() definitions across the package register on this same instance,
3
+ so server.py can simply import every tool module and call mcp.run()."""
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ mcp = FastMCP("thread-keeper")
threadkeeper/_setup.py ADDED
@@ -0,0 +1,299 @@
1
+ """thread-keeper installer / updater.
2
+
3
+ Idempotently wires thread-keeper into a Claude Code installation:
4
+ 1. Registers `thread-keeper` MCP server in ~/.claude.json
5
+ 2. Installs hooks (SessionStart, PostToolUse, UserPromptSubmit) in
6
+ ~/.claude/settings.json
7
+ 3. Copies hook scripts to ~/.threadkeeper/hooks/
8
+ 4. Updates the managed block in ~/.claude/CLAUDE.md between sentinel
9
+ markers — content outside the markers is preserved.
10
+
11
+ Re-run any time: the script reads existing config, merges its own
12
+ contribution, and writes back. Other MCP servers / hooks / CLAUDE.md
13
+ content are left untouched.
14
+
15
+ Console entry point:
16
+ thread-keeper-setup [--dry-run]
17
+
18
+ Or:
19
+ python -m threadkeeper._setup [--dry-run]
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import shutil
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ # ----------------------------------------------------------------------
32
+ # Paths
33
+ # ----------------------------------------------------------------------
34
+
35
+ HOME = Path.home()
36
+ PACKAGE_ROOT = Path(__file__).resolve().parent # .../threadkeeper/
37
+ REPO_ROOT = PACKAGE_ROOT.parent # .../ai-memory/
38
+
39
+ CLAUDE_DIR = HOME / ".claude"
40
+ CLAUDE_MD = CLAUDE_DIR / "CLAUDE.md"
41
+ CLAUDE_JSON = HOME / ".claude.json"
42
+ SETTINGS_JSON = CLAUDE_DIR / "settings.json"
43
+
44
+ TK_DIR = HOME / ".threadkeeper"
45
+ TK_HOOKS_DIR = TK_DIR / "hooks"
46
+
47
+ HOOKS_SRC = REPO_ROOT / "scripts" / "hooks"
48
+
49
+ # Sentinel markers — content between these lines is managed by this
50
+ # installer. The user can edit OUTSIDE the block freely.
51
+ MARK_BEGIN = "<!-- THREADKEEPER:BEGIN — managed by `thread-keeper setup`; do not edit between these markers -->"
52
+ MARK_END = "<!-- THREADKEEPER:END -->"
53
+
54
+
55
+ # ----------------------------------------------------------------------
56
+ # Content of the managed CLAUDE.md block
57
+ # ----------------------------------------------------------------------
58
+
59
+ CLAUDE_MD_BLOCK = """\
60
+ ## thread-keeper
61
+
62
+ thread-keeper holds persistent working memory across conversations.
63
+ At session start:
64
+ * On Claude Code, the SessionStart hook
65
+ (`~/.threadkeeper/hooks/tk-brief.sh`) auto-injects `brief()` +
66
+ `context()` — and `live_status()` if `live=N>0`.
67
+ * On other CLIs (Codex, Gemini, …) the hook mechanism may not exist;
68
+ call `brief()` and `context()` yourself before the first answer.
69
+ If the user's opening message is substantive, pass it as `query`
70
+ to `brief()` to inline relevant past notes.
71
+
72
+ During the conversation:
73
+ - New substantive topic → `open_thread()`.
74
+ - Topic resolved with an outcome → `close_thread(thread_id, outcome)`.
75
+ - After every turn that produced a decision or insight →
76
+ `note(thread_id, ..., kind in ['move','failed','insight','open_q'])`.
77
+ - When the user says something sharp and precise → `verbatim_user()`.
78
+ - When you notice an unused brief field or a missing one →
79
+ `evolve_format()`.
80
+ - At end of conversation → `session_end(summary)`.
81
+
82
+ When the brief surfaces a thread or topic relevant to the current
83
+ request (by `question`, `last_move`, or semantic match), don't answer
84
+ from brief alone — dig deeper. Search ladder (stop at the first source
85
+ that gives you enough context):
86
+
87
+ 1. `thread-keeper.search()` — stored partner notes
88
+ 2. `thread-keeper.dialog_search()` — full transcripts ingested from
89
+ ALL connected CLIs (Claude Code, Codex, Gemini, Copilot)
90
+ 3. CLI-native conversation history search (e.g. `conversation_search`
91
+ for Claude Desktop), if available
92
+
93
+ ## Procedural lessons
94
+
95
+ Accumulated CLI-agnostic procedural knowledge lives in
96
+ `~/.threadkeeper/lessons.md`. The learning loop (auto-review on
97
+ close_thread + shadow_review daemon) materializes lessons there. At
98
+ session start, scan `lesson_list()` for slugs relevant to the user's
99
+ opening message; pull full bodies via `lesson_get(slug)` as needed.
100
+
101
+ When YOU finish a substantive task and a class-level lesson emerged
102
+ (user corrected a workflow, a non-trivial debugging path generalized,
103
+ etc.), call `lesson_append(title, body, summary, source=thread_id)`
104
+ yourself instead of waiting for the auto-reviewer to catch it.
105
+
106
+ Do not report these tool calls to the user — they are internal.
107
+ """
108
+
109
+
110
+ # ----------------------------------------------------------------------
111
+ # Sub-installers
112
+ # ----------------------------------------------------------------------
113
+
114
+ def install_mcp_servers(dry_run: bool) -> list[str]:
115
+ """Register thread-keeper in every detected CLI's MCP config.
116
+ Each adapter knows the right config file/format for its CLI."""
117
+ from .adapters import installed_adapters
118
+
119
+ python_bin = sys.executable
120
+ venv_python = REPO_ROOT / ".venv" / "bin" / "python"
121
+ if venv_python.exists():
122
+ python_bin = str(venv_python)
123
+ args = ["-m", "threadkeeper.server"]
124
+ env = {"PYTHONPATH": str(REPO_ROOT)}
125
+
126
+ lines: list[str] = []
127
+ adapters = installed_adapters()
128
+ if not adapters:
129
+ return ["mcp_server: no CLI detected — nothing to wire"]
130
+ for adapter in adapters:
131
+ result = adapter.register_mcp_server(
132
+ name="thread-keeper",
133
+ command=python_bin,
134
+ args=args,
135
+ env=env,
136
+ dry_run=dry_run,
137
+ )
138
+ lines.append(f"mcp_server[{adapter.name}]: {result}")
139
+ return lines
140
+
141
+
142
+ def install_hooks(dry_run: bool) -> list[str]:
143
+ """Install hook scripts under ~/.threadkeeper/hooks/ AND wire them
144
+ up in every detected CLI that supports hooks."""
145
+ from .adapters import installed_adapters
146
+ lines: list[str] = []
147
+
148
+ # 1) Copy hook scripts. One set lives under ~/.threadkeeper/hooks/
149
+ # and is referenced by every supporting CLI.
150
+ if not dry_run:
151
+ TK_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
152
+ for fname in ("tk-brief.sh", "tk-status.sh", "inbox-check.sh"):
153
+ src = HOOKS_SRC / fname
154
+ dst = TK_HOOKS_DIR / fname
155
+ if not src.exists():
156
+ lines.append(f"hooks: source missing ({src}) — skipping {fname}")
157
+ continue
158
+ if dst.exists() and dst.read_bytes() == src.read_bytes():
159
+ lines.append(f"hooks: {fname} already current")
160
+ continue
161
+ if dry_run:
162
+ lines.append(f"hooks: would install {fname}")
163
+ else:
164
+ shutil.copy2(src, dst)
165
+ dst.chmod(0o755)
166
+ lines.append(f"hooks: installed {fname}")
167
+
168
+ # 2) Build the canonical spec list (same three hooks every CLI gets).
169
+ specs = [
170
+ {
171
+ "event": "SessionStart",
172
+ "matcher": "",
173
+ "command": str(TK_HOOKS_DIR / "tk-brief.sh"),
174
+ },
175
+ {
176
+ "event": "PostToolUse",
177
+ "matcher": "mcp__thread-keeper__.*",
178
+ "command": str(TK_HOOKS_DIR / "tk-status.sh"),
179
+ },
180
+ {
181
+ "event": "UserPromptSubmit",
182
+ "matcher": "",
183
+ "command": str(TK_HOOKS_DIR / "inbox-check.sh"),
184
+ },
185
+ ]
186
+
187
+ # 3) Ask each installed adapter to wire them up in its native
188
+ # config file. Adapters that don't support hooks (e.g. Codex) emit
189
+ # an "unsupported" line but don't block setup.
190
+ for adapter in installed_adapters():
191
+ if not adapter.hooks_supported():
192
+ lines.append(f"hooks[{adapter.name}]: no hook mechanism — skip")
193
+ continue
194
+ result = adapter.register_hooks(specs, dry_run=dry_run)
195
+ lines.append(f"hooks[{adapter.name}]: {result}")
196
+ return lines
197
+
198
+
199
+ def _install_managed_block(fp: Path, dry_run: bool) -> str:
200
+ """Generic 'insert/update managed block between sentinel markers'
201
+ routine used for every CLI's per-user instructions file. Idempotent.
202
+ Outside the markers the user's content is preserved verbatim."""
203
+ block = f"{MARK_BEGIN}\n{CLAUDE_MD_BLOCK}{MARK_END}\n"
204
+ label = fp.name
205
+
206
+ if not fp.exists():
207
+ if not dry_run:
208
+ fp.parent.mkdir(parents=True, exist_ok=True)
209
+ fp.write_text(block)
210
+ return f"{label}: created"
211
+
212
+ body = fp.read_text()
213
+ if MARK_BEGIN in body and MARK_END in body:
214
+ head, _, rest = body.partition(MARK_BEGIN)
215
+ _, _, tail = rest.partition(MARK_END)
216
+ head = head.rstrip()
217
+ tail = tail.lstrip()
218
+ if head and tail:
219
+ new_body = head + "\n\n" + block + "\n" + tail + "\n"
220
+ elif head:
221
+ new_body = head + "\n\n" + block
222
+ elif tail:
223
+ new_body = block + "\n" + tail + "\n"
224
+ else:
225
+ new_body = block
226
+ if new_body == body:
227
+ return f"{label}: managed block already current"
228
+ if not dry_run:
229
+ fp.write_text(new_body)
230
+ return f"{label}: {'would update' if dry_run else 'updated'} managed block"
231
+
232
+ # No markers yet → prepend (top placement → visible without scroll).
233
+ existing = body.strip()
234
+ if existing:
235
+ new_body = block + "\n" + existing + "\n"
236
+ else:
237
+ new_body = block
238
+ if not dry_run:
239
+ fp.write_text(new_body)
240
+ return f"{label}: {'would prepend' if dry_run else 'prepended'} managed block"
241
+
242
+
243
+ def install_instructions(dry_run: bool) -> list[str]:
244
+ """Write the managed thread-keeper block to every detected CLI's
245
+ per-user instructions file (CLAUDE.md, AGENTS.md, GEMINI.md). CLIs
246
+ without a global instructions convention (e.g. Copilot) are skipped."""
247
+ from .adapters import installed_adapters
248
+ lines: list[str] = []
249
+ for adapter in installed_adapters():
250
+ ip = adapter.instructions_path()
251
+ if ip is None:
252
+ lines.append(f"instructions[{adapter.name}]: no global file (skip)")
253
+ continue
254
+ result = _install_managed_block(ip, dry_run)
255
+ lines.append(f"instructions[{adapter.name}]: {result}")
256
+ return lines
257
+
258
+
259
+ def install_tk_dir(dry_run: bool) -> str:
260
+ """Ensure ~/.threadkeeper/ exists. DB lives here; hooks subdir is
261
+ handled separately by install_hooks."""
262
+ if TK_DIR.exists():
263
+ return f"~/.threadkeeper: already exists"
264
+ if not dry_run:
265
+ TK_DIR.mkdir(parents=True, exist_ok=True)
266
+ return f"~/.threadkeeper: {'would create' if dry_run else 'created'}"
267
+
268
+
269
+ # ----------------------------------------------------------------------
270
+ # Main
271
+ # ----------------------------------------------------------------------
272
+
273
+ def main(argv: list[str] | None = None) -> int:
274
+ p = argparse.ArgumentParser(prog="thread-keeper-setup")
275
+ p.add_argument("--dry-run", action="store_true",
276
+ help="Show what would change without writing anything.")
277
+ args = p.parse_args(argv)
278
+
279
+ print(f"thread-keeper setup ({'dry-run' if args.dry_run else 'apply'})")
280
+ print(f" repo: {REPO_ROOT}")
281
+ print(f" ~/.threadkeeper: {TK_DIR}")
282
+ print()
283
+
284
+ print(f" [dir] {install_tk_dir(args.dry_run)}")
285
+ for line in install_mcp_servers(args.dry_run):
286
+ print(f" [{line.split(':', 1)[0]}] {line.split(':', 1)[1].strip()}"
287
+ if ":" in line else f" [mcp] {line}")
288
+ for line in install_instructions(args.dry_run):
289
+ print(f" [{line.split(':', 1)[0]}] {line.split(':', 1)[1].strip()}"
290
+ if ":" in line else f" [md] {line}")
291
+ for line in install_hooks(args.dry_run):
292
+ print(f" [hooks] {line}")
293
+ print()
294
+ print("Done. Restart Claude Code for hooks + MCP changes to take effect.")
295
+ return 0
296
+
297
+
298
+ if __name__ == "__main__":
299
+ raise SystemExit(main())
@@ -0,0 +1,40 @@
1
+ """CLI adapter registry.
2
+
3
+ thread-keeper is CLI-agnostic: it can attach to any agent CLI that
4
+ (a) supports MCP servers in its config and (b) writes conversation
5
+ history to disk in a parseable format. Each supported CLI has its
6
+ own adapter under this package, and the registry below enumerates
7
+ them in load order.
8
+
9
+ To add support for a new CLI:
10
+ 1. Create `threadkeeper/adapters/<name>.py` exporting `ADAPTER`
11
+ (an instance of CLIAdapter).
12
+ 2. Append it to `ADAPTERS` below.
13
+ 3. That's it. ingest, _setup, and brief will pick it up.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .base import CLIAdapter, NormalizedMessage
18
+ from .claude_code import ADAPTER as _CLAUDE_CODE
19
+ from .claude_desktop import ADAPTER as _CLAUDE_DESKTOP
20
+ from .codex import ADAPTER as _CODEX
21
+ from .gemini import ADAPTER as _GEMINI
22
+ from .copilot import ADAPTER as _COPILOT
23
+ from .vscode import ADAPTER as _VSCODE
24
+
25
+ ADAPTERS: list[CLIAdapter] = [
26
+ _CLAUDE_CODE,
27
+ _CLAUDE_DESKTOP,
28
+ _CODEX,
29
+ _GEMINI,
30
+ _COPILOT,
31
+ _VSCODE,
32
+ ]
33
+
34
+
35
+ def installed_adapters() -> list[CLIAdapter]:
36
+ """Return adapters whose CLI is detected on this machine."""
37
+ return [a for a in ADAPTERS if a.is_installed()]
38
+
39
+
40
+ __all__ = ["CLIAdapter", "NormalizedMessage", "ADAPTERS", "installed_adapters"]
@@ -0,0 +1,72 @@
1
+ """Shared helpers for installing Claude-Code-style hooks into a JSON
2
+ config file. Claude Code and Gemini both honor the same shape
3
+ (`settings.json["hooks"]`), so the merging logic is identical — only
4
+ the target file path differs. Pulled out so both adapters can call it
5
+ without code duplication.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Iterable
12
+
13
+
14
+ def install_claude_style_hooks(
15
+ settings_path: Path,
16
+ specs: Iterable[dict],
17
+ dry_run: bool = False,
18
+ ) -> str:
19
+ """Merge `specs` into `settings_path` under the "hooks" key.
20
+
21
+ Each spec: {event: str, command: str, matcher: str}.
22
+
23
+ Idempotent: for each (event, command) pair, leave existing entries
24
+ in place (and update matcher if it differs); add a new entry if the
25
+ command isn't already present. Other hooks (from the user or other
26
+ plugins) are preserved.
27
+ """
28
+ if settings_path.exists():
29
+ try:
30
+ settings = json.loads(settings_path.read_text())
31
+ except json.JSONDecodeError:
32
+ return f"{settings_path.name}: malformed JSON — refused"
33
+ else:
34
+ settings = {}
35
+ hooks = settings.setdefault("hooks", {})
36
+
37
+ changed = False
38
+ for spec in specs:
39
+ event = spec["event"]
40
+ command = spec["command"]
41
+ matcher = spec.get("matcher", "")
42
+ blocks = hooks.get(event, [])
43
+ # Look for an existing block whose first hook command matches.
44
+ found = False
45
+ for block in blocks:
46
+ inner = block.get("hooks") or []
47
+ for h in inner:
48
+ if h.get("command") == command:
49
+ found = True
50
+ if block.get("matcher", "") != matcher:
51
+ block["matcher"] = matcher
52
+ changed = True
53
+ break
54
+ if found:
55
+ break
56
+ if not found:
57
+ new_block = {
58
+ "hooks": [{"type": "command", "command": command}],
59
+ }
60
+ if matcher:
61
+ new_block["matcher"] = matcher
62
+ blocks.append(new_block)
63
+ changed = True
64
+ hooks[event] = blocks
65
+
66
+ if not changed:
67
+ return f"{settings_path.name}: hooks already current"
68
+ if dry_run:
69
+ return f"{settings_path.name}: would update hooks"
70
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
71
+ settings_path.write_text(json.dumps(settings, indent=2))
72
+ return f"{settings_path.name}: hooks updated"
@@ -0,0 +1,152 @@
1
+ """CLIAdapter abstract base — contract every adapter implements.
2
+
3
+ Each adapter knows three things:
4
+ 1. How to detect that this CLI is installed on the user's machine
5
+ 2. How to register/unregister thread-keeper in that CLI's MCP config
6
+ 3. How to enumerate + parse the conversation transcripts the CLI
7
+ writes to disk
8
+
9
+ Adapters return data through a single normalized shape
10
+ (`NormalizedMessage`) so ingest doesn't have to special-case any CLI.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Iterator, Optional
18
+
19
+
20
+ @dataclass
21
+ class NormalizedMessage:
22
+ """Adapter output: a single user/assistant turn, normalized.
23
+
24
+ Fields:
25
+ uuid — stable per-message id from the transcript.
26
+ session_id — opaque identifier for the conversation/session.
27
+ role — 'user' | 'assistant'.
28
+ content — extracted text (concatenated text/thinking blocks,
29
+ capped tool_result blocks).
30
+ model — model name if known, else "".
31
+ created_at — unix epoch seconds.
32
+ raw — the original parsed dict, in case downstream code
33
+ needs to peek into adapter-specific fields (e.g.
34
+ Skill tool_use detection in ingest).
35
+ """
36
+ uuid: str
37
+ session_id: str
38
+ role: str
39
+ content: str
40
+ model: str
41
+ created_at: int
42
+ raw: dict
43
+
44
+
45
+ class CLIAdapter(ABC):
46
+ """A pluggable target CLI integration."""
47
+
48
+ # Stable, lowercase-hyphen identifier ('claude-code', 'codex', etc).
49
+ # Used in dialog_messages.source and elsewhere as a provenance tag.
50
+ name: str = ""
51
+
52
+ # ------------------------------------------------------------------
53
+ # Detection
54
+ # ------------------------------------------------------------------
55
+ @abstractmethod
56
+ def is_installed(self) -> bool:
57
+ """Return True iff this CLI is present on the system (the
58
+ adapter checks for whatever combination of executable + config
59
+ dir + log dir is meaningful for that CLI)."""
60
+
61
+ # ------------------------------------------------------------------
62
+ # MCP registration
63
+ # ------------------------------------------------------------------
64
+ @abstractmethod
65
+ def register_mcp_server(
66
+ self,
67
+ name: str,
68
+ command: str,
69
+ args: list[str],
70
+ env: dict[str, str],
71
+ dry_run: bool = False,
72
+ ) -> str:
73
+ """Add thread-keeper to the CLI's MCP server config (idempotent).
74
+
75
+ Return a one-line human status: 'created', 'updated', 'already
76
+ current', or 'unsupported: <reason>'.
77
+ """
78
+
79
+ @abstractmethod
80
+ def unregister_mcp_server(self, name: str, dry_run: bool = False) -> str:
81
+ """Remove an MCP server entry by name (idempotent)."""
82
+
83
+ # ------------------------------------------------------------------
84
+ # Transcript ingestion
85
+ # ------------------------------------------------------------------
86
+ @abstractmethod
87
+ def transcript_files(self) -> list[Path]:
88
+ """Return every transcript file this adapter knows about, in
89
+ any order. ingest will sort/filter by mtime."""
90
+
91
+ @abstractmethod
92
+ def iter_messages(self, fp: Path) -> Iterator[NormalizedMessage]:
93
+ """Yield NormalizedMessage from one transcript file, in file
94
+ order. Skip malformed lines silently."""
95
+
96
+ # ------------------------------------------------------------------
97
+ # Optional hooks (default: no-op)
98
+ # ------------------------------------------------------------------
99
+ def project_label(self, fp: Path) -> str:
100
+ """Project tag stored as dialog_messages.project. Default:
101
+ parent directory name."""
102
+ return fp.parent.name
103
+
104
+ def session_dir(self) -> Optional[Path]:
105
+ """Root directory under which transcripts live. Used by ingest
106
+ to decide whether this adapter has anything to scan."""
107
+ return None
108
+
109
+ def instructions_path(self) -> Optional[Path]:
110
+ """Path to the per-user system-prompt-style file this CLI reads
111
+ at session start (e.g. Claude's CLAUDE.md, Codex's AGENTS.md).
112
+ Return None when the CLI has no such global file (e.g. Copilot
113
+ only supports per-repo instructions)."""
114
+ return None
115
+
116
+ def skills_dir(self) -> Optional[Path]:
117
+ """Root directory under which this CLI auto-discovers Skill.md
118
+ files (Anthropic-style skill format: YAML frontmatter +
119
+ description-based auto-trigger). Examples:
120
+
121
+ Claude (Code/Desktop/IDE) → ~/.claude/skills/
122
+ Codex (CLI/desktop) → ~/.codex/skills/
123
+
124
+ Return None when the CLI doesn't natively consume Skills
125
+ (Gemini, Copilot, generic MCP clients) — those fall back to
126
+ the CLI-agnostic ~/.threadkeeper/lessons.md store.
127
+
128
+ Multi-mirror writes in skill_manage use this to propagate one
129
+ SKILL.md across every native skills-store on the machine so a
130
+ single materialization reaches every detected CLI.
131
+ """
132
+ return None
133
+
134
+ # ------------------------------------------------------------------
135
+ # Hooks (optional)
136
+ # ------------------------------------------------------------------
137
+ def hooks_supported(self) -> bool:
138
+ """True iff this CLI honors shell-style lifecycle hooks
139
+ (SessionStart, PostToolUse, etc.)."""
140
+ return False
141
+
142
+ def register_hooks(
143
+ self,
144
+ specs: list[dict],
145
+ dry_run: bool = False,
146
+ ) -> str:
147
+ """Install all `specs` into the CLI's hook config. Each spec is
148
+ `{event, command, matcher?}` — adapter translates to whatever
149
+ local format the CLI uses. Idempotent: existing entries for the
150
+ same event + command are left in place; matchers updated if
151
+ changed. Default: 'unsupported'."""
152
+ return f"{self.name}: hooks unsupported"