self-evolve-framework 1.3.0 → 1.4.0

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 (57) hide show
  1. package/package.json +1 -1
  2. package/template/skills/ponytail/SKILL.md +16 -0
  3. package/template/skills/ponytail/scripts/hooks/claude-codex-hooks.json +44 -0
  4. package/template/skills/ponytail/scripts/hooks/copilot-hooks.json +21 -0
  5. package/template/skills/ponytail/scripts/hooks/ponytail-activate.js +91 -0
  6. package/template/skills/ponytail/scripts/hooks/ponytail-config.js +122 -0
  7. package/template/skills/ponytail/scripts/hooks/ponytail-instructions.js +94 -0
  8. package/template/skills/ponytail/scripts/hooks/ponytail-mode-tracker.js +55 -0
  9. package/template/skills/ponytail/scripts/hooks/ponytail-runtime.js +68 -0
  10. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.ps1 +21 -0
  11. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.sh +12 -0
  12. package/template/skills/ponytail/scripts/hooks/ponytail-subagent.js +22 -0
  13. package/template/skills/ponytail/scripts/mcp/README.md +46 -0
  14. package/template/skills/ponytail/scripts/mcp/index.js +48 -0
  15. package/template/skills/ponytail/scripts/mcp/instructions.js +26 -0
  16. package/template/skills/ponytail/scripts/mcp/package.json +13 -0
  17. package/template/skills/ponytail/scripts/mcp/test/instructions.test.js +22 -0
  18. package/template/skills/skillopt-sleep/SKILL.md +48 -34
  19. package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
  20. package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
  21. package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
  22. package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
  23. package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
  24. package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
  25. package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
  26. package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
  27. package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
  28. package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
  29. package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
  30. package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
  31. package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
  32. package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
  33. package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
  34. package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
  35. package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
  36. package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
  37. package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
  38. package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
  39. package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
  40. package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
  41. package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
  42. package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
  43. package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
  44. package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
  45. package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
  46. package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
  47. package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
  48. package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
  49. package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
  50. package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
  51. package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
  52. package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
  53. package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
  54. package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
  55. package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
  56. package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
  57. package/template/skills/skillopt-sleep/scripts/shell/train.py +556 -0
@@ -0,0 +1,253 @@
1
+ """SkillOpt-Sleep Codex Desktop session harvesting.
2
+
3
+ Reads Codex Desktop archived session JSONL files and normalizes them into
4
+ ``SessionDigest`` records without copying developer/system instructions, tool
5
+ arguments, or raw tool outputs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from typing import Any, Dict, Iterable, List, Optional
12
+
13
+ from skillopt_sleep.harvest import (
14
+ _detect_feedback,
15
+ _is_meta_prompt,
16
+ _iter_jsonl,
17
+ _project_matches,
18
+ )
19
+ from skillopt_sleep.types import SessionDigest
20
+
21
+ _SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
22
+ (re.compile(r"sk-[A-Za-z0-9_-]{10,}"), "[REDACTED_OPENAI_KEY]"),
23
+ (re.compile(r"(?i)(Authorization:\s*Bearer\s+)[^\s\"']+"), r"\1[REDACTED]"),
24
+ (re.compile(r"(?i)(Authorization:\s*Basic\s+)[^\s\"']+"), r"\1[REDACTED]"),
25
+ (
26
+ re.compile(r"(?i)\b(api[_-]?key|token|password|secret)\b(\s*[:=]\s*)[^\s\"']+"),
27
+ r"\1\2[REDACTED]",
28
+ ),
29
+ (
30
+ re.compile(r"(?i)\b(api[_-]?key|token|password|secret)\b(\s+)[^\s\"']+"),
31
+ r"\1\2[REDACTED]",
32
+ ),
33
+ (
34
+ re.compile(
35
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
36
+ re.DOTALL,
37
+ ),
38
+ "[REDACTED_PRIVATE_KEY]",
39
+ ),
40
+ )
41
+
42
+
43
+ def _payload(rec: Dict[str, Any]) -> Dict[str, Any]:
44
+ payload = rec.get("payload")
45
+ return payload if isinstance(payload, dict) else {}
46
+
47
+
48
+ def _timestamp(rec: Dict[str, Any], payload: Dict[str, Any]) -> str:
49
+ for value in (
50
+ payload.get("timestamp"),
51
+ rec.get("timestamp"),
52
+ payload.get("started_at"),
53
+ payload.get("completed_at"),
54
+ ):
55
+ if isinstance(value, str) and value:
56
+ return value
57
+ return ""
58
+
59
+
60
+ def _text_from_any(content: Any) -> str:
61
+ if isinstance(content, str):
62
+ return content
63
+ if isinstance(content, list):
64
+ parts: List[str] = []
65
+ for item in content:
66
+ if isinstance(item, str):
67
+ parts.append(item)
68
+ elif isinstance(item, dict):
69
+ if item.get("type") == "text" and item.get("text"):
70
+ parts.append(str(item["text"]))
71
+ elif item.get("text"):
72
+ parts.append(str(item["text"]))
73
+ return "\n".join(parts)
74
+ if isinstance(content, dict):
75
+ if content.get("text"):
76
+ return str(content["text"])
77
+ if content.get("content"):
78
+ return _text_from_any(content["content"])
79
+ return ""
80
+
81
+
82
+ def _strip_codex_meta(text: str) -> str:
83
+ stripped = text.strip()
84
+ if not stripped:
85
+ return ""
86
+ if stripped.startswith("<codex_internal_context"):
87
+ return ""
88
+ if stripped.startswith("<environment_context"):
89
+ return ""
90
+ if stripped.startswith("# AGENTS.md instructions") or "--- project-doc ---" in stripped:
91
+ for marker in ("</environment_context>", "</INSTRUCTIONS>"):
92
+ idx = stripped.rfind(marker)
93
+ if idx == -1:
94
+ continue
95
+ tail = stripped[idx + len(marker):].strip()
96
+ if tail and not tail.startswith("<"):
97
+ return tail
98
+ return ""
99
+ return stripped
100
+
101
+
102
+ def _sanitize_text(text: str) -> str:
103
+ sanitized = _strip_codex_meta(text).replace("\x00", "").strip()
104
+ if not sanitized or _is_meta_prompt(sanitized):
105
+ return ""
106
+ for pattern, replacement in _SECRET_PATTERNS:
107
+ sanitized = pattern.sub(replacement, sanitized)
108
+ return sanitized
109
+
110
+
111
+ def _sanitize_tool_name(name: str) -> str:
112
+ return re.sub(r"[^A-Za-z0-9_.:-]+", "_", name)[:80]
113
+
114
+
115
+ def _tool_name(payload: Dict[str, Any]) -> str:
116
+ payload_type = payload.get("type")
117
+ name = payload.get("name")
118
+ if isinstance(name, str) and name:
119
+ return _sanitize_tool_name(name)
120
+ if payload_type == "exec_command_end":
121
+ return "exec_command"
122
+ if payload_type == "patch_apply_end":
123
+ return "apply_patch"
124
+ if payload_type == "web_search_call":
125
+ return "web_search"
126
+ if payload_type == "tool_search_call":
127
+ return "tool_search"
128
+ if isinstance(payload_type, str) and payload_type.endswith("_tool_call"):
129
+ return _sanitize_tool_name(payload_type)
130
+ return ""
131
+
132
+
133
+ def _dedup(xs: Iterable[str]) -> List[str]:
134
+ seen = set()
135
+ out: List[str] = []
136
+ for x in xs:
137
+ if x not in seen:
138
+ seen.add(x)
139
+ out.append(x)
140
+ return out
141
+
142
+
143
+ def digest_codex_archived_session(path: str, project: str = "") -> Optional[SessionDigest]:
144
+ """Build a ``SessionDigest`` from one Codex Desktop archived session."""
145
+ session_id = os.path.splitext(os.path.basename(path))[0]
146
+ started = ""
147
+ ended = ""
148
+ session_project = ""
149
+ user_prompts: List[str] = []
150
+ assistant_finals: List[str] = []
151
+ tools: List[str] = []
152
+ feedback: List[str] = []
153
+ n_user = 0
154
+ n_asst = 0
155
+
156
+ for rec in _iter_jsonl(path):
157
+ payload = _payload(rec)
158
+ payload_type = payload.get("type")
159
+ ts = _timestamp(rec, payload)
160
+ if ts:
161
+ if not started:
162
+ started = ts
163
+ ended = ts
164
+ cwd = payload.get("cwd")
165
+ if isinstance(cwd, str) and cwd:
166
+ if not session_project:
167
+ session_project = cwd
168
+ if project and _project_matches(cwd, "invoked", project):
169
+ session_project = cwd
170
+
171
+ role = payload.get("role")
172
+ text = ""
173
+ output_role = ""
174
+ if payload_type == "user_message":
175
+ text = _text_from_any(payload.get("message"))
176
+ output_role = "user"
177
+ elif payload_type == "agent_message":
178
+ text = _text_from_any(payload.get("message"))
179
+ output_role = "assistant"
180
+ elif payload_type == "message" and role in {"user", "assistant"}:
181
+ text = _text_from_any(payload.get("content"))
182
+ output_role = str(role)
183
+ else:
184
+ tool = _tool_name(payload)
185
+ if tool:
186
+ tools.append(tool)
187
+ continue
188
+
189
+ sanitized = _sanitize_text(text)
190
+ if not sanitized:
191
+ continue
192
+ if output_role == "user":
193
+ n_user += 1
194
+ user_prompts.append(sanitized)
195
+ feedback.extend(_detect_feedback(sanitized))
196
+ elif output_role == "assistant":
197
+ n_asst += 1
198
+ assistant_finals.append(sanitized)
199
+
200
+ if project and not _project_matches(session_project or "", "invoked", project):
201
+ return None
202
+ if n_user == 0 and n_asst == 0:
203
+ return None
204
+
205
+ return SessionDigest(
206
+ session_id=session_id,
207
+ project=session_project,
208
+ started_at=started,
209
+ ended_at=ended,
210
+ user_prompts=user_prompts,
211
+ assistant_finals=assistant_finals[-5:],
212
+ tools_used=_dedup(tools),
213
+ files_touched=[],
214
+ feedback_signals=feedback,
215
+ n_user_turns=n_user,
216
+ n_assistant_turns=n_asst,
217
+ raw_path=path,
218
+ )
219
+
220
+
221
+ def harvest_codex(
222
+ archived_sessions_dir: str,
223
+ *,
224
+ scope: Any = "all",
225
+ invoked_project: str = "",
226
+ since_iso: Optional[str] = None,
227
+ limit: int = 0,
228
+ ) -> List[SessionDigest]:
229
+ """Walk ``~/.codex/archived_sessions`` and return matching digests."""
230
+ digests: List[SessionDigest] = []
231
+ if not os.path.isdir(archived_sessions_dir):
232
+ return digests
233
+
234
+ paths = [
235
+ os.path.join(archived_sessions_dir, fn)
236
+ for fn in os.listdir(archived_sessions_dir)
237
+ if fn.endswith(".jsonl")
238
+ ]
239
+ paths.sort(key=lambda p: os.path.getmtime(p), reverse=True)
240
+
241
+ project_hint = invoked_project if scope == "invoked" else ""
242
+ for path in paths:
243
+ digest = digest_codex_archived_session(path, project=project_hint)
244
+ if digest is None:
245
+ continue
246
+ if not _project_matches(digest.project or "", scope, invoked_project):
247
+ continue
248
+ if since_iso and digest.ended_at and digest.ended_at < since_iso:
249
+ continue
250
+ digests.append(digest)
251
+ if limit and len(digests) >= limit:
252
+ break
253
+ return digests
@@ -0,0 +1,41 @@
1
+ """Source selection for SkillOpt-Sleep transcript harvesting."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from skillopt_sleep.harvest import harvest
7
+ from skillopt_sleep.harvest_codex import harvest_codex
8
+ from skillopt_sleep.types import SessionDigest
9
+
10
+
11
+ def harvest_for_config(cfg, *, since_iso: Optional[str] = None, limit: int = 0) -> list[SessionDigest]:
12
+ source = cfg.get("transcript_source", "claude")
13
+ scope = cfg.get("projects", "invoked")
14
+ invoked_project = cfg.get("invoked_project", "")
15
+
16
+ if source == "codex":
17
+ return harvest_codex(
18
+ cfg.codex_archived_sessions_dir,
19
+ scope=scope,
20
+ invoked_project=invoked_project,
21
+ since_iso=since_iso,
22
+ limit=limit,
23
+ )
24
+ if source == "auto":
25
+ codex_digests = harvest_codex(
26
+ cfg.codex_archived_sessions_dir,
27
+ scope=scope,
28
+ invoked_project=invoked_project,
29
+ since_iso=since_iso,
30
+ limit=limit,
31
+ )
32
+ if codex_digests:
33
+ return codex_digests
34
+
35
+ return harvest(
36
+ cfg.transcripts_dir,
37
+ scope=scope,
38
+ invoked_project=invoked_project,
39
+ since_iso=since_iso,
40
+ limit=limit,
41
+ )
@@ -0,0 +1,84 @@
1
+ """SkillOpt-Sleep — rule-based judges (gbrain-evals compatible).
2
+
3
+ Implements the programmatic check operators used by gbrain-evals'
4
+ skillopt-v1 benchmark so we can score skill outputs locally, with NO judge
5
+ API call:
6
+
7
+ * section_present <name> — a markdown heading containing <name> exists
8
+ * regex <pattern> — the pattern matches the response
9
+ * max_chars <n> — response length <= n
10
+ * min_chars <n> — response length >= n
11
+ * contains <text> — substring present (case-insensitive)
12
+ * tool_called <name> — a tool with <name> was invoked (needs a tool loop;
13
+ in single-shot replay we approximate via an
14
+ explicit "TOOL_CALL: <name>" marker the agent emits)
15
+
16
+ A task whose judge is {"kind": "rule", "checks": [...]} passes (hard=1.0) iff
17
+ ALL checks pass; soft = fraction of checks passed. This mirrors gbrain's
18
+ all-checks-must-pass rule scoring and gives the gate a smooth signal.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Any, Dict, List, Tuple
24
+
25
+
26
+ def _section_present(response: str, name: str) -> bool:
27
+ # a markdown heading line (#, ##, ...) or bold line that contains `name`
28
+ pat = re.compile(
29
+ r"(?im)^\s{0,3}(#{1,6}\s*.*%s|\*\*.*%s.*\*\*\s*:?)\s*$" % (re.escape(name), re.escape(name))
30
+ )
31
+ if pat.search(response or ""):
32
+ return True
33
+ # also accept "Name:" style label at line start
34
+ label = re.compile(r"(?im)^\s*%s\s*:" % re.escape(name))
35
+ return bool(label.search(response or ""))
36
+
37
+
38
+ def _check(op: str, arg: Any, response: str, tools_called: List[str]) -> bool:
39
+ r = response or ""
40
+ if op == "section_present":
41
+ return _section_present(r, str(arg))
42
+ if op == "regex":
43
+ try:
44
+ return bool(re.search(str(arg), r))
45
+ except re.error:
46
+ return False
47
+ if op == "max_chars":
48
+ return len(r) <= int(arg)
49
+ if op == "min_chars":
50
+ return len(r) >= int(arg)
51
+ if op == "contains":
52
+ return str(arg).lower() in r.lower()
53
+ if op == "tool_called":
54
+ name = str(arg).lower()
55
+ if any(name == t.lower() for t in tools_called):
56
+ return True
57
+ # single-shot approximation: the agent emits an explicit marker
58
+ return bool(re.search(r"(?i)\btool_call\s*:\s*%s\b" % re.escape(name), r))
59
+ # unknown op: do not block
60
+ return True
61
+
62
+
63
+ def score_rule_judge(
64
+ judge: Dict[str, Any],
65
+ response: str,
66
+ tools_called: List[str] | None = None,
67
+ ) -> Tuple[float, float, str]:
68
+ """Return (hard, soft, rationale) for a gbrain-style rule judge."""
69
+ checks = (judge or {}).get("checks", []) or []
70
+ if not checks:
71
+ return 0.0, 0.0, "no checks"
72
+ tools_called = tools_called or []
73
+ passed = 0
74
+ failed_desc: List[str] = []
75
+ for c in checks:
76
+ ok = _check(c.get("op", ""), c.get("arg"), response, tools_called)
77
+ if ok:
78
+ passed += 1
79
+ else:
80
+ failed_desc.append(f"{c.get('op')}={c.get('arg')}")
81
+ soft = passed / len(checks)
82
+ hard = 1.0 if passed == len(checks) else 0.0
83
+ rationale = "all checks passed" if hard else "failed: " + ", ".join(failed_desc)
84
+ return hard, soft, rationale
@@ -0,0 +1,134 @@
1
+ """SkillOpt-Sleep — LLM-backed task miner.
2
+
3
+ The heuristic miner (mine.py) produces TaskRecords without a checkable
4
+ reference, so real harvested transcripts can't show measurable lift. This
5
+ module uses an optimizer backend to turn session digests into TaskRecords
6
+ WITH a checkable rubric judge — the missing piece for real-data improvement.
7
+
8
+ For each recurring intent it extracts:
9
+ * a clean, generalized `intent` (the reusable task, stripped of one-off specifics)
10
+ * a `rubric` (what a good answer must satisfy) -> stored as a rule judge of
11
+ `contains`/`regex`/`section_present` checks the local judge can score, OR a
12
+ free-text rubric scored by the backend's judge() when no programmatic check fits
13
+ * a preference signal (was the user satisfied?) to weight failures
14
+
15
+ It is deliberately conservative: it only emits a task when it can name a
16
+ concrete, checkable success criterion, so the gate has real signal. Tasks it
17
+ can't make checkable are dropped (logged), not faked.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from typing import Any, Callable, Dict, List
24
+
25
+ from skillopt_sleep.backend import Backend, _extract_json
26
+ from skillopt_sleep.types import SessionDigest, TaskRecord
27
+
28
+
29
+ _MINER_PROMPT = """You are mining a user's past AI-assistant sessions to find RECURRING tasks
30
+ worth optimizing a skill for. From the session below, extract 0-3 reusable tasks.
31
+
32
+ A good task is something the user asks for repeatedly or had to correct, where a
33
+ GENERAL rule would help next time (formatting, structure, tool-use, conventions).
34
+ Skip one-off or purely exploratory requests.
35
+
36
+ For each task return:
37
+ - "intent": the reusable request, generalized (no one-off specifics)
38
+ - "checks": a list of programmatic success checks a grader can run on a future
39
+ answer. Each check is one of:
40
+ {"op":"section_present","arg":"<heading text>"}
41
+ {"op":"regex","arg":"<python regex the answer must match>"}
42
+ {"op":"contains","arg":"<substring the answer must contain>"}
43
+ {"op":"max_chars","arg":<int>}
44
+ Only include checks you are confident a GOOD answer must satisfy.
45
+ - "rubric": a one-sentence description of what a good answer looks like
46
+ - "satisfied": true/false — did the user seem satisfied with the assistant's answer?
47
+
48
+ Return ONLY a JSON array (possibly empty). No prose.
49
+
50
+ # Session
51
+ project: __PROJECT__
52
+ user prompts:
53
+ __PROMPTS__
54
+ assistant final (last):
55
+ __FINAL__
56
+ feedback signals: __FEEDBACK__
57
+ """
58
+
59
+
60
+ def _digest_to_prompt(d: SessionDigest) -> str:
61
+ prompts = "\n".join(f" - {p[:240]}" for p in d.user_prompts[:6]) or " (none)"
62
+ final = (d.assistant_finals[-1][:400] if d.assistant_finals else "(none)")
63
+ return (
64
+ _MINER_PROMPT
65
+ .replace("__PROJECT__", d.project or "(unknown)")
66
+ .replace("__PROMPTS__", prompts)
67
+ .replace("__FINAL__", final)
68
+ .replace("__FEEDBACK__", ", ".join(d.feedback_signals[:6]) or "(none)")
69
+ )
70
+
71
+
72
+ def _mk_task(d: SessionDigest, obj: Dict[str, Any], idx: int) -> TaskRecord | None:
73
+ intent = str(obj.get("intent", "")).strip()
74
+ if len(intent) < 8:
75
+ return None
76
+ checks = obj.get("checks") or []
77
+ rubric = str(obj.get("rubric", "")).strip()
78
+ satisfied = bool(obj.get("satisfied", False))
79
+
80
+ # keep only well-formed checks
81
+ clean_checks = []
82
+ for c in checks:
83
+ if isinstance(c, dict) and c.get("op") in {
84
+ "section_present", "regex", "contains", "max_chars", "min_chars",
85
+ }:
86
+ clean_checks.append({"op": c["op"], "arg": c.get("arg")})
87
+
88
+ import hashlib
89
+ tid = "llm_" + hashlib.sha256((d.project + intent).encode()).hexdigest()[:12]
90
+
91
+ if clean_checks:
92
+ return TaskRecord(
93
+ id=tid, project=d.project, intent=intent,
94
+ reference_kind="rule", judge={"kind": "rule", "checks": clean_checks},
95
+ outcome="success" if satisfied else "fail",
96
+ tags=["mined:llm"], source_sessions=[d.session_id],
97
+ )
98
+ if rubric:
99
+ return TaskRecord(
100
+ id=tid, project=d.project, intent=intent,
101
+ reference_kind="rubric", reference=rubric,
102
+ outcome="success" if satisfied else "fail",
103
+ tags=["mined:llm"], source_sessions=[d.session_id],
104
+ )
105
+ return None # not checkable -> drop
106
+
107
+
108
+ def make_llm_miner(
109
+ backend: Backend,
110
+ *,
111
+ max_sessions: int = 20,
112
+ max_tasks: int = 40,
113
+ ) -> Callable[[List[SessionDigest]], List[TaskRecord]]:
114
+ """Return an llm_miner(digests) -> list[TaskRecord] bound to a backend."""
115
+
116
+ def _miner(digests: List[SessionDigest]) -> List[TaskRecord]:
117
+ out: List[TaskRecord] = []
118
+ for d in digests[:max_sessions]:
119
+ if not d.user_prompts:
120
+ continue
121
+ raw = backend._call(_digest_to_prompt(d), max_tokens=800) # type: ignore[attr-defined]
122
+ arr = _extract_json(raw, "array")
123
+ if not isinstance(arr, list):
124
+ continue
125
+ for i, obj in enumerate(arr[:3]):
126
+ if isinstance(obj, dict):
127
+ t = _mk_task(d, obj, i)
128
+ if t is not None:
129
+ out.append(t)
130
+ if len(out) >= max_tasks:
131
+ return out
132
+ return out
133
+
134
+ return _miner
@@ -0,0 +1,129 @@
1
+ """SkillOpt-Sleep — skill/memory document manipulation.
2
+
3
+ Applies bounded EditRecords to a skill (SKILL.md body) or memory (CLAUDE.md)
4
+ document, and provides Dream-style consolidation helpers (dedup near-identical
5
+ lines, drop contradictions). All edits live inside a protected, clearly-marked
6
+ region so the sleep cycle never clobbers the user's hand-written content.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import List, Tuple
12
+
13
+ from skillopt_sleep.types import EditRecord
14
+
15
+ LEARNED_START = "<!-- SKILLOPT-SLEEP:LEARNED START -->"
16
+ LEARNED_END = "<!-- SKILLOPT-SLEEP:LEARNED END -->"
17
+ _BANNER = (
18
+ "_This block is maintained by SkillOpt-Sleep. Edits here are proposed "
19
+ "offline, validated against your past tasks, and adopted only after you "
20
+ "approve them. Hand-edits outside this block are never touched._"
21
+ )
22
+
23
+
24
+ def extract_learned(doc: str) -> str:
25
+ s = doc.find(LEARNED_START)
26
+ e = doc.find(LEARNED_END)
27
+ if s == -1 or e == -1:
28
+ return ""
29
+ return doc[s + len(LEARNED_START):e].strip()
30
+
31
+
32
+ def _strip_learned(doc: str) -> str:
33
+ while True:
34
+ s = doc.find(LEARNED_START)
35
+ if s == -1:
36
+ break
37
+ e = doc.find(LEARNED_END, s)
38
+ if e == -1:
39
+ doc = doc[:s]
40
+ break
41
+ doc = doc[:s] + doc[e + len(LEARNED_END):]
42
+ while "\n\n\n" in doc:
43
+ doc = doc.replace("\n\n\n", "\n\n")
44
+ return doc.rstrip()
45
+
46
+
47
+ def set_learned(doc: str, learned_lines: List[str]) -> str:
48
+ """Replace the protected learned region with the given bullet lines."""
49
+ base = _strip_learned(doc)
50
+ body = "\n".join(f"- {ln.strip().lstrip('- ').strip()}" for ln in learned_lines if ln.strip())
51
+ block = (
52
+ f"\n\n{LEARNED_START}\n"
53
+ f"## Learned preferences & procedures\n\n{_BANNER}\n\n{body}\n"
54
+ f"{LEARNED_END}\n"
55
+ )
56
+ return (base + block).lstrip("\n")
57
+
58
+
59
+ def current_learned_lines(doc: str) -> List[str]:
60
+ inner = extract_learned(doc)
61
+ lines: List[str] = []
62
+ for ln in inner.splitlines():
63
+ ln = ln.strip()
64
+ if ln.startswith("- "):
65
+ lines.append(ln[2:].strip())
66
+ return lines
67
+
68
+
69
+ def _norm(s: str) -> str:
70
+ return re.sub(r"\s+", " ", (s or "").lower()).strip()
71
+
72
+
73
+ def apply_edits(doc: str, edits: List[EditRecord]) -> Tuple[str, List[EditRecord]]:
74
+ """Apply add/delete/replace edits to the protected learned region.
75
+
76
+ Returns (new_doc, applied_edits). Dedups: an `add` whose content already
77
+ exists (normalized) is skipped. `delete`/`replace` match on normalized
78
+ anchor substring.
79
+ """
80
+ lines = current_learned_lines(doc)
81
+ norm_set = {_norm(line) for line in lines}
82
+ applied: List[EditRecord] = []
83
+
84
+ for e in edits:
85
+ op = (e.op or "add").lower()
86
+ if op == "add":
87
+ if _norm(e.content) in norm_set or not e.content.strip():
88
+ continue
89
+ lines.append(e.content.strip())
90
+ norm_set.add(_norm(e.content))
91
+ applied.append(e)
92
+ elif op == "delete":
93
+ anchor = _norm(e.anchor or e.content)
94
+ keep = [line for line in lines if anchor not in _norm(line)]
95
+ if len(keep) != len(lines):
96
+ lines = keep
97
+ norm_set = {_norm(line) for line in lines}
98
+ applied.append(e)
99
+ elif op == "replace":
100
+ anchor = _norm(e.anchor)
101
+ new_lines = []
102
+ changed = False
103
+ for line in lines:
104
+ if anchor and anchor in _norm(line):
105
+ new_lines.append(e.content.strip())
106
+ changed = True
107
+ else:
108
+ new_lines.append(line)
109
+ if changed:
110
+ lines = new_lines
111
+ norm_set = {_norm(line) for line in lines}
112
+ applied.append(e)
113
+
114
+ return set_learned(doc, lines), applied
115
+
116
+
117
+ def ensure_skill_scaffold(doc: str, *, name: str, description: str) -> str:
118
+ """Ensure a SKILL.md has YAML frontmatter so local agents load it."""
119
+ if doc.lstrip().startswith("---"):
120
+ return doc
121
+ fm = (
122
+ "---\n"
123
+ f"name: {name}\n"
124
+ f"description: {description}\n"
125
+ "---\n\n"
126
+ f"# {name}\n\n"
127
+ "Preferences and procedures learned from your past local agent sessions.\n"
128
+ )
129
+ return fm + doc