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
threadkeeper/__init__.py
ADDED
|
@@ -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"
|