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
|
@@ -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
|