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.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- 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.
|