rockycode 0.1.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.
Files changed (83) hide show
  1. rockycode/__init__.py +1 -0
  2. rockycode/banner.py +37 -0
  3. rockycode/cli.py +1386 -0
  4. rockycode/config.py +178 -0
  5. rockycode/dream/__init__.py +9 -0
  6. rockycode/dream/core.py +523 -0
  7. rockycode/dream/judge.py +134 -0
  8. rockycode/dream/mining.py +152 -0
  9. rockycode/dream/proposals.py +440 -0
  10. rockycode/engine/__init__.py +10 -0
  11. rockycode/engine/artifact.py +367 -0
  12. rockycode/engine/budget.py +90 -0
  13. rockycode/engine/checks.py +157 -0
  14. rockycode/engine/compaction.py +181 -0
  15. rockycode/engine/container.py +225 -0
  16. rockycode/engine/effort.py +46 -0
  17. rockycode/engine/events.py +101 -0
  18. rockycode/engine/explore.py +592 -0
  19. rockycode/engine/goal.py +541 -0
  20. rockycode/engine/goal_review.py +161 -0
  21. rockycode/engine/goal_session.py +259 -0
  22. rockycode/engine/headless.py +481 -0
  23. rockycode/engine/loop.py +711 -0
  24. rockycode/engine/lsp.py +473 -0
  25. rockycode/engine/mcp.py +364 -0
  26. rockycode/engine/modes.py +123 -0
  27. rockycode/engine/outcome.py +81 -0
  28. rockycode/engine/permission.py +198 -0
  29. rockycode/engine/planmode.py +249 -0
  30. rockycode/engine/providers.py +196 -0
  31. rockycode/engine/redact.py +83 -0
  32. rockycode/engine/safety.py +139 -0
  33. rockycode/engine/sandbox.py +219 -0
  34. rockycode/engine/server.py +431 -0
  35. rockycode/engine/skills.py +178 -0
  36. rockycode/engine/titler.py +46 -0
  37. rockycode/engine/tools.py +479 -0
  38. rockycode/engine/trajectory.py +131 -0
  39. rockycode/engine/web.py +431 -0
  40. rockycode/engine/worktree.py +128 -0
  41. rockycode/memory/__init__.py +7 -0
  42. rockycode/memory/index.py +260 -0
  43. rockycode/memory/store.py +331 -0
  44. rockycode/modes/learn/learn.md +46 -0
  45. rockycode/modes/research/deep-research.md +53 -0
  46. rockycode/modes/research/paper-reading.md +49 -0
  47. rockycode/modes/research/prove.md +60 -0
  48. rockycode/modes/research/whiteboard.md +64 -0
  49. rockycode/onboarding.py +332 -0
  50. rockycode/palette.py +15 -0
  51. rockycode/pricing.py +178 -0
  52. rockycode/prompts/__init__.py +0 -0
  53. rockycode/prompts/rocky.py +257 -0
  54. rockycode/routines.py +287 -0
  55. rockycode/runners/__init__.py +0 -0
  56. rockycode/runners/agent.py +273 -0
  57. rockycode/runners/data.py +61 -0
  58. rockycode/runners/raw.py +176 -0
  59. rockycode/score.py +114 -0
  60. rockycode/session.py +298 -0
  61. rockycode/skills/architecture-viz/SKILL.md +71 -0
  62. rockycode/skills/architecture-viz/template.html +87 -0
  63. rockycode/skills/lean-prover/SKILL.md +155 -0
  64. rockycode/skills/lean-prover/torchlean-api.md +85 -0
  65. rockycode/tui/__init__.py +1 -0
  66. rockycode/tui/app.py +2450 -0
  67. rockycode/tui/exitsheet.py +181 -0
  68. rockycode/tui/goal_screen.py +315 -0
  69. rockycode/tui/mdterm.py +232 -0
  70. rockycode/tui/mdview.py +99 -0
  71. rockycode/tui/modepicker.py +103 -0
  72. rockycode/tui/permission.py +154 -0
  73. rockycode/tui/plangate.py +110 -0
  74. rockycode/tui/prompt_history.py +77 -0
  75. rockycode/tui/proposalcard.py +126 -0
  76. rockycode/tui/resume.py +142 -0
  77. rockycode/tui/rocky_pet.py +96 -0
  78. rockycode/tui/routinecard.py +123 -0
  79. rockycode-0.1.0.dist-info/METADATA +488 -0
  80. rockycode-0.1.0.dist-info/RECORD +83 -0
  81. rockycode-0.1.0.dist-info/WHEEL +4 -0
  82. rockycode-0.1.0.dist-info/entry_points.txt +2 -0
  83. rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,260 @@
1
+ """Semantic memory index: Ollama embeddings in sqlite-vec, FTS5 keyword hybrid.
2
+
3
+ `index.db` is a disposable cache derived from the markdown files — delete it
4
+ any time, the next search rebuilds it. The files stay the truth (M0 rule).
5
+
6
+ Two embedding SPACES, every document in both (user setup, verified live
7
+ 2026-06-12 — including the cross-lingual case):
8
+ - "en" space: nomic-embed-text (768d) — the precision space for
9
+ English↔English. REQUIRES `search_document:` / `search_query:` task
10
+ prefixes; without them quality silently degrades.
11
+ - "zh" space: qwen3-embedding:0.6b (1024d) — multilingual, so a Chinese
12
+ query matches an English memory here and vice versa.
13
+
14
+ Storing docs in one language-routed table would break exactly the user's
15
+ real pattern (asking in Chinese about English code facts), so language
16
+ detection only steers query-side weights: English queries trust the nomic
17
+ space first; Chinese queries search only the qwen space (nomic cannot embed
18
+ CJK meaningfully). FTS5 keyword hits join the rank fusion in both cases.
19
+
20
+ Degradation ladder — search never throws at the caller:
21
+ Ollama down → FTS5 keyword search only
22
+ sqlite-vec won't load → IndexUnavailable at construction; callers fall
23
+ back to the store's substring search (M0 path)
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import hashlib
29
+ import os
30
+ import re
31
+ import sqlite3
32
+ import struct
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ from rockycode.memory.store import Memory, MemoryStore
37
+
38
+ OLLAMA_URL = os.getenv("ROCKYCODE_OLLAMA_URL", "http://localhost:11434")
39
+
40
+ # space → (model, dimensions, document prefix, query prefix)
41
+ EMBED_MODELS = {
42
+ "en": ("nomic-embed-text", 768, "search_document: ", "search_query: "),
43
+ "zh": ("qwen3-embedding:0.6b", 1024, "", ""),
44
+ }
45
+ # query language → {space: RRF weight}; FTS5 weight applies to both
46
+ SPACE_WEIGHTS = {
47
+ "en": {"en": 1.0, "zh": 0.8},
48
+ "zh": {"zh": 1.0}, # no nomic for CJK queries
49
+ }
50
+ FTS_WEIGHT = 0.8
51
+ MAX_EMBED_CHARS = 4_000
52
+ RRF_K = 60 # standard reciprocal-rank-fusion constant
53
+
54
+ _CJK = re.compile(r"[぀-ヿ㐀-䶿一-鿿豈-﫿]")
55
+
56
+
57
+ class IndexUnavailable(RuntimeError):
58
+ """sqlite-vec could not be loaded; semantic search is off."""
59
+
60
+
61
+ def detect_lang(text: str) -> str:
62
+ return "zh" if len(_CJK.findall(text[:2000])) >= 2 else "en"
63
+
64
+
65
+ def _fts_norm(text: str) -> str:
66
+ """Space out CJK characters so FTS5's unicode61 tokenizer can index them
67
+ individually — otherwise a run like 界面颜色只用十六进制 is one opaque
68
+ token and 颜色 never matches. Queries phrase-match adjacent chars."""
69
+ return _CJK.sub(lambda m: f" {m.group(0)} ", text)
70
+
71
+
72
+ def _pack(vec: list[float]) -> bytes:
73
+ return struct.pack(f"{len(vec)}f", *vec)
74
+
75
+
76
+ def _doc_text(mem: Memory) -> str:
77
+ return f"{mem.name}\n{mem.description}\n{mem.body}"[:MAX_EMBED_CHARS]
78
+
79
+
80
+ def _doc_hash(mem: Memory) -> str:
81
+ return hashlib.sha256(_doc_text(mem).encode()).hexdigest()[:16]
82
+
83
+
84
+ class MemoryIndex:
85
+ def __init__(
86
+ self,
87
+ store: MemoryStore,
88
+ db_path: Optional[Path] = None,
89
+ client=None, # AsyncOpenAI-compatible; tests inject a fake
90
+ ) -> None:
91
+ self.store = store
92
+ self.db_path = db_path or (store.root / ".." / "index.db").resolve()
93
+ if client is None:
94
+ from openai import AsyncOpenAI
95
+
96
+ client = AsyncOpenAI(base_url=f"{OLLAMA_URL}/v1", api_key="ollama", max_retries=0, timeout=60.0)
97
+ self.client = client
98
+ self._conn: Optional[sqlite3.Connection] = None
99
+
100
+ def conn(self) -> sqlite3.Connection:
101
+ if self._conn is not None:
102
+ return self._conn
103
+ try:
104
+ import sqlite_vec
105
+ except ImportError as e:
106
+ raise IndexUnavailable(f"sqlite-vec not installed: {e}") from e
107
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
108
+ conn = sqlite3.connect(self.db_path)
109
+ try:
110
+ conn.enable_load_extension(True)
111
+ sqlite_vec.load(conn)
112
+ conn.enable_load_extension(False)
113
+ except (AttributeError, sqlite3.OperationalError) as e:
114
+ conn.close()
115
+ raise IndexUnavailable(f"could not load sqlite-vec extension: {e}") from e
116
+ conn.executescript(
117
+ f"""
118
+ CREATE TABLE IF NOT EXISTS mem(
119
+ name TEXT PRIMARY KEY, lang TEXT, hash TEXT, description TEXT);
120
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_en USING vec0(emb float[{EMBED_MODELS['en'][1]}]);
121
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_zh USING vec0(emb float[{EMBED_MODELS['zh'][1]}]);
122
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(name, description, body);
123
+ """
124
+ )
125
+ self._conn = conn
126
+ return conn
127
+
128
+ async def _embed(self, lang: str, texts: list[str], *, query: bool = False) -> list[list[float]]:
129
+ model, _, doc_prefix, query_prefix = EMBED_MODELS[lang]
130
+ prefix = query_prefix if query else doc_prefix
131
+ resp = await self.client.embeddings.create(model=model, input=[prefix + t for t in texts])
132
+ return [d.embedding for d in resp.data]
133
+
134
+ def _rowid(self, conn: sqlite3.Connection, name: str) -> Optional[int]:
135
+ row = conn.execute("SELECT rowid FROM mem WHERE name = ?", (name,)).fetchone()
136
+ return row[0] if row else None
137
+
138
+ def _remove(self, conn: sqlite3.Connection, name: str) -> None:
139
+ rowid = self._rowid(conn, name)
140
+ if rowid is None:
141
+ return
142
+ for table in ("vec_en", "vec_zh", "fts", "mem"):
143
+ conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
144
+
145
+ async def reindex(self, force: bool = False) -> tuple[int, int, int]:
146
+ """Sync index.db with the markdown files. Returns (indexed, kept, removed).
147
+
148
+ Hash comparison makes the no-change case a few milliseconds, so this
149
+ runs before every search — the index is always fresh, no manual step.
150
+ """
151
+ conn = self.conn()
152
+ memories = [m for m in self.store.load_all() if m.status == "active"]
153
+ seen = {m.name for m in memories}
154
+
155
+ stale = [
156
+ name for (name,) in conn.execute("SELECT name FROM mem").fetchall()
157
+ if name not in seen
158
+ ]
159
+ for name in stale:
160
+ self._remove(conn, name)
161
+
162
+ pending: list[Memory] = []
163
+ kept = 0
164
+ for mem in memories:
165
+ row = conn.execute("SELECT hash FROM mem WHERE name = ?", (mem.name,)).fetchone()
166
+ if not force and row is not None and row[0] == _doc_hash(mem):
167
+ kept += 1
168
+ continue
169
+ pending.append(mem)
170
+
171
+ if pending:
172
+ texts = [_doc_text(m) for m in pending]
173
+ # every doc goes into BOTH spaces — cross-lingual recall depends on it
174
+ vectors = {space: await self._embed(space, texts) for space in EMBED_MODELS}
175
+ for i, mem in enumerate(pending):
176
+ self._remove(conn, mem.name)
177
+ cur = conn.execute(
178
+ "INSERT INTO mem(name, lang, hash, description) VALUES (?, ?, ?, ?)",
179
+ (mem.name, detect_lang(_doc_text(mem)), _doc_hash(mem), mem.description),
180
+ )
181
+ rowid = cur.lastrowid
182
+ for space in EMBED_MODELS:
183
+ conn.execute(
184
+ f"INSERT INTO vec_{space}(rowid, emb) VALUES (?, ?)",
185
+ (rowid, _pack(vectors[space][i])),
186
+ )
187
+ conn.execute(
188
+ "INSERT INTO fts(rowid, name, description, body) VALUES (?, ?, ?, ?)",
189
+ (rowid, mem.name, _fts_norm(mem.description), _fts_norm(mem.body)),
190
+ )
191
+ conn.commit()
192
+ return len(pending), kept, len(stale)
193
+
194
+ def _fts_names(self, query: str, k: int) -> list[str]:
195
+ # CJK terms become phrase queries over their spaced-out chars
196
+ # ("颜色" → '"颜 色"'), matching how _fts_norm indexed them.
197
+ terms = " OR ".join(
198
+ f'"{" ".join(_fts_norm(t).split())}"' for t in re.findall(r"\w+", query)[:12]
199
+ )
200
+ if not terms:
201
+ return []
202
+ try:
203
+ rows = self.conn().execute(
204
+ "SELECT m.name FROM fts JOIN mem m ON m.rowid = fts.rowid "
205
+ "WHERE fts MATCH ? ORDER BY rank LIMIT ?",
206
+ (terms, k),
207
+ ).fetchall()
208
+ except sqlite3.OperationalError:
209
+ return []
210
+ return [r[0] for r in rows]
211
+
212
+ async def search(self, query: str, k: int = 5) -> list[tuple[Memory, float]]:
213
+ """Hybrid search: both vector tables + FTS5, merged by RRF.
214
+
215
+ Ollama being down degrades to keyword-only; this never raises for
216
+ anything but IndexUnavailable (no sqlite-vec at all).
217
+ """
218
+ conn = self.conn()
219
+ try:
220
+ await self.reindex()
221
+ except Exception: # noqa: BLE001 — embedding refresh is best-effort
222
+ pass
223
+
224
+ # Weighted RRF across spaces. Weights are query-language-dependent
225
+ # (SPACE_WEIGHTS): KNN always returns k rows however distant, so the
226
+ # less trustworthy space must not be able to outvote the primary one.
227
+ rankings: list[tuple[float, list[str]]] = []
228
+ for space, weight in SPACE_WEIGHTS[detect_lang(query)].items():
229
+ try:
230
+ qvec = (await self._embed(space, [query[:MAX_EMBED_CHARS]], query=True))[0]
231
+ except Exception: # noqa: BLE001 — Ollama down → keyword only
232
+ continue
233
+ rows = conn.execute(
234
+ f"SELECT m.name FROM vec_{space} v JOIN mem m ON m.rowid = v.rowid "
235
+ "WHERE v.emb MATCH ? AND k = ? ORDER BY distance",
236
+ (_pack(qvec), k),
237
+ ).fetchall()
238
+ rankings.append((weight, [r[0] for r in rows]))
239
+
240
+ rankings.append((FTS_WEIGHT, self._fts_names(query, k)))
241
+
242
+ scores: dict[str, float] = {}
243
+ for weight, ranking in rankings:
244
+ for rank, name in enumerate(ranking):
245
+ scores[name] = scores.get(name, 0.0) + weight / (RRF_K + rank)
246
+
247
+ out: list[tuple[Memory, float]] = []
248
+ for name, score in sorted(scores.items(), key=lambda kv: -kv[1])[:k]:
249
+ mem = self.store.get(name)
250
+ if mem is not None and mem.status == "active":
251
+ out.append((mem, score))
252
+ return out
253
+
254
+
255
+ def search_sync(index: MemoryIndex, query: str, k: int = 5) -> list[tuple[Memory, float]]:
256
+ return asyncio.run(index.search(query, k))
257
+
258
+
259
+ def reindex_sync(index: MemoryIndex, force: bool = False) -> tuple[int, int, int]:
260
+ return asyncio.run(index.reindex(force=force))
@@ -0,0 +1,331 @@
1
+ """Memory M0: plain markdown files, progressive disclosure, archive-not-delete.
2
+
3
+ Design: docs/memory-dream.md. The two rules that matter:
4
+
5
+ 1. Markdown is the source of truth. One memory per file under
6
+ .rockycode/memory/<type-dir>/, lenient `key: value` frontmatter (no YAML
7
+ dependency, same parser style as skills.py). The user can read, edit,
8
+ git-track, or delete any memory with normal tools; future indexes
9
+ (sqlite-vec, M1) are rebuildable caches, never canonical.
10
+ 2. Nothing is ever hard-deleted by rockycode. `rm` moves the file to
11
+ archive/ with status flipped — wrong memories go away, but stay
12
+ auditable.
13
+
14
+ Loading mirrors the skills pattern: MEMORY.md (the hand-curated index) and
15
+ `feedback` memories load fully into the system prompt; everything else gets
16
+ a one-line index entry and is fetched on demand via the `recall_memory`
17
+ tool. Fifty memories cost fifty lines, not fifty files.
18
+
19
+ Chat-only, like skills and MCP: bench never loads memory — cross-task
20
+ memory contaminates SWE-bench scores (see design doc §4).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from rockycode.engine.tools import Tool, _truncate
31
+
32
+ MEMORY_DIR = Path(".rockycode") / "memory"
33
+ INDEX_FILE = "MEMORY.md"
34
+
35
+ TYPE_DIRS = {
36
+ "fact": "facts", "skill": "skills", "episode": "episodes",
37
+ "feedback": "feedback", "weakness": "weaknesses",
38
+ }
39
+ ARCHIVE_DIR = "archive"
40
+
41
+ MAX_INDEX_CHARS = 8_000 # MEMORY.md cap in the system prompt
42
+ MAX_FEEDBACK_CHARS = 1_000 # per feedback memory in the system prompt
43
+ MAX_DESCRIPTION_CHARS = 150
44
+
45
+ _FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
46
+ _LIST_FIELDS = {"evidence", "triggers"}
47
+
48
+
49
+ @dataclass
50
+ class Memory:
51
+ name: str
52
+ type: str = "fact" # fact | skill | episode | feedback | weakness
53
+ description: str = "" # one-liner shown in the index
54
+ importance: int = 5 # 1–10
55
+ status: str = "active" # active | archived
56
+ origin: str = "user" # user | agent | dream
57
+ created: str = "" # YYYY-MM-DD
58
+ evidence: list[str] = field(default_factory=list) # trajectory session ids
59
+ triggers: list[str] = field(default_factory=list) # globs/keywords (used from M1)
60
+ body: str = ""
61
+ path: Optional[Path] = None
62
+
63
+
64
+ def _slugify(text: str, max_len: int = 48) -> str:
65
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
66
+ return slug[:max_len].rstrip("-") or "memory"
67
+
68
+
69
+ def _parse_list(value: str) -> list[str]:
70
+ inner = value.strip().strip("[]")
71
+ return [v.strip().strip("'\"") for v in inner.split(",") if v.strip().strip("'\"")]
72
+
73
+
74
+ def parse_memory(text: str, path: Optional[Path] = None) -> Memory:
75
+ """Lenient parse; a file with no frontmatter is still a valid memory."""
76
+ m = _FRONTMATTER.match(text)
77
+ fields: dict[str, str] = {}
78
+ body = text
79
+ if m:
80
+ for line in m.group(1).splitlines():
81
+ if ":" in line and not line.startswith((" ", "\t", "#")):
82
+ key, _, value = line.partition(":")
83
+ fields[key.strip().lower()] = value.strip()
84
+ body = text[m.end():]
85
+ body = body.strip()
86
+
87
+ def clean(key: str, default: str = "") -> str:
88
+ return fields.get(key, default).strip("'\"")
89
+
90
+ try:
91
+ importance = max(1, min(10, int(clean("importance", "5"))))
92
+ except ValueError:
93
+ importance = 5
94
+
95
+ first_line = body.splitlines()[0].strip() if body else ""
96
+ return Memory(
97
+ name=clean("name") or (path.stem if path else _slugify(first_line)),
98
+ type=clean("type") if clean("type") in TYPE_DIRS else "fact",
99
+ description=(clean("description") or first_line)[:MAX_DESCRIPTION_CHARS],
100
+ importance=importance,
101
+ status=clean("status") or "active",
102
+ origin=clean("origin") or "user",
103
+ created=clean("created"),
104
+ evidence=_parse_list(fields.get("evidence", "")),
105
+ triggers=_parse_list(fields.get("triggers", "")),
106
+ body=body,
107
+ path=path,
108
+ )
109
+
110
+
111
+ def to_markdown(mem: Memory) -> str:
112
+ lines = [
113
+ "---",
114
+ f"name: {mem.name}",
115
+ f"type: {mem.type}",
116
+ f"description: {mem.description}",
117
+ f"importance: {mem.importance}",
118
+ f"status: {mem.status}",
119
+ f"origin: {mem.origin}",
120
+ f"created: {mem.created}",
121
+ f"evidence: [{', '.join(mem.evidence)}]",
122
+ f"triggers: [{', '.join(mem.triggers)}]",
123
+ "---",
124
+ "",
125
+ mem.body.strip(),
126
+ "",
127
+ ]
128
+ return "\n".join(lines)
129
+
130
+
131
+ class MemoryStore:
132
+ """All paths relative to one root: <workdir>/.rockycode/memory."""
133
+
134
+ def __init__(self, root: Path) -> None:
135
+ self.root = root
136
+
137
+ @classmethod
138
+ def for_workdir(cls, workdir: Path) -> "MemoryStore":
139
+ return cls(workdir / MEMORY_DIR)
140
+
141
+ def index_text(self) -> str:
142
+ p = self.root / INDEX_FILE
143
+ try:
144
+ return p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
145
+ except OSError:
146
+ return ""
147
+
148
+ def load_all(self, include_archived: bool = False) -> list[Memory]:
149
+ out: list[Memory] = []
150
+ dirs = list(TYPE_DIRS.values()) + ([ARCHIVE_DIR] if include_archived else [])
151
+ for d in dirs:
152
+ folder = self.root / d
153
+ if not folder.is_dir():
154
+ continue
155
+ for f in sorted(folder.glob("*.md")):
156
+ try:
157
+ mem = parse_memory(f.read_text(encoding="utf-8", errors="replace"), path=f)
158
+ except OSError:
159
+ continue
160
+ if d == ARCHIVE_DIR:
161
+ mem.status = "archived"
162
+ out.append(mem)
163
+ return out
164
+
165
+ def get(self, name: str) -> Optional[Memory]:
166
+ for mem in self.load_all(include_archived=True):
167
+ if mem.name == name:
168
+ return mem
169
+ return None
170
+
171
+ def save(self, mem: Memory) -> Path:
172
+ if not mem.created:
173
+ mem.created = time.strftime("%Y-%m-%d")
174
+ if not mem.name:
175
+ mem.name = _slugify(mem.description or mem.body)
176
+ folder = self.root / TYPE_DIRS.get(mem.type, "facts")
177
+ folder.mkdir(parents=True, exist_ok=True)
178
+ path = folder / f"{_slugify(mem.name)}.md"
179
+ if path.exists() and (mem.path is None or path != mem.path):
180
+ path = folder / f"{_slugify(mem.name)}-{time.strftime('%H%M%S')}.md"
181
+ path.write_text(to_markdown(mem), encoding="utf-8")
182
+ mem.path = path
183
+ return path
184
+
185
+ def archive(self, name: str) -> bool:
186
+ """Move a memory to archive/ — never delete. Returns False if absent."""
187
+ mem = self.get(name)
188
+ if mem is None or mem.path is None or mem.status == "archived":
189
+ return False
190
+ mem.status = "archived"
191
+ archive = self.root / ARCHIVE_DIR
192
+ archive.mkdir(parents=True, exist_ok=True)
193
+ target = archive / mem.path.name
194
+ if target.exists():
195
+ target = archive / f"{mem.path.stem}-{time.strftime('%H%M%S')}.md"
196
+ target.write_text(to_markdown(mem), encoding="utf-8")
197
+ mem.path.unlink()
198
+ return True
199
+
200
+ def search(self, query: str) -> list[Memory]:
201
+ """M0: case-insensitive substring over name/description/body.
202
+ M1 replaces this with hybrid sqlite-vec + FTS search."""
203
+ q = query.lower()
204
+ return [
205
+ m for m in self.load_all()
206
+ if q in m.name.lower() or q in m.description.lower() or q in m.body.lower()
207
+ ]
208
+
209
+
210
+ # ---- system prompt + tools ---------------------------------------------------
211
+
212
+ def memory_prompt_section(store: MemoryStore) -> str:
213
+ """MEMORY.md + feedback fully; everything else as index lines."""
214
+ parts: list[str] = []
215
+
216
+ index = store.index_text().strip()
217
+ if index:
218
+ parts.append(f"# Project memory (MEMORY.md)\n\n{index[:MAX_INDEX_CHARS]}")
219
+
220
+ memories = [m for m in store.load_all() if m.status == "active"]
221
+ feedback = [m for m in memories if m.type == "feedback"]
222
+ others = [m for m in memories if m.type != "feedback"]
223
+
224
+ if feedback:
225
+ notes = "\n\n".join(f"- {m.body[:MAX_FEEDBACK_CHARS]}" for m in feedback)
226
+ parts.append(f"# User feedback (always follow)\n\n{notes}")
227
+
228
+ if others:
229
+ lines = [f"- {m.name} [{m.type}] — {m.description}" for m in others]
230
+ parts.append(
231
+ "# Memories available\n\n"
232
+ "Knowledge from past sessions. When one looks relevant, call the "
233
+ "`recall_memory` tool with its name — or a free-text `query` to "
234
+ "search by meaning — before relying on it.\n\n"
235
+ + "\n".join(lines)
236
+ )
237
+
238
+ if not parts:
239
+ return ""
240
+ return "\n\n" + "\n\n".join(parts)
241
+
242
+
243
+ def build_memory_tools(store: MemoryStore, index=None) -> list[Tool]:
244
+ """`index` is a memory.index.MemoryIndex for semantic recall (M1);
245
+ None keeps the M0 exact-name behavior with substring fallback."""
246
+ recall_schema = {
247
+ "type": "function",
248
+ "function": {
249
+ "name": "recall_memory",
250
+ "description": (
251
+ "Look up memories. Pass `name` for an exact entry from the "
252
+ "'Memories available' list, or `query` to search by meaning "
253
+ "(English or Chinese). Call this before relying on a memory."
254
+ ),
255
+ "parameters": {
256
+ "type": "object",
257
+ "properties": {
258
+ "name": {"type": "string", "description": "Exact memory name from the index."},
259
+ "query": {"type": "string", "description": "Free-text search when no exact name is known."},
260
+ },
261
+ "required": [],
262
+ },
263
+ },
264
+ }
265
+
266
+ def _render(mem: Memory) -> str:
267
+ note = " (archived — may be outdated or superseded)" if mem.status == "archived" else ""
268
+ return f"# memory: {mem.name} [{mem.type}]{note}\n\n{mem.body}"
269
+
270
+ async def recall(name: str = "", query: str = "") -> str:
271
+ if name:
272
+ mem = store.get(name)
273
+ if mem is None:
274
+ available = ", ".join(m.name for m in store.load_all()) or "(none)"
275
+ return f"[error] no memory named '{name}'. available: {available}"
276
+ return _truncate(_render(mem))
277
+ if not query:
278
+ return "[error] pass either name or query"
279
+ hits: list[Memory] = []
280
+ if index is not None:
281
+ try:
282
+ hits = [m for m, _ in await index.search(query, k=3)]
283
+ except Exception: # noqa: BLE001 — semantic search is best-effort
284
+ hits = []
285
+ if not hits:
286
+ hits = store.search(query)[:3]
287
+ if not hits:
288
+ return f"[error] nothing in memory matches '{query}'"
289
+ return _truncate("\n\n---\n\n".join(_render(m) for m in hits))
290
+
291
+ remember_schema = {
292
+ "type": "function",
293
+ "function": {
294
+ "name": "remember",
295
+ "description": (
296
+ "Save a memory for future sessions: a project fact, a verified how-to, "
297
+ "or user feedback. Keep it one focused fact per call."
298
+ ),
299
+ "parameters": {
300
+ "type": "object",
301
+ "properties": {
302
+ "name": {"type": "string", "description": "Short kebab-case identifier."},
303
+ "content": {"type": "string", "description": "The memory body (markdown)."},
304
+ "type": {
305
+ "type": "string",
306
+ "enum": sorted(TYPE_DIRS),
307
+ "description": "fact (project knowledge), skill (verified how-to), "
308
+ "episode (what happened), feedback (user guidance).",
309
+ },
310
+ "description": {"type": "string", "description": "One-line summary for the index."},
311
+ },
312
+ "required": ["name", "content", "type"],
313
+ },
314
+ },
315
+ }
316
+
317
+ async def remember(name: str, content: str, type: str, description: str = "") -> str:
318
+ mem = Memory(
319
+ name=name,
320
+ type=type if type in TYPE_DIRS else "fact",
321
+ description=description or content.splitlines()[0][:MAX_DESCRIPTION_CHARS],
322
+ body=content,
323
+ origin="agent",
324
+ )
325
+ path = store.save(mem)
326
+ return f"[ok] remembered '{mem.name}' ({mem.type}) at {path}"
327
+
328
+ return [
329
+ Tool(name="recall_memory", schema=recall_schema, fn=recall, risk="safe"),
330
+ Tool(name="remember", schema=remember_schema, fn=remember, risk="moderate"),
331
+ ]
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: learn
3
+ description: tutor posture — teach the user a paper, a codebase, or a concept
4
+ ---
5
+
6
+ For sessions where the goal is the user's understanding, not an output. The
7
+ material can be anything — a paper, an unfamiliar git repo, a concept. Rocky
8
+ teaches; the user absorbs at their own pace.
9
+
10
+ # How you hold this session
11
+
12
+ You are a patient tutor, not a lecturer. The goal is not to look decisive or
13
+ cover everything — it is to keep the material navigable and let understanding
14
+ grow at the right speed. Sit in uncertainty with the user without forcing
15
+ closure.
16
+
17
+ ## The cadence
18
+
19
+ Small steps. Start from what the user already knows — ask if you don't know.
20
+ Explain one idea, then check: a short question, or "does this connect to what
21
+ you expected?" before moving on. Never answer a confusion with a wall of
22
+ text; shrink the step instead. When the user's framing differs from yours,
23
+ work inside THEIR framing first.
24
+
25
+ ## Learning a codebase
26
+
27
+ Walk the repo, don't describe it from memory: `read_file` the actual files,
28
+ quote the actual lines, and always give file paths (they render clickable —
29
+ the user can jump in and look). Trace one real path end to end — an entry
30
+ point, a request, one feature's flow — before generalizing about
31
+ architecture. Diagrams that help can become an artifact (`create_artifact`).
32
+
33
+ ## Learning a paper or concept
34
+
35
+ Build up from the user's current picture; anchor every abstraction in one
36
+ concrete example before naming it. Distinguish "this is in the text" from
37
+ "this is my explanation" — the user should always know which they are
38
+ holding. If a converted markdown of a paper exists (or a conversion skill is
39
+ installed), work from that text so you can quote it exactly.
40
+
41
+ ## Evidence and honesty
42
+
43
+ Label observation, interpretation, and speculation as such. "I don't know —
44
+ let's check" is a fully valid teaching move, and checking together (a quick
45
+ `web_search`, opening the file) models the skill being taught. No retroactive
46
+ certainty; no pretending the lesson plan was always the plan.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: deep-research
3
+ description: survey a topic — scope first, collect broadly, describe papers neutrally
4
+ ---
5
+
6
+ For surveying a research area before forming an argument: a literature review,
7
+ a scoping pass, checking what primary evidence actually exists. The output is
8
+ an evidence map, not a conclusion.
9
+
10
+ # How you hold this session
11
+
12
+ This is careful library work, not an argument trying to finish itself early.
13
+ You are a patient research assistant: accurate, restrained, and consistent —
14
+ never impressive-sounding at the cost of being wrong about a paper.
15
+
16
+ ## Scope before anything
17
+
18
+ Before the collection grows, align three things with the user and keep them
19
+ visible: what question the review collects around, what boundaries are fixed
20
+ for this round, and what output this pass should produce. Scope changes are
21
+ allowed but must be said out loud — never let "KV-cache quantization" silently
22
+ become "everything about long-context inference".
23
+
24
+ ## Collecting
25
+
26
+ Use `web_research` to fan independent queries out in parallel, then `web_fetch`
27
+ to close-read the sources that matter. For recency-sensitive queries, put the
28
+ current year (from the "Today is" line) into the query itself — "X update
29
+ 2026", never "X recent update"; your training prior will otherwise reach for
30
+ an older year. Coverage means covering the chosen
31
+ slice well, not the whole field. Track what is still missing as explicitly as
32
+ what was found — holes in the evidence map are findings too.
33
+
34
+ ## Describing papers
35
+
36
+ Stay close to what each paper is: title, authors, year, venue/status, and what
37
+ it actually studies or does. If you cannot describe a paper's content reliably,
38
+ say so — never fill the gap with plausible-sounding speculation. Resist early
39
+ labels like "core", "strong evidence", or "relevant": those are already acts
40
+ of analysis, and analysis comes only after the collection is stable and only
41
+ if the user asks for it.
42
+
43
+ ## Keep three layers separate
44
+
45
+ What was directly observed (a quote, a result, a number from the paper); what
46
+ you interpret it to mean; and what is speculation. Label the second and third.
47
+ A review that mixes these layers cannot be audited later.
48
+
49
+ ## Organizing
50
+
51
+ Group papers by a simple content axis — topic, method family, task setting.
52
+ Simple classification beats elaborate structure. For a big map, offer a
53
+ markdown table or an artifact (`create_artifact`) the user can keep.