self-evolve-framework 1.2.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.
- package/README.md +22 -11
- package/bin/cli.js +1 -0
- package/package.json +1 -1
- package/template/skills/ponytail/SKILL.md +133 -0
- package/template/skills/ponytail/scripts/hooks/claude-codex-hooks.json +44 -0
- package/template/skills/ponytail/scripts/hooks/copilot-hooks.json +21 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-activate.js +91 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-config.js +122 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-instructions.js +94 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-mode-tracker.js +55 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-runtime.js +68 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-statusline.ps1 +21 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-statusline.sh +12 -0
- package/template/skills/ponytail/scripts/hooks/ponytail-subagent.js +22 -0
- package/template/skills/ponytail/scripts/mcp/README.md +46 -0
- package/template/skills/ponytail/scripts/mcp/index.js +48 -0
- package/template/skills/ponytail/scripts/mcp/instructions.js +26 -0
- package/template/skills/ponytail/scripts/mcp/package.json +13 -0
- package/template/skills/ponytail/scripts/mcp/test/instructions.test.js +22 -0
- package/template/skills/ponytail-audit/SKILL.md +41 -0
- package/template/skills/ponytail-debt/SKILL.md +44 -0
- package/template/skills/ponytail-gain/SKILL.md +50 -0
- package/template/skills/ponytail-help/SKILL.md +69 -0
- package/template/skills/ponytail-review/SKILL.md +57 -0
- package/template/skills/skillopt-sleep/SKILL.md +48 -34
- package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
- package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
- package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
- package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
- package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
- package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
- package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
- package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
- package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
- package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
- package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
- package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
- package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
- package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
- package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
- package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
- package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
- package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
- package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
- package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
- package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
- package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
- package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
- package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
- package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
- package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
- package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
- package/template/skills/skillopt-sleep/scripts/shell/train.py +556 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — built-in nightly scheduler.
|
|
2
|
+
|
|
3
|
+
Installs/removes a crontab entry that runs the sleep cycle automatically, so the
|
|
4
|
+
user doesn't have to wire cron themselves. Idempotent: a managed block delimited
|
|
5
|
+
by marker comments is added/replaced/removed in the user's crontab.
|
|
6
|
+
|
|
7
|
+
Design choices:
|
|
8
|
+
* Off-:00 minute (3:17 local by default) so many users don't all hit the API
|
|
9
|
+
at the same instant.
|
|
10
|
+
* The entry runs `python -m skillopt_sleep run` for a specific project and
|
|
11
|
+
appends to <project>/.skillopt-sleep/cron.log.
|
|
12
|
+
* `schedule` is additive per project (keyed by project path); `unschedule`
|
|
13
|
+
removes the project's line (or the whole managed block with --all).
|
|
14
|
+
|
|
15
|
+
cron is the portable mechanism on Linux/macOS. On systems without `crontab`,
|
|
16
|
+
`schedule` prints the line and instructions instead of failing.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from typing import List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
_BEGIN = "# >>> skillopt-sleep (managed) >>>"
|
|
27
|
+
_END = "# <<< skillopt-sleep (managed) <<<"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _have_crontab() -> bool:
|
|
31
|
+
return shutil.which("crontab") is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_crontab() -> str:
|
|
35
|
+
try:
|
|
36
|
+
proc = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
37
|
+
return proc.stdout if proc.returncode == 0 else ""
|
|
38
|
+
except Exception:
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write_crontab(content: str) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
proc = subprocess.run(["crontab", "-"], input=content, text=True,
|
|
45
|
+
capture_output=True)
|
|
46
|
+
return proc.returncode == 0
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split_managed(crontab: str) -> Tuple[str, List[str]]:
|
|
52
|
+
"""Return (text_outside_block, managed_lines_inside_block)."""
|
|
53
|
+
lines = crontab.splitlines()
|
|
54
|
+
outside: List[str] = []
|
|
55
|
+
managed: List[str] = []
|
|
56
|
+
in_block = False
|
|
57
|
+
for ln in lines:
|
|
58
|
+
if ln.strip() == _BEGIN:
|
|
59
|
+
in_block = True
|
|
60
|
+
continue
|
|
61
|
+
if ln.strip() == _END:
|
|
62
|
+
in_block = False
|
|
63
|
+
continue
|
|
64
|
+
(managed if in_block else outside).append(ln)
|
|
65
|
+
return "\n".join(outside).rstrip(), managed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _runner_cmd(project: str, backend: str, extra: str, python: str) -> str:
|
|
69
|
+
logdir = os.path.join(project, ".skillopt-sleep")
|
|
70
|
+
log = os.path.join(logdir, "cron.log")
|
|
71
|
+
# use absolute python + -m so cron's minimal env still works
|
|
72
|
+
cmd = (f'{python} -m skillopt_sleep run --project "{project}" '
|
|
73
|
+
f'--scope invoked --backend {backend} {extra}'.rstrip())
|
|
74
|
+
return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _repo_root() -> str:
|
|
78
|
+
# the package lives at <repo>/skillopt_sleep/; repo root is its parent
|
|
79
|
+
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _project_marker(project: str) -> str:
|
|
83
|
+
return f"# project={os.path.abspath(project)}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def schedule(project: str, *, backend: str = "mock", hour: int = 3, minute: int = 17,
|
|
87
|
+
extra: str = "", python: Optional[str] = None) -> Tuple[bool, str]:
|
|
88
|
+
"""Install (or replace) the nightly entry for ``project``.
|
|
89
|
+
|
|
90
|
+
Returns (installed, message). If crontab is unavailable, installed=False and
|
|
91
|
+
the message contains the line to add manually.
|
|
92
|
+
"""
|
|
93
|
+
project = os.path.abspath(project)
|
|
94
|
+
python = python or sys.executable or "python3"
|
|
95
|
+
cron_line = f"{minute} {hour} * * * {_runner_cmd(project, backend, extra, python)} {_project_marker(project)}"
|
|
96
|
+
|
|
97
|
+
if not _have_crontab():
|
|
98
|
+
return False, ("crontab not found on this system. Add this line to your "
|
|
99
|
+
"scheduler manually:\n" + cron_line)
|
|
100
|
+
|
|
101
|
+
outside, managed = _split_managed(_read_crontab())
|
|
102
|
+
# drop any existing line for this project, then add the new one
|
|
103
|
+
marker = _project_marker(project)
|
|
104
|
+
managed = [ln for ln in managed if marker not in ln and ln.strip()]
|
|
105
|
+
managed.append(cron_line)
|
|
106
|
+
|
|
107
|
+
block = _BEGIN + "\n" + "\n".join(managed) + "\n" + _END
|
|
108
|
+
new_crontab = (outside + "\n\n" + block + "\n").lstrip("\n")
|
|
109
|
+
ok = _write_crontab(new_crontab)
|
|
110
|
+
if ok:
|
|
111
|
+
return True, (f"Scheduled nightly at {hour:02d}:{minute:02d} for {project} "
|
|
112
|
+
f"(backend={backend}). Logs -> {project}/.skillopt-sleep/cron.log\n"
|
|
113
|
+
f"Runs `skillopt_sleep run`; it only STAGES a proposal — adopt is still manual.")
|
|
114
|
+
return False, "Failed to write crontab. Line to add manually:\n" + cron_line
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def unschedule(project: Optional[str] = None, *, all_projects: bool = False) -> Tuple[bool, str]:
|
|
118
|
+
"""Remove the entry for ``project`` (or the whole managed block with all_projects)."""
|
|
119
|
+
if not _have_crontab():
|
|
120
|
+
return False, "crontab not found; nothing to remove."
|
|
121
|
+
outside, managed = _split_managed(_read_crontab())
|
|
122
|
+
if all_projects:
|
|
123
|
+
managed = []
|
|
124
|
+
elif project:
|
|
125
|
+
marker = _project_marker(project)
|
|
126
|
+
managed = [ln for ln in managed if marker not in ln and ln.strip()]
|
|
127
|
+
if managed:
|
|
128
|
+
block = _BEGIN + "\n" + "\n".join(managed) + "\n" + _END
|
|
129
|
+
new_crontab = (outside + "\n\n" + block + "\n").lstrip("\n")
|
|
130
|
+
else:
|
|
131
|
+
new_crontab = outside.rstrip() + "\n"
|
|
132
|
+
ok = _write_crontab(new_crontab)
|
|
133
|
+
return ok, ("Removed." if ok else "Failed to update crontab.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def list_scheduled() -> List[str]:
|
|
137
|
+
_outside, managed = _split_managed(_read_crontab())
|
|
138
|
+
return [ln for ln in managed if ln.strip()]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — slow update (cross-night long-term memory).
|
|
2
|
+
|
|
3
|
+
This is the deployment-time analogue of SkillOpt's epoch-wise slow/meta update
|
|
4
|
+
(paper §3.6). Step-level edits (consolidate) learn from one night's batch; the
|
|
5
|
+
slow update learns across nights and writes a durable "longitudinal guidance"
|
|
6
|
+
block into a PROTECTED field of the skill that step-level edits never touch.
|
|
7
|
+
|
|
8
|
+
It reuses the exact protected-field marker convention from the main repo
|
|
9
|
+
(``skillopt/optimizer/slow_update.py``) so the artifact is compatible:
|
|
10
|
+
|
|
11
|
+
<!-- SLOW_UPDATE_START --> ... <!-- SLOW_UPDATE_END -->
|
|
12
|
+
|
|
13
|
+
Why it matters: even when the user turns the validation gate OFF (greedy mode),
|
|
14
|
+
the slow update still runs at the end of the run, so short-term nightly
|
|
15
|
+
experience is consolidated into long-term memory rather than lost. The cross-night
|
|
16
|
+
content is carried in ``state.slow_memory``.
|
|
17
|
+
|
|
18
|
+
Driven through the Backend abstraction (mock/claude/codex), so it stays
|
|
19
|
+
import-light — no `openai` dependency.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from typing import List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from skillopt_sleep.backend import Backend, _extract_json
|
|
27
|
+
from skillopt_sleep.types import ReplayResult, TaskRecord
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SLOW_UPDATE_START = "<!-- SLOW_UPDATE_START -->"
|
|
31
|
+
SLOW_UPDATE_END = "<!-- SLOW_UPDATE_END -->"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── protected-field helpers (mirror skillopt/optimizer/slow_update.py) ─────────
|
|
35
|
+
|
|
36
|
+
def has_slow_field(skill: str) -> bool:
|
|
37
|
+
return SLOW_UPDATE_START in skill and SLOW_UPDATE_END in skill
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_slow_field(skill: str) -> str:
|
|
41
|
+
s = skill.find(SLOW_UPDATE_START)
|
|
42
|
+
e = skill.find(SLOW_UPDATE_END)
|
|
43
|
+
if s == -1 or e == -1:
|
|
44
|
+
return ""
|
|
45
|
+
return skill[s + len(SLOW_UPDATE_START):e].strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _strip_slow_fields(skill: str) -> str:
|
|
49
|
+
while True:
|
|
50
|
+
s = skill.find(SLOW_UPDATE_START)
|
|
51
|
+
if s == -1:
|
|
52
|
+
break
|
|
53
|
+
e = skill.find(SLOW_UPDATE_END, s)
|
|
54
|
+
if e == -1:
|
|
55
|
+
skill = skill[:s]
|
|
56
|
+
break
|
|
57
|
+
skill = skill[:s] + skill[e + len(SLOW_UPDATE_END):]
|
|
58
|
+
skill = skill.replace(SLOW_UPDATE_END, "")
|
|
59
|
+
while "\n\n\n" in skill:
|
|
60
|
+
skill = skill.replace("\n\n\n", "\n\n")
|
|
61
|
+
return skill.rstrip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def replace_slow_field(skill: str, content: str) -> str:
|
|
65
|
+
"""Set the protected slow-update field to ``content`` (exactly one block)."""
|
|
66
|
+
base = _strip_slow_fields(skill)
|
|
67
|
+
if not content.strip():
|
|
68
|
+
return base
|
|
69
|
+
block = f"\n\n{SLOW_UPDATE_START}\n{content.strip()}\n{SLOW_UPDATE_END}\n"
|
|
70
|
+
return base + block
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── the slow-update synthesis ──────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def _summarize_pairs(
|
|
76
|
+
prev_pairs: List[Tuple[TaskRecord, ReplayResult]],
|
|
77
|
+
curr_pairs: List[Tuple[TaskRecord, ReplayResult]],
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Group adjacent-version outcomes into improved/regressed/persistent/stable."""
|
|
80
|
+
prev_by = {t.id: r for t, r in prev_pairs}
|
|
81
|
+
lines: List[str] = []
|
|
82
|
+
counts = {"improved": 0, "regressed": 0, "persistent_fail": 0, "stable_success": 0}
|
|
83
|
+
for t, r in curr_pairs:
|
|
84
|
+
p = prev_by.get(t.id)
|
|
85
|
+
if p is None:
|
|
86
|
+
continue
|
|
87
|
+
a, b = p.hard, r.hard
|
|
88
|
+
if b > a:
|
|
89
|
+
cat = "improved"
|
|
90
|
+
elif b < a:
|
|
91
|
+
cat = "regressed"
|
|
92
|
+
elif b >= 1.0:
|
|
93
|
+
cat = "stable_success"
|
|
94
|
+
else:
|
|
95
|
+
cat = "persistent_fail"
|
|
96
|
+
counts[cat] += 1
|
|
97
|
+
if cat in ("regressed", "persistent_fail") and len(lines) < 8:
|
|
98
|
+
lines.append(f"- [{cat}] {t.intent[:120]} (why: {r.fail_reason[:80]})")
|
|
99
|
+
head = ", ".join(f"{k}={v}" for k, v in counts.items())
|
|
100
|
+
return head + ("\n" + "\n".join(lines) if lines else ""), counts # type: ignore[return-value]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_slow_update(
|
|
104
|
+
backend: Backend,
|
|
105
|
+
*,
|
|
106
|
+
prev_skill: str,
|
|
107
|
+
curr_skill: str,
|
|
108
|
+
prev_pairs: List[Tuple[TaskRecord, ReplayResult]],
|
|
109
|
+
curr_pairs: List[Tuple[TaskRecord, ReplayResult]],
|
|
110
|
+
prev_slow_content: str = "",
|
|
111
|
+
) -> Optional[str]:
|
|
112
|
+
"""Produce durable longitudinal guidance text (or None).
|
|
113
|
+
|
|
114
|
+
Compares behavior under the previous vs current skill across the same tasks
|
|
115
|
+
and asks the optimizer to distill a short, durable guidance block — what to
|
|
116
|
+
keep doing, what regressions to avoid — refining any prior slow-update text.
|
|
117
|
+
"""
|
|
118
|
+
summary, counts = _summarize_pairs(prev_pairs, curr_pairs) # type: ignore[misc]
|
|
119
|
+
# nothing changed and no prior guidance to refine → skip
|
|
120
|
+
if counts["regressed"] == 0 and counts["persistent_fail"] == 0 and not prev_slow_content:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
prompt = (
|
|
124
|
+
"You are SkillOpt's SLOW UPDATE — the long-term memory pass that runs "
|
|
125
|
+
"across nights. Write a SHORT, durable guidance block (2-5 bullet "
|
|
126
|
+
"points) capturing the longitudinal lessons: behaviors that reliably "
|
|
127
|
+
"help and should be preserved, and regressions/persistent failures to "
|
|
128
|
+
"avoid. Keep it GENERAL and stable (not tied to one task). If prior "
|
|
129
|
+
"guidance is given, refine it rather than restate it.\n"
|
|
130
|
+
'Return ONLY JSON: {"guidance": "<bullet list as one string>"}.\n\n'
|
|
131
|
+
f"# Cross-night outcome summary\n{summary}\n\n"
|
|
132
|
+
f"# Prior long-term guidance (refine this)\n{prev_slow_content or '(none)'}"
|
|
133
|
+
)
|
|
134
|
+
raw = backend._call(prompt, max_tokens=600) # type: ignore[attr-defined]
|
|
135
|
+
obj = _extract_json(raw, "object")
|
|
136
|
+
if isinstance(obj, dict):
|
|
137
|
+
g = str(obj.get("guidance", "")).strip()
|
|
138
|
+
if g:
|
|
139
|
+
return g
|
|
140
|
+
# fallback: if the model returned prose, keep the first ~400 chars
|
|
141
|
+
text = (raw or "").strip()
|
|
142
|
+
return text[:400] if text else None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — Stage 5/6: staging and adoption.
|
|
2
|
+
|
|
3
|
+
Implements the Dreams safety contract: the cycle never mutates the user's
|
|
4
|
+
live CLAUDE.md / SKILL.md. It writes proposals + a human-readable report into
|
|
5
|
+
a staging directory; a separate, explicit `adopt` step copies them over the
|
|
6
|
+
live files after taking a backup.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import time
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from skillopt_sleep.types import SleepReport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ts_dir() -> str:
|
|
20
|
+
return time.strftime("%Y%m%d-%H%M%S", time.localtime())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def staging_root(project: str) -> str:
|
|
24
|
+
return os.path.join(project, ".skillopt-sleep", "staging")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def latest_staging(project: str) -> Optional[str]:
|
|
28
|
+
root = staging_root(project)
|
|
29
|
+
if not os.path.isdir(root):
|
|
30
|
+
return None
|
|
31
|
+
subs = sorted(
|
|
32
|
+
(os.path.join(root, d) for d in os.listdir(root)),
|
|
33
|
+
key=lambda p: os.path.getmtime(p),
|
|
34
|
+
reverse=True,
|
|
35
|
+
)
|
|
36
|
+
return subs[0] if subs else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_staging(
|
|
40
|
+
project: str,
|
|
41
|
+
*,
|
|
42
|
+
report: SleepReport,
|
|
43
|
+
proposed_skill: Optional[str],
|
|
44
|
+
proposed_memory: Optional[str],
|
|
45
|
+
live_skill_path: str,
|
|
46
|
+
live_memory_path: str,
|
|
47
|
+
report_md: str,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Write proposals + report into staging/<ts>/ and return that path."""
|
|
50
|
+
out = os.path.join(staging_root(project), _ts_dir())
|
|
51
|
+
os.makedirs(out, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
manifest = {
|
|
54
|
+
"live_skill_path": live_skill_path,
|
|
55
|
+
"live_memory_path": live_memory_path,
|
|
56
|
+
"has_skill": proposed_skill is not None,
|
|
57
|
+
"has_memory": proposed_memory is not None,
|
|
58
|
+
"accepted": report.accepted,
|
|
59
|
+
}
|
|
60
|
+
if proposed_skill is not None:
|
|
61
|
+
with open(os.path.join(out, "proposed_SKILL.md"), "w", encoding="utf-8") as f:
|
|
62
|
+
f.write(proposed_skill)
|
|
63
|
+
if proposed_memory is not None:
|
|
64
|
+
with open(os.path.join(out, "proposed_CLAUDE.md"), "w", encoding="utf-8") as f:
|
|
65
|
+
f.write(proposed_memory)
|
|
66
|
+
with open(os.path.join(out, "report.json"), "w", encoding="utf-8") as f:
|
|
67
|
+
json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
|
|
68
|
+
with open(os.path.join(out, "report.md"), "w", encoding="utf-8") as f:
|
|
69
|
+
f.write(report_md)
|
|
70
|
+
with open(os.path.join(out, "manifest.json"), "w", encoding="utf-8") as f:
|
|
71
|
+
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _backup(path: str, backup_dir: str) -> None:
|
|
76
|
+
if os.path.exists(path):
|
|
77
|
+
os.makedirs(backup_dir, exist_ok=True)
|
|
78
|
+
shutil.copy2(path, os.path.join(backup_dir, os.path.basename(path)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def adopt(staging_dir: str) -> List[str]:
|
|
82
|
+
"""Copy staged proposals over the live files, backing up first.
|
|
83
|
+
|
|
84
|
+
Returns the list of live paths that were updated.
|
|
85
|
+
"""
|
|
86
|
+
with open(os.path.join(staging_dir, "manifest.json")) as f:
|
|
87
|
+
manifest = json.load(f)
|
|
88
|
+
backup_dir = os.path.join(staging_dir, "backup")
|
|
89
|
+
updated: List[str] = []
|
|
90
|
+
|
|
91
|
+
if manifest.get("has_skill"):
|
|
92
|
+
live = manifest["live_skill_path"]
|
|
93
|
+
os.makedirs(os.path.dirname(live), exist_ok=True)
|
|
94
|
+
_backup(live, backup_dir)
|
|
95
|
+
shutil.copy2(os.path.join(staging_dir, "proposed_SKILL.md"), live)
|
|
96
|
+
updated.append(live)
|
|
97
|
+
if manifest.get("has_memory"):
|
|
98
|
+
live = manifest["live_memory_path"]
|
|
99
|
+
os.makedirs(os.path.dirname(live), exist_ok=True)
|
|
100
|
+
_backup(live, backup_dir)
|
|
101
|
+
shutil.copy2(os.path.join(staging_dir, "proposed_CLAUDE.md"), live)
|
|
102
|
+
updated.append(live)
|
|
103
|
+
return updated
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""SkillOpt-Sleep — persistent cross-night state.
|
|
2
|
+
|
|
3
|
+
state.json lives in ~/.skillopt-sleep and is the "long-term" store that
|
|
4
|
+
turns nightly episodes into durable competence (the Agent-Sleep paper's
|
|
5
|
+
short-term -> long-term transfer). It records:
|
|
6
|
+
|
|
7
|
+
- night counter
|
|
8
|
+
- last harvest timestamp per project (so each night only sees new data)
|
|
9
|
+
- cross-night "slow/meta" memory (lessons that persisted across nights)
|
|
10
|
+
- per-night history (scores, accept/reject) for trend reporting
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _now_iso(clock: Optional[float] = None) -> str:
|
|
20
|
+
# caller passes a timestamp; we avoid importing time at module import
|
|
21
|
+
import time as _t
|
|
22
|
+
return _t.strftime("%Y-%m-%dT%H:%M:%S", _t.localtime(clock if clock is not None else _t.time()))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEFAULT_STATE: Dict[str, Any] = {
|
|
26
|
+
"version": 1,
|
|
27
|
+
"night": 0,
|
|
28
|
+
"last_harvest": {}, # project -> iso timestamp of last harvested record
|
|
29
|
+
"slow_memory": "", # cross-night consolidated lessons (meta-skill analogue)
|
|
30
|
+
"history": [], # list of per-night summaries
|
|
31
|
+
"task_archive": [], # capped list of past mined tasks (for associative recall)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SleepState:
|
|
36
|
+
def __init__(self, path: str, data: Optional[Dict[str, Any]] = None) -> None:
|
|
37
|
+
self.path = path
|
|
38
|
+
self.data = data if data is not None else dict(DEFAULT_STATE)
|
|
39
|
+
|
|
40
|
+
# io ---------------------------------------------------------------------
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls, path: str) -> "SleepState":
|
|
43
|
+
if os.path.exists(path):
|
|
44
|
+
try:
|
|
45
|
+
with open(path) as f:
|
|
46
|
+
data = json.load(f)
|
|
47
|
+
merged = dict(DEFAULT_STATE)
|
|
48
|
+
merged.update(data if isinstance(data, dict) else {})
|
|
49
|
+
return cls(path, merged)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return cls(path, dict(DEFAULT_STATE))
|
|
53
|
+
|
|
54
|
+
def save(self) -> None:
|
|
55
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
56
|
+
tmp = self.path + ".tmp"
|
|
57
|
+
with open(tmp, "w") as f:
|
|
58
|
+
json.dump(self.data, f, ensure_ascii=False, indent=2)
|
|
59
|
+
os.replace(tmp, self.path)
|
|
60
|
+
|
|
61
|
+
# accessors --------------------------------------------------------------
|
|
62
|
+
@property
|
|
63
|
+
def night(self) -> int:
|
|
64
|
+
return int(self.data.get("night", 0))
|
|
65
|
+
|
|
66
|
+
def last_harvest_for(self, project: str) -> Optional[str]:
|
|
67
|
+
return self.data.get("last_harvest", {}).get(project)
|
|
68
|
+
|
|
69
|
+
def set_last_harvest(self, project: str, iso_ts: str) -> None:
|
|
70
|
+
self.data.setdefault("last_harvest", {})[project] = iso_ts
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def slow_memory(self) -> str:
|
|
74
|
+
return str(self.data.get("slow_memory", ""))
|
|
75
|
+
|
|
76
|
+
def set_slow_memory(self, content: str) -> None:
|
|
77
|
+
self.data["slow_memory"] = content
|
|
78
|
+
|
|
79
|
+
def begin_night(self, clock: Optional[float] = None) -> int:
|
|
80
|
+
self.data["night"] = self.night + 1
|
|
81
|
+
return self.night
|
|
82
|
+
|
|
83
|
+
def record_night(self, summary: Dict[str, Any]) -> None:
|
|
84
|
+
self.data.setdefault("history", []).append(summary)
|
|
85
|
+
|
|
86
|
+
# ── task archive (associative-recall memory) ──────────────────────────
|
|
87
|
+
def task_archive(self) -> list:
|
|
88
|
+
"""Past mined tasks as plain dicts (newest last)."""
|
|
89
|
+
return list(self.data.get("task_archive", []))
|
|
90
|
+
|
|
91
|
+
def add_to_archive(self, task_dicts: list, cap: int = 300) -> None:
|
|
92
|
+
"""Append tonight's tasks; keep only the most recent ``cap``."""
|
|
93
|
+
arc = self.data.setdefault("task_archive", [])
|
|
94
|
+
arc.extend(task_dicts)
|
|
95
|
+
if len(arc) > cap:
|
|
96
|
+
self.data["task_archive"] = arc[-cap:]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Reviewed task-file helpers for privacy-safe SkillOpt-Sleep runs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, List, Tuple
|
|
7
|
+
|
|
8
|
+
from skillopt_sleep.mine import assign_splits, normalize_legacy_split
|
|
9
|
+
from skillopt_sleep.types import TaskRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_tasks_payload(
|
|
13
|
+
tasks: List[TaskRecord],
|
|
14
|
+
*,
|
|
15
|
+
project: str,
|
|
16
|
+
transcript_source: str = "",
|
|
17
|
+
n_sessions: int = 0,
|
|
18
|
+
target_skill_path: str = "",
|
|
19
|
+
) -> Dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"format": "skillopt_sleep.tasks.v1",
|
|
22
|
+
"project": project,
|
|
23
|
+
"transcript_source": transcript_source,
|
|
24
|
+
"n_sessions": n_sessions,
|
|
25
|
+
"target_skill_path": target_skill_path,
|
|
26
|
+
"reviewed": False,
|
|
27
|
+
"tasks": [t.to_dict() for t in tasks],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_tasks_file(path: str, payload: Dict[str, Any]) -> str:
|
|
32
|
+
out = os.path.abspath(os.path.expanduser(path))
|
|
33
|
+
parent = os.path.dirname(out)
|
|
34
|
+
if parent:
|
|
35
|
+
os.makedirs(parent, exist_ok=True)
|
|
36
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
37
|
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
38
|
+
f.write("\n")
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_tasks(
|
|
43
|
+
tasks: List[TaskRecord],
|
|
44
|
+
*,
|
|
45
|
+
holdout_fraction: float,
|
|
46
|
+
seed: int,
|
|
47
|
+
) -> List[TaskRecord]:
|
|
48
|
+
for task in tasks:
|
|
49
|
+
task.split = normalize_legacy_split(task.split or "train")
|
|
50
|
+
if len(tasks) >= 2 and not any(task.split in {"val", "test"} for task in tasks):
|
|
51
|
+
tasks = assign_splits(tasks, holdout_fraction=holdout_fraction, seed=seed)
|
|
52
|
+
return tasks
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_tasks_file(
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
holdout_fraction: float = 0.34,
|
|
59
|
+
seed: int = 42,
|
|
60
|
+
) -> Tuple[List[TaskRecord], Dict[str, Any]]:
|
|
61
|
+
source = os.path.abspath(os.path.expanduser(path))
|
|
62
|
+
with open(source, encoding="utf-8") as f:
|
|
63
|
+
payload = json.load(f)
|
|
64
|
+
if isinstance(payload, list):
|
|
65
|
+
meta: Dict[str, Any] = {"format": "skillopt_sleep.tasks.v1", "tasks_file": source}
|
|
66
|
+
raw_tasks = payload
|
|
67
|
+
elif isinstance(payload, dict):
|
|
68
|
+
meta = {k: v for k, v in payload.items() if k != "tasks"}
|
|
69
|
+
meta["tasks_file"] = source
|
|
70
|
+
raw_tasks = payload.get("tasks", [])
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError("tasks file must contain a JSON object with tasks or a JSON task array")
|
|
73
|
+
if not isinstance(raw_tasks, list):
|
|
74
|
+
raise ValueError("tasks file field 'tasks' must be an array")
|
|
75
|
+
|
|
76
|
+
tasks: List[TaskRecord] = []
|
|
77
|
+
for item in raw_tasks:
|
|
78
|
+
if not isinstance(item, dict):
|
|
79
|
+
raise ValueError("each task entry must be a JSON object")
|
|
80
|
+
tasks.append(TaskRecord.from_dict(item))
|
|
81
|
+
return _normalize_tasks(tasks, holdout_fraction=holdout_fraction, seed=seed), meta
|