muscle-memory 0.2.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.
@@ -0,0 +1,24 @@
1
+ """muscle-memory: procedural memory for coding agents."""
2
+
3
+ from muscle_memory.models import (
4
+ Episode,
5
+ Maturity,
6
+ Outcome,
7
+ Scope,
8
+ Skill,
9
+ ToolCall,
10
+ Trajectory,
11
+ )
12
+
13
+ __version__ = "0.2.0"
14
+
15
+ __all__ = [
16
+ "Episode",
17
+ "Maturity",
18
+ "Outcome",
19
+ "Scope",
20
+ "Skill",
21
+ "ToolCall",
22
+ "Trajectory",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,6 @@
1
+ """Enables `python -m muscle_memory ...` to dispatch to the CLI."""
2
+
3
+ from muscle_memory.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,171 @@
1
+ """Seed the skill store from existing Claude Code session history.
2
+
3
+ Claude Code stores session JSONLs under `~/.claude/projects/<encoded-path>/`.
4
+ We walk those files, parse each into a Trajectory, infer outcomes,
5
+ and run the extractor to produce candidate Skills.
6
+
7
+ This lets new users get value from muscle-memory on day one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime, timedelta
14
+ from pathlib import Path
15
+
16
+ from muscle_memory.config import Config
17
+ from muscle_memory.db import Store
18
+ from muscle_memory.dedup import add_skill_with_dedup
19
+ from muscle_memory.embeddings import Embedder
20
+ from muscle_memory.extractor import ExtractionError, Extractor
21
+ from muscle_memory.hooks.stop import parse_transcript
22
+ from muscle_memory.llm import LLM
23
+ from muscle_memory.models import Episode, Skill
24
+ from muscle_memory.outcomes import infer_outcome
25
+
26
+
27
+ @dataclass
28
+ class BootstrapReport:
29
+ sessions_considered: int = 0
30
+ sessions_parsed: int = 0
31
+ episodes_added: int = 0
32
+ skills_extracted: int = 0
33
+ errors: list[str] = field(default_factory=list)
34
+ aborted_reason: str | None = None
35
+
36
+
37
+ def find_session_files(
38
+ *,
39
+ project_path: Path | None = None,
40
+ since: datetime | None = None,
41
+ claude_home: Path | None = None,
42
+ ) -> list[Path]:
43
+ """Return JSONL session files, optionally scoped to a project and time window.
44
+
45
+ Claude Code encodes project paths in the directory name by replacing
46
+ slashes with dashes. We detect that encoding if `project_path` is
47
+ given, otherwise we pick up every project.
48
+ """
49
+ root = claude_home or (Path.home() / ".claude" / "projects")
50
+ if not root.exists():
51
+ return []
52
+
53
+ if project_path is not None:
54
+ encoded = str(project_path.resolve()).replace("/", "-")
55
+ if encoded.startswith("-"):
56
+ encoded = encoded # already starts with - for absolute paths
57
+ candidates = [root / encoded]
58
+ # also try with leading dash (some versions include it)
59
+ alt = root / ("-" + encoded if not encoded.startswith("-") else encoded[1:])
60
+ candidates.append(alt)
61
+ dirs = [p for p in candidates if p.exists() and p.is_dir()]
62
+ else:
63
+ dirs = [p for p in root.iterdir() if p.is_dir()]
64
+
65
+ files: list[Path] = []
66
+ for d in dirs:
67
+ for jsonl in d.glob("*.jsonl"):
68
+ if since is not None:
69
+ mtime = datetime.fromtimestamp(jsonl.stat().st_mtime, tz=UTC)
70
+ if mtime < since:
71
+ continue
72
+ files.append(jsonl)
73
+
74
+ files.sort(key=lambda p: p.stat().st_mtime)
75
+ return files
76
+
77
+
78
+ def bootstrap(
79
+ *,
80
+ config: Config,
81
+ store: Store,
82
+ embedder: Embedder,
83
+ llm: LLM,
84
+ days: int | None = 30,
85
+ project_only: bool = True,
86
+ max_sessions: int = 200,
87
+ ) -> BootstrapReport:
88
+ report = BootstrapReport()
89
+
90
+ since: datetime | None = None
91
+ if days is not None:
92
+ since = datetime.now(UTC) - timedelta(days=days)
93
+
94
+ project_path = config.project_root if project_only else None
95
+ files = find_session_files(
96
+ project_path=project_path,
97
+ since=since,
98
+ )
99
+ files = files[-max_sessions:]
100
+
101
+ extractor = Extractor(llm, config)
102
+
103
+ for path in files:
104
+ report.sessions_considered += 1
105
+ try:
106
+ trajectory = parse_transcript(path)
107
+ except Exception as e: # noqa: BLE001
108
+ report.errors.append(f"{path.name}: parse failed: {e}")
109
+ continue
110
+
111
+ if not trajectory.tool_calls:
112
+ continue
113
+ report.sessions_parsed += 1
114
+
115
+ # Bootstrap has no activation sidecars — it's processing historical
116
+ # sessions from before muscle-memory was installed. Falls back to
117
+ # keyword-only heuristic.
118
+ signal = infer_outcome(trajectory)
119
+
120
+ episode = Episode(
121
+ session_id=path.stem,
122
+ user_prompt=trajectory.user_prompt or "(bootstrap)",
123
+ trajectory=trajectory,
124
+ outcome=signal.outcome,
125
+ reward=signal.reward,
126
+ project_path=str(config.project_root) if config.project_root else None,
127
+ )
128
+ try:
129
+ store.add_episode(episode)
130
+ report.episodes_added += 1
131
+ except Exception as e: # noqa: BLE001
132
+ report.errors.append(f"{path.name}: episode insert failed: {e}")
133
+ continue
134
+
135
+ try:
136
+ skills = extractor.extract(episode)
137
+ except ExtractionError as e:
138
+ # Surface LLM errors loudly. If the first one fails for
139
+ # auth/credit/quota reasons, abort — every subsequent call
140
+ # will fail the same way and waste time.
141
+ msg = str(e)
142
+ report.errors.append(f"{path.name}: extraction failed: {msg}")
143
+ if _looks_fatal(msg) or report.skills_extracted == 0:
144
+ report.aborted_reason = msg
145
+ return report
146
+ continue
147
+
148
+ for skill in skills:
149
+ added, _existing = add_skill_with_dedup(store, embedder, skill)
150
+ if added:
151
+ report.skills_extracted += 1
152
+
153
+ return report
154
+
155
+
156
+ def _looks_fatal(msg: str) -> bool:
157
+ """Heuristic: does this LLM error look like it'll keep happening?"""
158
+ low = msg.lower()
159
+ fatal_markers = (
160
+ "credit",
161
+ "quota",
162
+ "billing",
163
+ "unauthorized",
164
+ "authentication",
165
+ "api key",
166
+ "not found", # wrong model name
167
+ "invalid_request_error",
168
+ )
169
+ return any(m in low for m in fatal_markers)
170
+
171
+