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.
Files changed (64) hide show
  1. package/README.md +22 -11
  2. package/bin/cli.js +1 -0
  3. package/package.json +1 -1
  4. package/template/skills/ponytail/SKILL.md +133 -0
  5. package/template/skills/ponytail/scripts/hooks/claude-codex-hooks.json +44 -0
  6. package/template/skills/ponytail/scripts/hooks/copilot-hooks.json +21 -0
  7. package/template/skills/ponytail/scripts/hooks/ponytail-activate.js +91 -0
  8. package/template/skills/ponytail/scripts/hooks/ponytail-config.js +122 -0
  9. package/template/skills/ponytail/scripts/hooks/ponytail-instructions.js +94 -0
  10. package/template/skills/ponytail/scripts/hooks/ponytail-mode-tracker.js +55 -0
  11. package/template/skills/ponytail/scripts/hooks/ponytail-runtime.js +68 -0
  12. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.ps1 +21 -0
  13. package/template/skills/ponytail/scripts/hooks/ponytail-statusline.sh +12 -0
  14. package/template/skills/ponytail/scripts/hooks/ponytail-subagent.js +22 -0
  15. package/template/skills/ponytail/scripts/mcp/README.md +46 -0
  16. package/template/skills/ponytail/scripts/mcp/index.js +48 -0
  17. package/template/skills/ponytail/scripts/mcp/instructions.js +26 -0
  18. package/template/skills/ponytail/scripts/mcp/package.json +13 -0
  19. package/template/skills/ponytail/scripts/mcp/test/instructions.test.js +22 -0
  20. package/template/skills/ponytail-audit/SKILL.md +41 -0
  21. package/template/skills/ponytail-debt/SKILL.md +44 -0
  22. package/template/skills/ponytail-gain/SKILL.md +50 -0
  23. package/template/skills/ponytail-help/SKILL.md +69 -0
  24. package/template/skills/ponytail-review/SKILL.md +57 -0
  25. package/template/skills/skillopt-sleep/SKILL.md +48 -34
  26. package/template/skills/skillopt-sleep/scripts/python/__init__.py +20 -0
  27. package/template/skills/skillopt-sleep/scripts/python/__main__.py +343 -0
  28. package/template/skills/skillopt-sleep/scripts/python/backend.py +1371 -0
  29. package/template/skills/skillopt-sleep/scripts/python/budget.py +75 -0
  30. package/template/skills/skillopt-sleep/scripts/python/config.py +162 -0
  31. package/template/skills/skillopt-sleep/scripts/python/consolidate.py +238 -0
  32. package/template/skills/skillopt-sleep/scripts/python/cycle.py +291 -0
  33. package/template/skills/skillopt-sleep/scripts/python/dream.py +138 -0
  34. package/template/skills/skillopt-sleep/scripts/python/experiments/__init__.py +1 -0
  35. package/template/skills/skillopt-sleep/scripts/python/experiments/gbrain_bench.py +119 -0
  36. package/template/skills/skillopt-sleep/scripts/python/experiments/personas.py +86 -0
  37. package/template/skills/skillopt-sleep/scripts/python/experiments/report.py +132 -0
  38. package/template/skills/skillopt-sleep/scripts/python/experiments/run_experiment.py +178 -0
  39. package/template/skills/skillopt-sleep/scripts/python/experiments/run_gbrain.py +209 -0
  40. package/template/skills/skillopt-sleep/scripts/python/experiments/run_transfer.py +155 -0
  41. package/template/skills/skillopt-sleep/scripts/python/experiments/sweep.py +164 -0
  42. package/template/skills/skillopt-sleep/scripts/python/gate.py +50 -0
  43. package/template/skills/skillopt-sleep/scripts/python/harvest.py +304 -0
  44. package/template/skills/skillopt-sleep/scripts/python/harvest_codex.py +253 -0
  45. package/template/skills/skillopt-sleep/scripts/python/harvest_sources.py +41 -0
  46. package/template/skills/skillopt-sleep/scripts/python/judges.py +84 -0
  47. package/template/skills/skillopt-sleep/scripts/python/llm_miner.py +134 -0
  48. package/template/skills/skillopt-sleep/scripts/python/memory.py +129 -0
  49. package/template/skills/skillopt-sleep/scripts/python/mine.py +312 -0
  50. package/template/skills/skillopt-sleep/scripts/python/replay.py +146 -0
  51. package/template/skills/skillopt-sleep/scripts/python/rollout.py +153 -0
  52. package/template/skills/skillopt-sleep/scripts/python/scheduler.py +138 -0
  53. package/template/skills/skillopt-sleep/scripts/python/slow_update.py +142 -0
  54. package/template/skills/skillopt-sleep/scripts/python/staging.py +103 -0
  55. package/template/skills/skillopt-sleep/scripts/python/state.py +96 -0
  56. package/template/skills/skillopt-sleep/scripts/python/tasks_file.py +81 -0
  57. package/template/skills/skillopt-sleep/scripts/python/types.py +146 -0
  58. package/template/skills/skillopt-sleep/scripts/shell/__init__.py +0 -0
  59. package/template/skills/skillopt-sleep/scripts/shell/eval_only.py +466 -0
  60. package/template/skills/skillopt-sleep/scripts/shell/materialize_searchqa.py +148 -0
  61. package/template/skills/skillopt-sleep/scripts/shell/run_alfworld.sh +60 -0
  62. package/template/skills/skillopt-sleep/scripts/shell/run_searchqa.sh +40 -0
  63. package/template/skills/skillopt-sleep/scripts/shell/run_spreadsheetbench.sh +39 -0
  64. 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