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,856 @@
1
+ """Claude-skills lifecycle: create/edit/patch/delete + usage telemetry +
2
+ curator + background-review fork.
3
+
4
+ Bridges thread-keeper (working memory across sessions) and Claude's
5
+ ~/.claude/skills/ store (procedural memory). The Learning loop:
6
+
7
+ rich thread closes → brief() surfaces skill_hint nudge →
8
+ agent calls review_thread(...) → spawned child reads notes,
9
+ writes/patches a skill via skill_manage(), calls
10
+ mark_skill_materialized() → nudge clears
11
+
12
+ Telemetry sidecar lives in DB table skill_usage (created in db.py). Curator
13
+ archives stale agent-created skills; foreground/user-authored ones are
14
+ never auto-touched.
15
+
16
+ Validator enforces the agentskills.io-compatible frontmatter shape:
17
+ - starts with '---' at byte 0
18
+ - closes with '\\n---\\n' before body
19
+ - name field present, ≤64 chars, matching ^[a-z0-9][a-z0-9._-]*$
20
+ - description field present, ≤1024 chars
21
+ - total SKILL.md ≤100_000 chars
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ import shutil
29
+ import sqlite3
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ from .._mcp import mcp
35
+ from ..config import CLAUDE_SKILLS_DIR, WRITE_ORIGIN
36
+ from ..db import get_db
37
+ from ..helpers import q
38
+ from .. import identity
39
+ from ..identity import _ensure_session, _detect_self_cid, _emit
40
+ from ..review_prompts import (
41
+ MEMORY_REVIEW_PROMPT, SKILL_REVIEW_PROMPT, COMBINED_REVIEW_PROMPT,
42
+ )
43
+
44
+
45
+ # ──────────────────────────────────────────────────────────────────────────
46
+ # Validator
47
+ # ──────────────────────────────────────────────────────────────────────────
48
+
49
+ MAX_NAME_LENGTH = 64
50
+ MAX_DESCRIPTION_LENGTH = 1024
51
+ MAX_SKILL_CONTENT_CHARS = 100_000
52
+ MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB
53
+
54
+ _VALID_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
55
+ _FRONTMATTER_FIELD_RE = re.compile(
56
+ r"^(name|description):\s*(.+?)\s*$", re.MULTILINE
57
+ )
58
+
59
+ ALLOWED_SUBDIRS = {"references", "templates", "scripts", "assets"}
60
+
61
+
62
+ def _validate_name(name: str) -> Optional[str]:
63
+ if not name:
64
+ return "name is required"
65
+ if len(name) > MAX_NAME_LENGTH:
66
+ return f"name exceeds {MAX_NAME_LENGTH} chars"
67
+ if not _VALID_NAME_RE.match(name):
68
+ return (
69
+ f"invalid name '{name}' — use lowercase letters, numbers, "
70
+ f"hyphens, dots, underscores; must start with letter or digit"
71
+ )
72
+ return None
73
+
74
+
75
+ def _parse_frontmatter(body: str) -> tuple[Optional[dict], Optional[str]]:
76
+ """Extract name and description from leading --- ... --- block.
77
+
78
+ Returns (fields, error). Minimal — handles single-line scalars only.
79
+ A SKILL.md with multi-line YAML values won't parse here; for our use
80
+ case (name + description) that's fine.
81
+ """
82
+ if not body.startswith("---"):
83
+ return None, "frontmatter must start with '---' at byte 0"
84
+ m = re.search(r"\n---\s*\n", body[3:])
85
+ if not m:
86
+ return None, "frontmatter missing closing '\\n---\\n'"
87
+ front = body[3:m.start() + 3]
88
+ fields: dict[str, str] = {}
89
+ for fm in _FRONTMATTER_FIELD_RE.finditer(front):
90
+ fields[fm.group(1)] = fm.group(2).strip('"').strip("'")
91
+ if "name" not in fields:
92
+ return None, "frontmatter missing 'name' field"
93
+ if "description" not in fields:
94
+ return None, "frontmatter missing 'description' field"
95
+ # Body after closing must be non-empty.
96
+ rest = body[3 + m.end():].strip()
97
+ if not rest:
98
+ return None, "skill body empty after frontmatter"
99
+ return fields, None
100
+
101
+
102
+ def _validate_skill_md(content: str, expected_name: str) -> Optional[str]:
103
+ if len(content) > MAX_SKILL_CONTENT_CHARS:
104
+ return (
105
+ f"SKILL.md exceeds {MAX_SKILL_CONTENT_CHARS} chars "
106
+ f"(have {len(content)})"
107
+ )
108
+ fields, err = _parse_frontmatter(content)
109
+ if err:
110
+ return err
111
+ assert fields is not None # for type checker
112
+ name = fields["name"]
113
+ if name != expected_name:
114
+ return (
115
+ f"frontmatter name '{name}' does not match directory name "
116
+ f"'{expected_name}'"
117
+ )
118
+ if err := _validate_name(name):
119
+ return err
120
+ desc = fields["description"]
121
+ if len(desc) > MAX_DESCRIPTION_LENGTH:
122
+ return (
123
+ f"description exceeds {MAX_DESCRIPTION_LENGTH} chars "
124
+ f"(have {len(desc)})"
125
+ )
126
+ return None
127
+
128
+
129
+ # ──────────────────────────────────────────────────────────────────────────
130
+ # Path helpers
131
+ # ──────────────────────────────────────────────────────────────────────────
132
+
133
+ def _skill_dir(name: str) -> Path:
134
+ return CLAUDE_SKILLS_DIR / name
135
+
136
+
137
+ def _skill_md_path(name: str) -> Path:
138
+ return _skill_dir(name) / "SKILL.md"
139
+
140
+
141
+ def _archive_dir() -> Path:
142
+ return CLAUDE_SKILLS_DIR / ".archive"
143
+
144
+
145
+ # ──────────────────────────────────────────────────────────────────────────
146
+ # Multi-mirror — propagate SKILL.md across every detected CLI's skills/
147
+ # directory so a single skill_manage call reaches Claude AND Codex AND
148
+ # the canonical ~/.threadkeeper/skills/ fallback at once. Best-effort:
149
+ # per-mirror failures are logged but don't fail the canonical write.
150
+ # ──────────────────────────────────────────────────────────────────────────
151
+
152
+ def _mirror_targets(name: str) -> list[Path]:
153
+ """Per-CLI skill directories that should hold a copy of <name>.
154
+
155
+ Always includes the canonical ~/.threadkeeper/skills/<name>/ fallback
156
+ (consumed by lessons.md-style scans from CLIs without a native
157
+ skills/ loader). Plus every installed adapter whose skills_dir() is
158
+ distinct from CLAUDE_SKILLS_DIR (the canonical write target)."""
159
+ from ..adapters import installed_adapters
160
+ from ..config import DB_PATH
161
+
162
+ targets: list[Path] = []
163
+ seen: set[Path] = {CLAUDE_SKILLS_DIR.resolve()}
164
+ # CLI-native skills/ dirs from each installed adapter.
165
+ for a in installed_adapters():
166
+ sd = a.skills_dir()
167
+ if sd is None:
168
+ continue
169
+ sd_r = sd.resolve() if sd.exists() else sd
170
+ if sd_r in seen:
171
+ continue
172
+ seen.add(sd_r)
173
+ targets.append(sd / name)
174
+ # Canonical thread-keeper-side mirror (always written, used as the
175
+ # source of truth for restore / curator audit / non-skills CLIs).
176
+ tk_canonical = (DB_PATH.parent / "skills").resolve()
177
+ if tk_canonical not in seen:
178
+ targets.append((DB_PATH.parent / "skills") / name)
179
+ return targets
180
+
181
+
182
+ def _mirror_skill_dir(name: str) -> None:
183
+ """Copy CLAUDE_SKILLS_DIR/<name>/ → every mirror target (recursive).
184
+ Pre-existing mirror is replaced atomically. Best-effort per target."""
185
+ import shutil as _sh
186
+ src = _skill_dir(name)
187
+ if not src.exists():
188
+ return
189
+ for dst in _mirror_targets(name):
190
+ try:
191
+ dst.parent.mkdir(parents=True, exist_ok=True)
192
+ if dst.exists():
193
+ _sh.rmtree(dst)
194
+ _sh.copytree(src, dst)
195
+ except Exception:
196
+ # Per-mirror failure (permission denied / disk full / etc.)
197
+ # doesn't fail the canonical write. The user can re-sync
198
+ # later via re-running skill_manage.
199
+ pass
200
+
201
+
202
+ def _unmirror_skill(name: str) -> None:
203
+ """Remove <name>/ from every mirror target. Best-effort per target."""
204
+ import shutil as _sh
205
+ for dst in _mirror_targets(name):
206
+ try:
207
+ if dst.exists():
208
+ _sh.rmtree(dst)
209
+ except Exception:
210
+ pass
211
+
212
+
213
+ # ──────────────────────────────────────────────────────────────────────────
214
+ # Telemetry
215
+ # ──────────────────────────────────────────────────────────────────────────
216
+
217
+ def _record_event(name: str, kind: str) -> None:
218
+ """Bump skill_usage counters/timestamps. Inserts a row if missing.
219
+
220
+ `kind`: 'create' | 'use' | 'view' | 'patch'. Unknown kinds are ignored.
221
+ """
222
+ conn = get_db()
223
+ now = int(time.time())
224
+ cid = _detect_self_cid()
225
+ if kind == "create":
226
+ conn.execute(
227
+ "INSERT INTO skill_usage (name, created_at, created_by_cid, "
228
+ "created_by_origin, state) VALUES (?,?,?,?, 'active') "
229
+ "ON CONFLICT(name) DO NOTHING",
230
+ (name, now, cid, WRITE_ORIGIN),
231
+ )
232
+ conn.commit()
233
+ return
234
+ # ensure row exists for upserts that aren't 'create'
235
+ conn.execute(
236
+ "INSERT INTO skill_usage (name, created_at, created_by_cid, "
237
+ "created_by_origin, state) VALUES (?,?,?,?,'active') "
238
+ "ON CONFLICT(name) DO NOTHING",
239
+ (name, now, cid, WRITE_ORIGIN),
240
+ )
241
+ if kind == "view":
242
+ conn.execute(
243
+ "UPDATE skill_usage SET last_viewed_at=?, view_count=view_count+1, "
244
+ "state=CASE WHEN state='stale' THEN 'active' ELSE state END "
245
+ "WHERE name=?",
246
+ (now, name),
247
+ )
248
+ elif kind == "use":
249
+ conn.execute(
250
+ "UPDATE skill_usage SET last_used_at=?, use_count=use_count+1, "
251
+ "state=CASE WHEN state='stale' THEN 'active' ELSE state END "
252
+ "WHERE name=?",
253
+ (now, name),
254
+ )
255
+ elif kind == "patch":
256
+ conn.execute(
257
+ "UPDATE skill_usage SET last_patched_at=?, "
258
+ "patch_count=patch_count+1, "
259
+ "state=CASE WHEN state='stale' THEN 'active' ELSE state END "
260
+ "WHERE name=?",
261
+ (now, name),
262
+ )
263
+ conn.commit()
264
+
265
+
266
+ _VALID_OUTCOMES: set[str] = {"helped", "partial", "wrong"}
267
+
268
+
269
+ @mcp.tool()
270
+ def skill_record(name: str, kind: str = "use", outcome: str = "") -> str:
271
+ """Record usage telemetry for a Claude skill.
272
+
273
+ `kind`: 'use' | 'view' | 'patch' | 'create'. Bumps the corresponding
274
+ counter + timestamp in skill_usage. The curator reads these to decide
275
+ what to archive.
276
+
277
+ `outcome` (optional, meaningful with kind='use'): 'helped' | 'partial'
278
+ | 'wrong'. When set, also emits an `events.kind='skill_outcome'` row
279
+ so the curator can identify skills that fire often but consistently
280
+ give 'wrong' verdicts — those are false-positive candidates to PRUNE.
281
+
282
+ The 'wrong' outcome is the primary signal an agent has to say "I
283
+ consulted this skill, it didn't actually apply / was misleading;
284
+ please patch or delete next curator pass." Don't be shy about
285
+ marking 'wrong' — better a curated library than a polluted one.
286
+ """
287
+ name = name.strip()
288
+ if err := _validate_name(name):
289
+ return f"ERR {err}"
290
+ if kind not in {"use", "view", "patch", "create"}:
291
+ return f"ERR invalid_kind={kind} (use|view|patch|create)"
292
+ outcome = outcome.strip().lower()
293
+ if outcome and outcome not in _VALID_OUTCOMES:
294
+ return f"ERR invalid_outcome={outcome} ({'|'.join(sorted(_VALID_OUTCOMES))})"
295
+ _record_event(name, kind)
296
+ # Emit a per-invocation event row so the brief's consulted_skills
297
+ # section (and any later analytics) can see per-session activity,
298
+ # not just cumulative counters. _action_create / _action_patch /
299
+ # _action_edit already emit their own skill_* events; skill_record
300
+ # only adds events for kinds those don't cover (view + use).
301
+ if kind in {"view", "use"}:
302
+ conn = get_db()
303
+ _ensure_session(conn)
304
+ _emit(conn, f"skill_{kind}", target=name)
305
+ conn.commit()
306
+ if outcome:
307
+ conn = get_db()
308
+ _ensure_session(conn)
309
+ _emit(conn, "skill_outcome", target=name, summary=outcome)
310
+ conn.commit()
311
+ return "ok"
312
+
313
+
314
+ # ──────────────────────────────────────────────────────────────────────────
315
+ # skill_manage
316
+ # ──────────────────────────────────────────────────────────────────────────
317
+
318
+ @mcp.tool()
319
+ def skill_manage(action: str,
320
+ name: str = "",
321
+ content: str = "",
322
+ old_string: str = "",
323
+ new_string: str = "",
324
+ sub_path: str = "",
325
+ description: str = "") -> str:
326
+ """Create, edit, patch, or delete Claude skills under ~/.claude/skills/.
327
+
328
+ Atomic write with frontmatter validation before disk hits.
329
+
330
+ Actions:
331
+ create — write a brand-new skill. Requires `name` + `description`
332
+ + `content` (the body markdown WITHOUT frontmatter; the
333
+ tool prepends a valid frontmatter block).
334
+ Pass full `content` starting with '---' to skip the
335
+ auto-frontmatter and supply your own.
336
+ edit — overwrite SKILL.md wholesale. Requires `name` + `content`
337
+ (full file including frontmatter).
338
+ patch — find/replace within SKILL.md. Requires `name`,
339
+ `old_string`, `new_string`. Result revalidated.
340
+ write_file — add a support file. Requires `name`, `sub_path` (must
341
+ start with references/, templates/, scripts/, or assets/),
342
+ `content`.
343
+ remove_file — remove a support file under one of the allowed subdirs.
344
+ Requires `name`, `sub_path`.
345
+ delete — remove a skill entirely. Pinned skills (in skill_usage)
346
+ are refused.
347
+ """
348
+ action = action.strip()
349
+ name = name.strip()
350
+ if action != "delete" and (err := _validate_name(name)):
351
+ return f"ERR {err}"
352
+ if action == "create":
353
+ return _action_create(name, content, description)
354
+ if action == "edit":
355
+ return _action_edit(name, content)
356
+ if action == "patch":
357
+ return _action_patch(name, old_string, new_string)
358
+ if action == "write_file":
359
+ return _action_write_file(name, sub_path, content)
360
+ if action == "remove_file":
361
+ return _action_remove_file(name, sub_path)
362
+ if action == "delete":
363
+ if err := _validate_name(name):
364
+ return f"ERR {err}"
365
+ return _action_delete(name)
366
+ return (
367
+ f"ERR unknown_action={action} "
368
+ "(create|edit|patch|write_file|remove_file|delete)"
369
+ )
370
+
371
+
372
+ def _action_create(name: str, content: str, description: str) -> str:
373
+ sdir = _skill_dir(name)
374
+ md = _skill_md_path(name)
375
+ if md.exists():
376
+ return f"ERR skill_exists={name}"
377
+ if content and content.startswith("---"):
378
+ body = content
379
+ else:
380
+ if not description.strip():
381
+ return "ERR description_required when content lacks frontmatter"
382
+ if not content.strip():
383
+ return "ERR content_required (body markdown after frontmatter)"
384
+ body = (
385
+ f"---\n"
386
+ f"name: {name}\n"
387
+ f"description: {description.strip()}\n"
388
+ f"---\n\n"
389
+ f"{content.strip()}\n"
390
+ )
391
+ if err := _validate_skill_md(body, name):
392
+ return f"ERR validate_failed: {err}"
393
+ sdir.mkdir(parents=True, exist_ok=True)
394
+ md.write_text(body, encoding="utf-8")
395
+ _record_event(name, "create")
396
+ conn = get_db()
397
+ _ensure_session(conn)
398
+ _emit(conn, "skill_create", target=name, summary=str(md))
399
+ conn.commit()
400
+ _mirror_skill_dir(name)
401
+ return f"ok path={md}"
402
+
403
+
404
+ def _action_edit(name: str, content: str) -> str:
405
+ md = _skill_md_path(name)
406
+ if not md.exists():
407
+ return f"ERR skill_not_found={name}"
408
+ if err := _validate_skill_md(content, name):
409
+ return f"ERR validate_failed: {err}"
410
+ md.write_text(content, encoding="utf-8")
411
+ _record_event(name, "patch")
412
+ conn = get_db()
413
+ _ensure_session(conn)
414
+ _emit(conn, "skill_edit", target=name, summary=str(md))
415
+ conn.commit()
416
+ _mirror_skill_dir(name)
417
+ return f"ok path={md}"
418
+
419
+
420
+ def _action_patch(name: str, old_string: str, new_string: str) -> str:
421
+ md = _skill_md_path(name)
422
+ if not md.exists():
423
+ return f"ERR skill_not_found={name}"
424
+ if not old_string:
425
+ return "ERR old_string_required"
426
+ cur = md.read_text(encoding="utf-8")
427
+ if old_string not in cur:
428
+ return "ERR old_string_not_found"
429
+ if cur.count(old_string) > 1:
430
+ return (
431
+ f"ERR old_string_ambiguous (appears "
432
+ f"{cur.count(old_string)}× — make it unique)"
433
+ )
434
+ updated = cur.replace(old_string, new_string, 1)
435
+ if err := _validate_skill_md(updated, name):
436
+ return f"ERR validate_failed_after_patch: {err}"
437
+ md.write_text(updated, encoding="utf-8")
438
+ _record_event(name, "patch")
439
+ conn = get_db()
440
+ _ensure_session(conn)
441
+ _emit(conn, "skill_patch", target=name)
442
+ conn.commit()
443
+ _mirror_skill_dir(name)
444
+ return "ok"
445
+
446
+
447
+ def _action_write_file(name: str, sub_path: str, content: str) -> str:
448
+ sdir = _skill_dir(name)
449
+ if not sdir.exists():
450
+ return f"ERR skill_not_found={name}"
451
+ sub_path = sub_path.strip().lstrip("/")
452
+ if "/" not in sub_path:
453
+ return (
454
+ "ERR sub_path_must_be_under_allowed_subdir "
455
+ f"({', '.join(sorted(ALLOWED_SUBDIRS))}/)"
456
+ )
457
+ top, _ = sub_path.split("/", 1)
458
+ if top not in ALLOWED_SUBDIRS:
459
+ return (
460
+ f"ERR subdir_not_allowed={top} "
461
+ f"(allowed: {', '.join(sorted(ALLOWED_SUBDIRS))})"
462
+ )
463
+ if ".." in sub_path.split("/"):
464
+ return "ERR path_traversal_blocked"
465
+ if len(content.encode("utf-8")) > MAX_SKILL_FILE_BYTES:
466
+ return f"ERR file_exceeds_{MAX_SKILL_FILE_BYTES}_bytes"
467
+ target = sdir / sub_path
468
+ target.parent.mkdir(parents=True, exist_ok=True)
469
+ target.write_text(content, encoding="utf-8")
470
+ _record_event(name, "patch")
471
+ conn = get_db()
472
+ _ensure_session(conn)
473
+ _emit(conn, "skill_write_file", target=name, summary=sub_path)
474
+ conn.commit()
475
+ _mirror_skill_dir(name)
476
+ return f"ok path={target}"
477
+
478
+
479
+ def _action_remove_file(name: str, sub_path: str) -> str:
480
+ sdir = _skill_dir(name)
481
+ if not sdir.exists():
482
+ return f"ERR skill_not_found={name}"
483
+ sub_path = sub_path.strip().lstrip("/")
484
+ if "/" not in sub_path:
485
+ return "ERR sub_path_must_be_under_allowed_subdir"
486
+ top, _ = sub_path.split("/", 1)
487
+ if top not in ALLOWED_SUBDIRS:
488
+ return f"ERR subdir_not_allowed={top}"
489
+ if ".." in sub_path.split("/"):
490
+ return "ERR path_traversal_blocked"
491
+ target = sdir / sub_path
492
+ if not target.exists():
493
+ return f"ERR file_not_found={sub_path}"
494
+ target.unlink()
495
+ _record_event(name, "patch")
496
+ _mirror_skill_dir(name)
497
+ return "ok"
498
+
499
+
500
+ def _action_delete(name: str) -> str:
501
+ conn = get_db()
502
+ _ensure_session(conn)
503
+ row = conn.execute(
504
+ "SELECT pinned FROM skill_usage WHERE name=?", (name,)
505
+ ).fetchone()
506
+ if row and row["pinned"]:
507
+ return (
508
+ f"ERR pinned={name} (unpin via UPDATE skill_usage SET pinned=0 "
509
+ "first)"
510
+ )
511
+ sdir = _skill_dir(name)
512
+ if not sdir.exists():
513
+ return f"ERR skill_not_found={name}"
514
+ shutil.rmtree(sdir)
515
+ _unmirror_skill(name)
516
+ conn.execute("DELETE FROM skill_usage WHERE name=?", (name,))
517
+ _emit(conn, "skill_delete", target=name)
518
+ conn.commit()
519
+ return "ok"
520
+
521
+
522
+ # ──────────────────────────────────────────────────────────────────────────
523
+ # skill_list
524
+ # ──────────────────────────────────────────────────────────────────────────
525
+
526
+ @mcp.tool()
527
+ def skill_list(include_archived: bool = False) -> str:
528
+ """List Claude skills with telemetry. Format:
529
+ <name> origin=<foreground|background_review> state=<active|stale|archived>
530
+ uses=N views=N patches=N pinned=0/1 last_active=<age>
531
+ """
532
+ conn = get_db()
533
+ _ensure_session(conn)
534
+ if include_archived:
535
+ rows = conn.execute(
536
+ "SELECT * FROM skill_usage ORDER BY name"
537
+ ).fetchall()
538
+ else:
539
+ rows = conn.execute(
540
+ "SELECT * FROM skill_usage WHERE state != 'archived' "
541
+ "ORDER BY state, name"
542
+ ).fetchall()
543
+ if not rows:
544
+ return "no_skills_tracked"
545
+ now = int(time.time())
546
+ out: list[str] = []
547
+ for r in rows:
548
+ last = max(
549
+ r["last_used_at"] or 0,
550
+ r["last_viewed_at"] or 0,
551
+ r["last_patched_at"] or 0,
552
+ r["created_at"],
553
+ )
554
+ age_s = now - last
555
+ if age_s < 3600:
556
+ age = f"{age_s // 60}m"
557
+ elif age_s < 86400:
558
+ age = f"{age_s // 3600}h"
559
+ else:
560
+ age = f"{age_s // 86400}d"
561
+ out.append(
562
+ f"{r['name']} origin={r['created_by_origin']} "
563
+ f"state={r['state']} uses={r['use_count']} "
564
+ f"views={r['view_count']} patches={r['patch_count']} "
565
+ f"pinned={r['pinned']} last_active={age}_ago"
566
+ )
567
+ return "\n".join(out)
568
+
569
+
570
+ # ──────────────────────────────────────────────────────────────────────────
571
+ # Active-update bias — feed the review fork a list of recently-touched
572
+ # skills so it can PATCH existing umbrellas instead of creating new ones
573
+ # that overlap. Hermes Agent v0.12 calls this "active-update bias".
574
+ # ──────────────────────────────────────────────────────────────────────────
575
+
576
+ def _fmt_age(now: int, ts: int) -> str:
577
+ """Compact relative timestamp ("3h", "2d", "30m")."""
578
+ if not ts:
579
+ return "?"
580
+ delta = max(0, now - ts)
581
+ if delta < 60:
582
+ return f"{delta}s"
583
+ if delta < 3600:
584
+ return f"{delta // 60}m"
585
+ if delta < 86400:
586
+ return f"{delta // 3600}h"
587
+ return f"{delta // 86400}d"
588
+
589
+
590
+ def _recent_active_skills_dump(
591
+ conn: sqlite3.Connection,
592
+ limit: int = 10,
593
+ window_days: int = 14,
594
+ ) -> str:
595
+ """Return a markdown-ish block listing recently-touched skills, or
596
+ empty string when the window is empty.
597
+
598
+ "Touched" = use_count, view_count, or patch_count incremented within
599
+ the window. Sorted by most-recent activity desc.
600
+ """
601
+ now = int(time.time())
602
+ cutoff = now - window_days * 86400
603
+ try:
604
+ rows = conn.execute(
605
+ "SELECT name, use_count, view_count, patch_count, pinned, "
606
+ " MAX("
607
+ " COALESCE(last_used_at, 0), "
608
+ " COALESCE(last_viewed_at, 0), "
609
+ " COALESCE(last_patched_at, 0)"
610
+ " ) AS last_active "
611
+ "FROM skill_usage "
612
+ "WHERE state='active' "
613
+ " AND last_active > ? "
614
+ "ORDER BY last_active DESC "
615
+ "LIMIT ?",
616
+ (cutoff, limit),
617
+ ).fetchall()
618
+ except sqlite3.OperationalError:
619
+ return ""
620
+ if not rows:
621
+ return ""
622
+ lines = [
623
+ "RECENTLY ACTIVE SKILLS (prefer PATCH/extend over CREATE — see Q4):",
624
+ ]
625
+ for r in rows:
626
+ pin = " pinned" if r["pinned"] else ""
627
+ lines.append(
628
+ f" - {r['name']} "
629
+ f"(uses={r['use_count']} views={r['view_count']} "
630
+ f"patches={r['patch_count']}{pin}, "
631
+ f"last_active={_fmt_age(now, r['last_active'])}_ago)"
632
+ )
633
+ return "\n".join(lines) + "\n\n"
634
+
635
+
636
+ # ──────────────────────────────────────────────────────────────────────────
637
+ # Curator
638
+ # ──────────────────────────────────────────────────────────────────────────
639
+
640
+ @mcp.tool()
641
+ def curator_run(stale_after_days: int = 30,
642
+ archive_after_days: int = 90,
643
+ dry_run: bool = True) -> str:
644
+ """Move stale agent-created skills to archive.
645
+
646
+ Lifecycle:
647
+ active → stale when last activity > stale_after_days
648
+ stale → archived when last activity > archive_after_days
649
+ (also moves directory to ~/.claude/skills/.archive/)
650
+
651
+ NEVER touches:
652
+ • foreground (user-authored) skills — provenance check
653
+ • pinned skills — opt-out flag
654
+ • skills with no `created_by_origin` set (unknown provenance — be safe)
655
+
656
+ `dry_run=True` (default) reports what would change without writing.
657
+ Set False to apply."""
658
+ conn = get_db()
659
+ _ensure_session(conn)
660
+ now = int(time.time())
661
+ stale_thresh = now - stale_after_days * 86400
662
+ archive_thresh = now - archive_after_days * 86400
663
+
664
+ rows = conn.execute(
665
+ "SELECT * FROM skill_usage "
666
+ "WHERE created_by_origin='background_review' AND pinned=0"
667
+ ).fetchall()
668
+
669
+ plan: list[dict] = []
670
+ for r in rows:
671
+ last_activity = max(
672
+ r["last_used_at"] or 0,
673
+ r["last_viewed_at"] or 0,
674
+ r["last_patched_at"] or 0,
675
+ r["created_at"],
676
+ )
677
+ cur_state = r["state"]
678
+ if cur_state == "active" and last_activity < stale_thresh:
679
+ plan.append({
680
+ "name": r["name"], "from": "active", "to": "stale",
681
+ "last": last_activity,
682
+ })
683
+ elif cur_state == "stale" and last_activity < archive_thresh:
684
+ plan.append({
685
+ "name": r["name"], "from": "stale", "to": "archived",
686
+ "last": last_activity,
687
+ })
688
+
689
+ if not plan:
690
+ return "nothing_to_do"
691
+
692
+ lines = [f"plan n={len(plan)} dry_run={dry_run}"]
693
+ for p in plan:
694
+ age = (now - p["last"]) // 86400
695
+ lines.append(f" {p['name']}: {p['from']} → {p['to']} (last {age}d ago)")
696
+
697
+ if dry_run:
698
+ return "\n".join(lines)
699
+
700
+ # Apply.
701
+ arch = _archive_dir()
702
+ arch.mkdir(parents=True, exist_ok=True)
703
+ for p in plan:
704
+ conn.execute(
705
+ "UPDATE skill_usage SET state=? WHERE name=?",
706
+ (p["to"], p["name"]),
707
+ )
708
+ if p["to"] == "archived":
709
+ src = _skill_dir(p["name"])
710
+ if src.exists():
711
+ dst = arch / p["name"]
712
+ if dst.exists():
713
+ # collide — keep both with timestamp suffix
714
+ dst = arch / f"{p['name']}_{now}"
715
+ shutil.move(str(src), str(dst))
716
+ _emit(conn, "curator_transition", target=p["name"],
717
+ summary=f"{p['from']}→{p['to']}")
718
+ conn.commit()
719
+ return "\n".join(lines)
720
+
721
+
722
+ # ──────────────────────────────────────────────────────────────────────────
723
+ # review_thread — auto background fork
724
+ # ──────────────────────────────────────────────────────────────────────────
725
+
726
+ def _thread_notes_dump(conn, thread_id: str) -> str:
727
+ """Build a compact text dump of all notes + outcome for a thread."""
728
+ t = conn.execute(
729
+ "SELECT question, outcome, state FROM threads WHERE id=?",
730
+ (thread_id,),
731
+ ).fetchone()
732
+ if not t:
733
+ return ""
734
+ notes = conn.execute(
735
+ "SELECT kind, content, created_at FROM notes "
736
+ "WHERE thread_id=? ORDER BY created_at",
737
+ (thread_id,),
738
+ ).fetchall()
739
+ lines = [
740
+ f"Thread question: {t['question']}",
741
+ f"Thread state: {t['state']}",
742
+ f"Thread outcome: {t['outcome'] or '(none yet)'}",
743
+ f"Notes ({len(notes)}):",
744
+ ]
745
+ for n in notes:
746
+ snip = n["content"][:400].replace("\n", " ")
747
+ lines.append(f" [{n['kind']}] {snip}")
748
+ return "\n".join(lines)
749
+
750
+
751
+ @mcp.tool()
752
+ def review_thread(thread_id: str,
753
+ focus: str = "combined",
754
+ mode: str = "auto") -> str:
755
+ """Spawn a background review of a closed thread to extract memory/skills.
756
+
757
+ Equivalent of hermes-agent's _spawn_background_review: a separate Claude
758
+ process reads the thread's notes and writes back via memory/skill tools.
759
+
760
+ `focus`: 'memory' | 'skills' | 'combined' (default). Picks the review
761
+ prompt.
762
+ `mode`:
763
+ 'auto' — spawn an invisible background child with the review
764
+ prompt + thread notes. Returns the spawn task_id. Child's
765
+ write-origin is set to 'background_review' so curator
766
+ can later prune what it produces.
767
+ 'inline' — return the full prompt + notes context as a string; the
768
+ foreground agent processes it in the current turn.
769
+ """
770
+ thread_id = thread_id.strip()
771
+ conn = get_db()
772
+ _ensure_session(conn)
773
+ if not conn.execute(
774
+ "SELECT 1 FROM threads WHERE id=?", (thread_id,)
775
+ ).fetchone():
776
+ return f"ERR thread_not_found={thread_id}"
777
+
778
+ focus = focus.strip().lower()
779
+ if focus == "memory":
780
+ base_prompt = MEMORY_REVIEW_PROMPT
781
+ elif focus in {"skill", "skills"}:
782
+ base_prompt = SKILL_REVIEW_PROMPT
783
+ elif focus in {"combined", "both", ""}:
784
+ base_prompt = COMBINED_REVIEW_PROMPT
785
+ else:
786
+ return f"ERR invalid_focus={focus} (memory|skills|combined)"
787
+
788
+ notes_dump = _thread_notes_dump(conn, thread_id)
789
+ # Active-update bias (Hermes Agent v0.12 pattern): inject the list
790
+ # of skills the parent has touched recently so the fork prefers
791
+ # PATCHing an existing skill over creating a new one — see Q4 in
792
+ # the rubric. Falls back to empty when the library is fresh.
793
+ recent_skills_dump = _recent_active_skills_dump(conn)
794
+ full_prompt = (
795
+ f"You are reviewing closed thread {thread_id}.\n\n"
796
+ f"{notes_dump}\n\n"
797
+ f"{recent_skills_dump}"
798
+ f"---\n\n"
799
+ f"{base_prompt}\n\n"
800
+ f"When you write any skill, finish with "
801
+ f"mark_skill_materialized(thread_id='{thread_id}', skill_path=...) "
802
+ f"so the brief's skill_hint clears."
803
+ )
804
+
805
+ if mode == "inline":
806
+ return full_prompt
807
+
808
+ if mode != "auto":
809
+ return f"ERR invalid_mode={mode} (auto|inline)"
810
+
811
+ # Spawn an invisible background fork. Reuse spawn() — runtime import
812
+ # avoids a circular import on package load. slim=True loads ONLY
813
+ # thread-keeper MCP for the child (no context7/figma/etc) — review
814
+ # work doesn't need any of those, and it cuts startup RAM dramatically.
815
+ from .spawn import spawn # type: ignore
816
+ result = spawn(
817
+ prompt=full_prompt,
818
+ visible=False,
819
+ capture_output=True,
820
+ permission_mode="auto",
821
+ role="archivist",
822
+ write_origin="background_review",
823
+ slim=True,
824
+ extra_allowed_tools=(
825
+ "mcp__thread-keeper__lesson_append,"
826
+ "mcp__thread-keeper__lesson_list,"
827
+ "mcp__thread-keeper__skill_manage,"
828
+ "mcp__thread-keeper__skill_record,"
829
+ "mcp__thread-keeper__mark_skill_materialized,"
830
+ "mcp__thread-keeper__skill_list,"
831
+ "Edit,Read,Write"
832
+ ),
833
+ )
834
+ # The spawned child IS an application of the ai-memory-learning-loop
835
+ # skill (review prompt = that skill's procedure baked in). The child
836
+ # won't invoke Skill(...) explicitly, so bump the counter here so
837
+ # `uses` reflects how many times the loop actually fired in the wild.
838
+ now = int(time.time())
839
+ try:
840
+ conn.execute(
841
+ "INSERT INTO skill_usage "
842
+ "(name, created_at, created_by_origin) "
843
+ "VALUES (?, ?, 'background_review') "
844
+ "ON CONFLICT(name) DO NOTHING",
845
+ ("ai-memory-learning-loop", now),
846
+ )
847
+ conn.execute(
848
+ "UPDATE skill_usage "
849
+ "SET last_used_at=?, use_count=use_count+1 "
850
+ "WHERE name=?",
851
+ (now, "ai-memory-learning-loop"),
852
+ )
853
+ conn.commit()
854
+ except sqlite3.OperationalError:
855
+ pass # skill_usage missing on this conn
856
+ return result