@luizsantiago/spec-guardrails 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  | **Solution** | One kit, two deliberate modes: **Process** (Node only) for a flexible spec-driven workflow; **Brakes** (Node + Python) for the **full product** — structural gates that exit non-zero when paperwork or evidence is missing. You approve specs/tasks in both. |
12
12
  | **Result** | Traceable `.specs/` memory, fewer fake finishes, cheaper turns (~70% less skill text on planning). Choose Process for light ceremony; add Python when you want the [Guarantees matrix](#guarantees-matrix) enforced automatically. |
13
13
 
14
- npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.5.x**
14
+ npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **3.6.x**
15
15
 
16
16
  ---
17
17
 
@@ -217,6 +217,7 @@ npx @luizsantiago/spec-guardrails install
217
217
 
218
218
  | Version | What you gain |
219
219
  | --- | --- |
220
+ | **3.6.x** | Hybrid retrieval (`memory-retrieve`); chunk FTS; optional semantic embed |
220
221
  | **3.5.x** | Solution exploration (`solution-explore`) — compare candidates from approved spec |
221
222
  | **3.4.x** | Contextual guards (`context-guard`); FTS memory search (`memory-search`) |
222
223
  | **3.3.x** | Intent/effect policy — `check-path --op read|write|delete` and `effects` config block |
package/index.js CHANGED
@@ -103,12 +103,16 @@ Commands:
103
103
  execution-policy record-retry <task> Increment retry counter for a task id (blocks at limit)
104
104
  execution-policy record-run Increment agent-run counter (blocks at budget)
105
105
  memory-index rebuild Rebuild SQLite memory index from .specs/ artifacts
106
+ memory-index embed [--force] Optional semantic embeddings (requires config + provider)
106
107
  memory-query --from <id> Bounded context package from the knowledge graph
107
108
  [--depth N] Traversal depth (default 2)
108
109
  [--json] Machine-readable output
109
- memory-search <query> Full-text search over the memory index (FTS5)
110
+ memory-search <query> Full-text search over indexed artifact chunks (FTS5)
110
111
  [--limit N] Max results (default 10)
111
112
  [--json] Machine-readable output
113
+ memory-retrieve "<query>" Hybrid retrieval (FTS + graph + optional semantic)
114
+ [--mode fts|hybrid|semantic] Strategy (default: hybrid)
115
+ [--json] Machine-readable output
112
116
  context-guard status Execute readiness from STATE + tasks.md
113
117
  [--json] Machine-readable output
114
118
  context-guard check-edit <path> Contextual guard before editing a file
package/lib/constants.js CHANGED
@@ -112,6 +112,9 @@ export const SCRIPT_ASSETS = [
112
112
  { file: "memory_index.py", remotePath: "scripts/memory_index.py" },
113
113
  { file: "memory_query.py", remotePath: "scripts/memory_query.py" },
114
114
  { file: "memory_search.py", remotePath: "scripts/memory_search.py" },
115
+ { file: "memory_retrieve.py", remotePath: "scripts/memory_retrieve.py" },
116
+ { file: "_memory_config.py", remotePath: "scripts/_memory_config.py" },
117
+ { file: "_memory_embed.py", remotePath: "scripts/_memory_embed.py" },
115
118
  ];
116
119
 
117
120
  /** @type {{ file: string, remotePath: string }[]} */
package/lib/gates.js CHANGED
@@ -44,6 +44,7 @@ const AUX_SCRIPTS = {
44
44
  "memory-index": "memory_index.py",
45
45
  "memory-query": "memory_query.py",
46
46
  "memory-search": "memory_search.py",
47
+ "memory-retrieve": "memory_retrieve.py",
47
48
  };
48
49
 
49
50
  const GUARDRAILS_SCRIPTS = { ...GATE_SCRIPTS, ...AUX_SCRIPTS };
@@ -16,6 +16,42 @@ export async function rebuildMemoryIndex(options = {}) {
16
16
  return runGuardrailsScript("memory-index", ["rebuild"], { cwd });
17
17
  }
18
18
 
19
+ /**
20
+ * Build optional semantic embeddings for indexed chunks.
21
+ *
22
+ * @param {{ force?: boolean, json?: boolean, cwd?: string }} [options]
23
+ * @returns {Promise<number>} exit code
24
+ */
25
+ export async function embedMemoryIndex(options = {}) {
26
+ const cwd = options.cwd ?? process.cwd();
27
+ const args = ["embed"];
28
+ if (options.force) {
29
+ args.push("--force");
30
+ }
31
+ if (options.json) {
32
+ args.push("--json");
33
+ }
34
+ return runGuardrailsScript("memory-index", args, { cwd });
35
+ }
36
+
37
+ /**
38
+ * Hybrid retrieval over indexed artifacts.
39
+ *
40
+ * @param {{ query: string, mode?: string, json?: boolean, cwd?: string }} options
41
+ * @returns {Promise<number>} exit code
42
+ */
43
+ export async function retrieveMemory(options) {
44
+ const cwd = options.cwd ?? process.cwd();
45
+ const args = [options.query];
46
+ if (options.mode) {
47
+ args.push("--mode", options.mode);
48
+ }
49
+ if (options.json) {
50
+ args.push("--json");
51
+ }
52
+ return runGuardrailsScript("memory-retrieve", args, { cwd });
53
+ }
54
+
19
55
  /**
20
56
  * Query the knowledge graph for a bounded context package.
21
57
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luizsantiago/spec-guardrails",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "Keep AI coding agents honest — specify the work, prove each step, verify independently. Process mode (Node) for flexibility; Brakes mode (Node + Python) for structural gates and a Guarantees matrix. Progressive loading, independent verify — any AI agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,97 @@
1
+ """Shared memory retrieval config parsed from `.specs/config.yaml`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ SPECS_DIR = Path(".specs")
9
+ FEATURES_DIR = SPECS_DIR / "features"
10
+ CONFIG_PATH = SPECS_DIR / "config.yaml"
11
+ MEMORY_DIR = SPECS_DIR / "memory"
12
+ DB_PATH = MEMORY_DIR / "memory.db"
13
+
14
+ DEFAULT_RETRIEVAL = {
15
+ "semantic": False,
16
+ "provider": "none",
17
+ "model": "text-embedding-3-small",
18
+ "max_chunks": 20,
19
+ "fts_weight": 0.6,
20
+ "semantic_weight": 0.4,
21
+ "graph_depth": 1,
22
+ }
23
+
24
+ BOOL = {"true", "false", "yes", "no", "on", "off"}
25
+
26
+
27
+ def _parse_scalar(raw: str):
28
+ value = raw.strip().strip("'\"")
29
+ lower = value.lower()
30
+ if lower in {"true", "yes", "on"}:
31
+ return True
32
+ if lower in {"false", "no", "off"}:
33
+ return False
34
+ if re.fullmatch(r"-?\d+", value):
35
+ return int(value)
36
+ if re.fullmatch(r"-?\d+\.\d+", value):
37
+ return float(value)
38
+ return value
39
+
40
+
41
+ def load_memory_retrieval_config() -> dict:
42
+ """Return memory.retrieval settings with defaults."""
43
+
44
+ config = dict(DEFAULT_RETRIEVAL)
45
+ if not CONFIG_PATH.is_file():
46
+ return config
47
+
48
+ lines = CONFIG_PATH.read_text(encoding="utf-8").splitlines()
49
+ in_memory = False
50
+ in_retrieval = False
51
+ memory_indent = 0
52
+ retrieval_indent = 0
53
+
54
+ for line in lines:
55
+ if not line.strip() or line.lstrip().startswith("#"):
56
+ continue
57
+
58
+ indent = len(line) - len(line.lstrip())
59
+ stripped = line.strip()
60
+
61
+ if stripped == "memory:":
62
+ in_memory = True
63
+ in_retrieval = False
64
+ memory_indent = indent
65
+ continue
66
+
67
+ if not in_memory:
68
+ continue
69
+
70
+ if indent <= memory_indent and stripped != "memory:":
71
+ in_memory = False
72
+ in_retrieval = False
73
+ continue
74
+
75
+ if stripped == "retrieval:":
76
+ in_retrieval = True
77
+ retrieval_indent = indent
78
+ continue
79
+
80
+ if in_retrieval and indent <= retrieval_indent and not stripped.startswith("retrieval:"):
81
+ in_retrieval = False
82
+
83
+ if not in_retrieval:
84
+ continue
85
+
86
+ match = re.match(r"^([a-z_]+):\s*(.*)$", stripped)
87
+ if not match:
88
+ continue
89
+
90
+ key, raw_value = match.group(1), match.group(2)
91
+ if key not in config:
92
+ continue
93
+ if raw_value == "":
94
+ continue
95
+ config[key] = _parse_scalar(raw_value)
96
+
97
+ return config
@@ -0,0 +1,116 @@
1
+ """Optional chunk embeddings for semantic retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import struct
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Callable
12
+
13
+ HASH_DIMS = 64
14
+ OPENAI_DIMS = 256
15
+
16
+
17
+ def pack_vector(values: list[float]) -> bytes:
18
+ return struct.pack(f"{len(values)}f", *values)
19
+
20
+
21
+ def unpack_vector(blob: bytes) -> list[float]:
22
+ count = len(blob) // 4
23
+ return list(struct.unpack(f"{count}f", blob))
24
+
25
+
26
+ def cosine_similarity(a: list[float], b: list[float]) -> float:
27
+ if len(a) != len(b) or not a:
28
+ return 0.0
29
+ dot = sum(x * y for x, y in zip(a, b))
30
+ norm_a = sum(x * x for x in a) ** 0.5
31
+ norm_b = sum(y * y for y in b) ** 0.5
32
+ if norm_a == 0 or norm_b == 0:
33
+ return 0.0
34
+ return dot / (norm_a * norm_b)
35
+
36
+
37
+ def hash_embed(text: str, dims: int = HASH_DIMS) -> list[float]:
38
+ digest = hashlib.sha256(text.encode("utf-8")).digest()
39
+ values: list[float] = []
40
+ counter = 0
41
+ while len(values) < dims:
42
+ block = hashlib.sha256(digest + counter.to_bytes(2, "big")).digest()
43
+ for i in range(0, len(block), 4):
44
+ if len(values) >= dims:
45
+ break
46
+ chunk = block[i : i + 4]
47
+ if len(chunk) < 4:
48
+ break
49
+ integer = int.from_bytes(chunk, "big", signed=False)
50
+ values.append((integer / 2**31) - 1.0)
51
+ counter += 1
52
+ return values
53
+
54
+
55
+ def openai_embed(text: str, model: str) -> list[float]:
56
+ api_key = os.environ.get("OPENAI_API_KEY", "").strip()
57
+ if not api_key:
58
+ raise RuntimeError("OPENAI_API_KEY is required for provider openai")
59
+
60
+ payload = json.dumps({"input": text, "model": model}).encode("utf-8")
61
+ request = urllib.request.Request(
62
+ "https://api.openai.com/v1/embeddings",
63
+ data=payload,
64
+ headers={
65
+ "Authorization": f"Bearer {api_key}",
66
+ "Content-Type": "application/json",
67
+ },
68
+ method="POST",
69
+ )
70
+ try:
71
+ with urllib.request.urlopen(request, timeout=60) as response:
72
+ body = json.loads(response.read().decode("utf-8"))
73
+ except urllib.error.HTTPError as err:
74
+ detail = err.read().decode("utf-8", errors="replace")
75
+ raise RuntimeError(f"OpenAI embeddings failed: {detail}") from err
76
+
77
+ vector = body["data"][0]["embedding"]
78
+ if len(vector) > OPENAI_DIMS:
79
+ vector = vector[:OPENAI_DIMS]
80
+ return [float(value) for value in vector]
81
+
82
+
83
+ def ollama_embed(text: str, model: str) -> list[float]:
84
+ host = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
85
+ payload = json.dumps({"model": model, "prompt": text}).encode("utf-8")
86
+ request = urllib.request.Request(
87
+ f"{host}/api/embeddings",
88
+ data=payload,
89
+ headers={"Content-Type": "application/json"},
90
+ method="POST",
91
+ )
92
+ try:
93
+ with urllib.request.urlopen(request, timeout=120) as response:
94
+ body = json.loads(response.read().decode("utf-8"))
95
+ except urllib.error.URLError as err:
96
+ raise RuntimeError(f"Ollama embeddings failed: {err}") from err
97
+
98
+ vector = body.get("embedding") or []
99
+ if not vector:
100
+ raise RuntimeError("Ollama returned an empty embedding vector")
101
+ if len(vector) > OPENAI_DIMS:
102
+ vector = vector[:OPENAI_DIMS]
103
+ return [float(value) for value in vector]
104
+
105
+
106
+ def get_embed_fn(provider: str, model: str) -> Callable[[str], list[float]]:
107
+ normalized = provider.strip().lower()
108
+ if normalized in {"none", ""}:
109
+ raise RuntimeError("semantic retrieval is disabled (provider none)")
110
+ if normalized == "hash":
111
+ return lambda text: hash_embed(text)
112
+ if normalized == "openai":
113
+ return lambda text: openai_embed(text, model)
114
+ if normalized == "ollama":
115
+ return lambda text: ollama_embed(text, model)
116
+ raise RuntimeError(f"Unknown embedding provider: {provider}")
@@ -5,6 +5,7 @@ Source of truth remains markdown under `.specs/`. The database is a derived inde
5
5
 
6
6
  python3 memory_index.py rebuild
7
7
  python3 memory_index.py rebuild --json
8
+ python3 memory_index.py embed [--force]
8
9
 
9
10
  Exit codes: 0 ok, 1 failure, 2 usage error.
10
11
  """
@@ -12,6 +13,7 @@ Exit codes: 0 ok, 1 failure, 2 usage error.
12
13
  from __future__ import annotations
13
14
 
14
15
  import argparse
16
+ import hashlib
15
17
  import json
16
18
  import re
17
19
  import sqlite3
@@ -20,12 +22,10 @@ from datetime import datetime, timezone
20
22
  from pathlib import Path
21
23
 
22
24
  from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE, requirement_ids
25
+ from _memory_config import DB_PATH, FEATURES_DIR, MEMORY_DIR, SPECS_DIR, load_memory_retrieval_config
26
+ from _memory_embed import get_embed_fn, pack_vector
23
27
 
24
28
  GATE = "memory-index"
25
- SPECS_DIR = Path(".specs")
26
- FEATURES_DIR = SPECS_DIR / "features"
27
- MEMORY_DIR = SPECS_DIR / "memory"
28
- DB_PATH = MEMORY_DIR / "memory.db"
29
29
 
30
30
  TASK_HEADING = re.compile(
31
31
  r"^#{2,6}\s*(?P<id>T\d{1,6})\s*[:\-–]?\s*(?P<title>.*)$",
@@ -35,7 +35,11 @@ TASK_FIELD = re.compile(
35
35
  r"^\s*[-*]?\s*\*{0,2}(?P<key>[A-Za-z][A-Za-z ]+?)\*{0,2}\s*:\s*(?P<value>.+?)\s*$",
36
36
  re.MULTILINE,
37
37
  )
38
+ REQ_HEADING = re.compile(r"^#{2,6}\s*(?P<id>REQ-\d+)\b.*$", re.MULTILINE | re.IGNORECASE)
38
39
  REQ_REF = re.compile(r"\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b")
40
+ SECTION_HEADING = re.compile(r"^##\s+(.+)$", re.MULTILINE)
41
+
42
+ LESSON_STATUSES = {"approved", "graduated", "confirmed"}
39
43
 
40
44
 
41
45
  def fail(message: str, code: int = EXIT_FAILED) -> int:
@@ -53,6 +57,10 @@ def utc_now() -> str:
53
57
  return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
54
58
 
55
59
 
60
+ def text_hash(text: str) -> str:
61
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
62
+
63
+
56
64
  def init_schema(conn: sqlite3.Connection) -> None:
57
65
  conn.executescript(
58
66
  """
@@ -76,6 +84,31 @@ def init_schema(conn: sqlite3.Connection) -> None:
76
84
  source_path,
77
85
  tokenize='porter'
78
86
  );
87
+ CREATE TABLE IF NOT EXISTS chunks (
88
+ id TEXT PRIMARY KEY,
89
+ entity_id TEXT,
90
+ kind TEXT NOT NULL,
91
+ source_path TEXT,
92
+ text TEXT NOT NULL,
93
+ text_hash TEXT NOT NULL,
94
+ updated_at TEXT NOT NULL
95
+ );
96
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(
97
+ id UNINDEXED,
98
+ kind,
99
+ source_path,
100
+ text,
101
+ tokenize='porter'
102
+ );
103
+ CREATE TABLE IF NOT EXISTS embeddings (
104
+ chunk_id TEXT PRIMARY KEY,
105
+ model TEXT NOT NULL,
106
+ provider TEXT NOT NULL,
107
+ dims INTEGER NOT NULL,
108
+ text_hash TEXT NOT NULL,
109
+ vector BLOB NOT NULL,
110
+ updated_at TEXT NOT NULL
111
+ );
79
112
  """
80
113
  )
81
114
 
@@ -107,6 +140,41 @@ def upsert_entity(
107
140
  )
108
141
 
109
142
 
143
+ def upsert_chunk(
144
+ conn: sqlite3.Connection,
145
+ chunk_id: str,
146
+ entity_id: str | None,
147
+ kind: str,
148
+ source_path: str,
149
+ text: str,
150
+ now: str,
151
+ ) -> None:
152
+ normalized = text.strip()
153
+ if len(normalized) < 8:
154
+ return
155
+
156
+ digest = text_hash(normalized)
157
+ conn.execute(
158
+ """
159
+ INSERT INTO chunks (id, entity_id, kind, source_path, text, text_hash, updated_at)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?)
161
+ ON CONFLICT(id) DO UPDATE SET
162
+ entity_id=excluded.entity_id,
163
+ kind=excluded.kind,
164
+ source_path=excluded.source_path,
165
+ text=excluded.text,
166
+ text_hash=excluded.text_hash,
167
+ updated_at=excluded.updated_at
168
+ """,
169
+ (chunk_id, entity_id, kind, source_path, normalized, digest, now),
170
+ )
171
+ conn.execute("DELETE FROM chunk_fts WHERE id = ?", (chunk_id,))
172
+ conn.execute(
173
+ "INSERT INTO chunk_fts (id, kind, source_path, text) VALUES (?, ?, ?, ?)",
174
+ (chunk_id, kind, source_path, normalized),
175
+ )
176
+
177
+
110
178
  def upsert_relation(conn: sqlite3.Connection, from_id: str, to_id: str, kind: str) -> None:
111
179
  conn.execute(
112
180
  """
@@ -170,27 +238,94 @@ def task_file_paths(tasks_text: str) -> dict[str, set[str]]:
170
238
  return mapping
171
239
 
172
240
 
173
- def index_lessons(conn: sqlite3.Connection, now: str) -> int:
241
+ def chunk_spec_requirements(
242
+ conn: sqlite3.Connection,
243
+ feature_id: str,
244
+ spec_path: Path,
245
+ spec_text: str,
246
+ now: str,
247
+ ) -> int:
248
+ count = 0
249
+ matches = list(REQ_HEADING.finditer(spec_text))
250
+ for index, match in enumerate(matches):
251
+ req_id = match.group("id").upper()
252
+ start = match.end()
253
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(spec_text)
254
+ body = spec_text[start:end].strip()
255
+ if not body:
256
+ continue
257
+ chunk_id = f"chunk:{feature_id}:requirement:{req_id}"
258
+ upsert_chunk(conn, chunk_id, req_id, "requirement", str(spec_path), body, now)
259
+ count += 1
260
+ return count
261
+
262
+
263
+ def chunk_markdown_sections(
264
+ conn: sqlite3.Connection,
265
+ feature_id: str,
266
+ path: Path,
267
+ text: str,
268
+ kind: str,
269
+ entity_id: str | None,
270
+ now: str,
271
+ ) -> int:
272
+ count = 0
273
+ matches = list(SECTION_HEADING.finditer(text))
274
+ if not matches:
275
+ chunk_id = f"chunk:{feature_id}:{kind}:body"
276
+ upsert_chunk(conn, chunk_id, entity_id, kind, str(path), text, now)
277
+ return 1
278
+
279
+ for index, match in enumerate(matches):
280
+ title = match.group(1).strip()
281
+ slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "section"
282
+ start = match.end()
283
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
284
+ body = f"## {title}\n{text[start:end]}".strip()
285
+ chunk_id = f"chunk:{feature_id}:{kind}:{slug}"
286
+ upsert_chunk(conn, chunk_id, entity_id, kind, str(path), body, now)
287
+ count += 1
288
+ return count
289
+
290
+
291
+ def index_lessons(conn: sqlite3.Connection, now: str) -> tuple[int, int]:
174
292
  lessons_path = SPECS_DIR / "lessons.json"
175
293
  if not lessons_path.is_file():
176
- return 0
294
+ return 0, 0
177
295
 
178
296
  payload = json.loads(lessons_path.read_text(encoding="utf-8"))
179
- count = 0
297
+ entity_count = 0
298
+ chunk_count = 0
180
299
  for lesson in payload.get("lessons") or []:
181
300
  lesson_id = str(lesson.get("id") or "").strip()
182
301
  if not lesson_id:
183
302
  continue
303
+ status = str(lesson.get("status") or "").strip().lower()
304
+ title = str(lesson.get("title") or lesson_id)
184
305
  upsert_entity(
185
306
  conn,
186
307
  lesson_id,
187
308
  "lesson",
188
- str(lesson.get("title") or lesson_id),
309
+ title,
189
310
  str(lesson.get("source") or ""),
190
311
  now,
191
312
  )
192
- count += 1
193
- return count
313
+ entity_count += 1
314
+
315
+ if status not in LESSON_STATUSES:
316
+ continue
317
+
318
+ body_parts = [title]
319
+ for key in ("summary", "detail", "evidence", "recommendation"):
320
+ value = lesson.get(key)
321
+ if value:
322
+ body_parts.append(str(value))
323
+ body = "\n".join(body_parts).strip()
324
+ chunk_id = f"chunk:lesson:{lesson_id}"
325
+ upsert_chunk(conn, chunk_id, lesson_id, "lesson", str(lessons_path), body, now)
326
+ chunk_count += 1
327
+
328
+ return entity_count, chunk_count
194
329
 
195
330
 
196
331
  def rebuild(json_output: bool = False) -> int:
@@ -206,9 +341,13 @@ def rebuild(json_output: bool = False) -> int:
206
341
  conn.execute("DELETE FROM relations")
207
342
  conn.execute("DELETE FROM entities")
208
343
  conn.execute("DELETE FROM entity_fts")
344
+ conn.execute("DELETE FROM chunks")
345
+ conn.execute("DELETE FROM chunk_fts")
346
+ conn.execute("DELETE FROM embeddings")
209
347
 
210
348
  entity_count = 0
211
349
  relation_count = 0
350
+ chunk_count = 0
212
351
 
213
352
  if FEATURES_DIR.is_dir():
214
353
  for feature_dir in sorted(FEATURES_DIR.iterdir()):
@@ -241,6 +380,35 @@ def rebuild(json_output: bool = False) -> int:
241
380
  entity_count += 1
242
381
  upsert_relation(conn, feature_id, req_id, "contains")
243
382
  relation_count += 1
383
+ chunk_count += chunk_spec_requirements(
384
+ conn, feature_id, spec_path, spec_text, now
385
+ )
386
+
387
+ validation_path = feature_dir / "validation.md"
388
+ if validation_path.is_file():
389
+ validation_text = validation_path.read_text(encoding="utf-8")
390
+ chunk_count += chunk_markdown_sections(
391
+ conn,
392
+ feature_id,
393
+ validation_path,
394
+ validation_text,
395
+ "validation",
396
+ feature_id,
397
+ now,
398
+ )
399
+
400
+ exploration_path = feature_dir / "exploration.md"
401
+ if exploration_path.is_file():
402
+ exploration_text = exploration_path.read_text(encoding="utf-8")
403
+ chunk_count += chunk_markdown_sections(
404
+ conn,
405
+ feature_id,
406
+ exploration_path,
407
+ exploration_text,
408
+ "exploration",
409
+ feature_id,
410
+ now,
411
+ )
244
412
 
245
413
  tasks_path = feature_dir / "tasks.md"
246
414
  if tasks_path.is_file():
@@ -280,13 +448,16 @@ def rebuild(json_output: bool = False) -> int:
280
448
  upsert_relation(conn, task_id, file_entity, "touches")
281
449
  relation_count += 1
282
450
 
283
- entity_count += index_lessons(conn, now)
451
+ lesson_entities, lesson_chunks = index_lessons(conn, now)
452
+ entity_count += lesson_entities
453
+ chunk_count += lesson_chunks
284
454
  conn.commit()
285
455
 
286
456
  summary = {
287
457
  "database": str(DB_PATH),
288
458
  "entities": entity_count,
289
459
  "relations": relation_count,
460
+ "chunks": chunk_count,
290
461
  "updated_at": now,
291
462
  }
292
463
 
@@ -294,13 +465,86 @@ def rebuild(json_output: bool = False) -> int:
294
465
  print(json.dumps(summary, indent=2))
295
466
  else:
296
467
  return ok(
297
- f"indexed {entity_count} entities and {relation_count} relations -> {DB_PATH}"
468
+ f"indexed {entity_count} entities, {relation_count} relations, "
469
+ f"{chunk_count} chunks -> {DB_PATH}"
298
470
  )
299
471
  return EXIT_OK
300
472
  finally:
301
473
  conn.close()
302
474
 
303
475
 
476
+ def embed_chunks(force: bool = False, json_output: bool = False) -> int:
477
+ config = load_memory_retrieval_config()
478
+ if not config.get("semantic"):
479
+ print(f"[{GATE}] semantic retrieval disabled — embed skipped (set memory.retrieval.semantic: true)")
480
+ return EXIT_OK
481
+
482
+ provider = str(config.get("provider") or "none")
483
+ model = str(config.get("model") or "text-embedding-3-small")
484
+
485
+ if not DB_PATH.is_file():
486
+ return fail(f"{DB_PATH} not found — run `memory-index rebuild` first")
487
+
488
+ try:
489
+ embed_fn = get_embed_fn(provider, model)
490
+ except RuntimeError as err:
491
+ return fail(str(err))
492
+
493
+ now = utc_now()
494
+ conn = sqlite3.connect(DB_PATH)
495
+ embedded = 0
496
+ skipped = 0
497
+ try:
498
+ init_schema(conn)
499
+ rows = conn.execute(
500
+ "SELECT id, text, text_hash FROM chunks ORDER BY id"
501
+ ).fetchall()
502
+
503
+ for chunk_id, text, digest in rows:
504
+ if not force:
505
+ existing = conn.execute(
506
+ """
507
+ SELECT text_hash FROM embeddings
508
+ WHERE chunk_id = ? AND provider = ? AND model = ?
509
+ """,
510
+ (chunk_id, provider, model),
511
+ ).fetchone()
512
+ if existing and existing[0] == digest:
513
+ skipped += 1
514
+ continue
515
+
516
+ vector = embed_fn(text)
517
+ conn.execute(
518
+ """
519
+ INSERT INTO embeddings (chunk_id, model, provider, dims, text_hash, vector, updated_at)
520
+ VALUES (?, ?, ?, ?, ?, ?, ?)
521
+ ON CONFLICT(chunk_id) DO UPDATE SET
522
+ model=excluded.model,
523
+ provider=excluded.provider,
524
+ dims=excluded.dims,
525
+ text_hash=excluded.text_hash,
526
+ vector=excluded.vector,
527
+ updated_at=excluded.updated_at
528
+ """,
529
+ (chunk_id, model, provider, len(vector), digest, pack_vector(vector), now),
530
+ )
531
+ embedded += 1
532
+
533
+ conn.commit()
534
+ finally:
535
+ conn.close()
536
+
537
+ summary = {"embedded": embedded, "skipped": skipped, "provider": provider, "model": model}
538
+ if json_output:
539
+ print(json.dumps(summary, indent=2))
540
+ else:
541
+ print(
542
+ f"[{GATE}] embedded {embedded} chunk(s), skipped {skipped} "
543
+ f"({provider}/{model})"
544
+ )
545
+ return EXIT_OK
546
+
547
+
304
548
  def build_parser() -> argparse.ArgumentParser:
305
549
  parser = argparse.ArgumentParser(description="Rebuild SQLite memory index from .specs/")
306
550
  sub = parser.add_subparsers(dest="command")
@@ -309,6 +553,11 @@ def build_parser() -> argparse.ArgumentParser:
309
553
  rebuild_cmd.add_argument("--json", action="store_true")
310
554
  rebuild_cmd.set_defaults(func=lambda args: rebuild(json_output=args.json))
311
555
 
556
+ embed_cmd = sub.add_parser("embed", help="build optional semantic embeddings for chunks")
557
+ embed_cmd.add_argument("--force", action="store_true")
558
+ embed_cmd.add_argument("--json", action="store_true")
559
+ embed_cmd.set_defaults(func=lambda args: embed_chunks(force=args.force, json_output=args.json))
560
+
312
561
  return parser
313
562
 
314
563
 
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env python3
2
+ """Hybrid memory retrieval: FTS chunks + graph expansion (+ optional semantic).
3
+
4
+ python3 memory_retrieve.py "silent session expiry"
5
+ python3 memory_retrieve.py "auth failure" --mode hybrid --json
6
+
7
+ Exit codes: 0 ok, 1 failure, 2 usage error.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sqlite3
15
+ import sys
16
+ from collections import deque
17
+
18
+ from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
19
+ from _memory_config import DB_PATH, load_memory_retrieval_config
20
+ from _memory_embed import cosine_similarity, get_embed_fn, unpack_vector
21
+ from memory_search import search, search_chunks
22
+
23
+ GATE = "memory-retrieve"
24
+
25
+
26
+ def fail(message: str, code: int = EXIT_FAILED) -> int:
27
+ print(f"[{GATE}] FAIL - {DB_PATH}")
28
+ print(f" error {message}")
29
+ return code
30
+
31
+
32
+ def fetch_neighbors(conn: sqlite3.Connection, entity_id: str) -> list[dict]:
33
+ rows = conn.execute(
34
+ """
35
+ SELECT r.kind, e.id, e.kind, e.label, e.source_path
36
+ FROM relations r
37
+ JOIN entities e ON e.id = r.to_id
38
+ WHERE r.from_id = ?
39
+ UNION
40
+ SELECT r.kind, e.id, e.kind, e.label, e.source_path
41
+ FROM relations r
42
+ JOIN entities e ON e.id = r.from_id
43
+ WHERE r.to_id = ?
44
+ """,
45
+ (entity_id, entity_id),
46
+ ).fetchall()
47
+
48
+ return [
49
+ {
50
+ "relation": row[0],
51
+ "entity": {
52
+ "id": row[1],
53
+ "kind": row[2],
54
+ "label": row[3],
55
+ "source_path": row[4],
56
+ },
57
+ }
58
+ for row in rows
59
+ ]
60
+
61
+
62
+ def expand_graph(entity_ids: list[str], depth: int) -> dict:
63
+ if not DB_PATH.is_file() or depth <= 0 or not entity_ids:
64
+ return {"entities": [], "relations": []}
65
+
66
+ conn = sqlite3.connect(DB_PATH)
67
+ visited: set[str] = set()
68
+ entities: dict[str, dict] = {}
69
+ relations: list[dict] = []
70
+ queue: deque[tuple[str, int]] = deque()
71
+
72
+ try:
73
+ for entity_id in entity_ids:
74
+ if entity_id and entity_id not in visited:
75
+ visited.add(entity_id)
76
+ queue.append((entity_id, 0))
77
+
78
+ while queue:
79
+ current_id, current_depth = queue.popleft()
80
+ if current_depth >= depth:
81
+ continue
82
+
83
+ row = conn.execute(
84
+ "SELECT id, kind, label, source_path, updated_at FROM entities WHERE id = ?",
85
+ (current_id,),
86
+ ).fetchone()
87
+ if row:
88
+ entities[current_id] = {
89
+ "id": row[0],
90
+ "kind": row[1],
91
+ "label": row[2],
92
+ "source_path": row[3],
93
+ "updated_at": row[4],
94
+ }
95
+
96
+ for neighbor in fetch_neighbors(conn, current_id):
97
+ target = neighbor["entity"]
98
+ target_id = target["id"]
99
+ relations.append(
100
+ {"from": current_id, "to": target_id, "kind": neighbor["relation"]}
101
+ )
102
+ if target_id not in entities:
103
+ entities[target_id] = target
104
+ if target_id not in visited:
105
+ visited.add(target_id)
106
+ queue.append((target_id, current_depth + 1))
107
+ finally:
108
+ conn.close()
109
+
110
+ return {"entities": list(entities.values()), "relations": relations}
111
+
112
+
113
+ def semantic_search(query: str, limit: int, provider: str, model: str) -> list[dict]:
114
+ embed_fn = get_embed_fn(provider, model)
115
+ query_vector = embed_fn(query)
116
+
117
+ conn = sqlite3.connect(DB_PATH)
118
+ try:
119
+ rows = conn.execute(
120
+ """
121
+ SELECT c.id, c.entity_id, c.kind, c.source_path, c.text, e.vector
122
+ FROM embeddings e
123
+ JOIN chunks c ON c.id = e.chunk_id
124
+ WHERE e.provider = ? AND e.model = ?
125
+ """,
126
+ (provider, model),
127
+ ).fetchall()
128
+ finally:
129
+ conn.close()
130
+
131
+ scored: list[tuple[float, dict]] = []
132
+ for chunk_id, entity_id, kind, source_path, text, blob in rows:
133
+ vector = unpack_vector(blob)
134
+ score = cosine_similarity(query_vector, vector)
135
+ if score <= 0:
136
+ continue
137
+ snippet = text if len(text) <= 240 else text[:237] + "..."
138
+ scored.append(
139
+ (
140
+ score,
141
+ {
142
+ "chunk_id": chunk_id,
143
+ "entity_id": entity_id,
144
+ "kind": kind,
145
+ "source_path": source_path,
146
+ "snippet": snippet,
147
+ "score": round(score, 4),
148
+ "source": "semantic",
149
+ },
150
+ )
151
+ )
152
+
153
+ scored.sort(key=lambda item: item[0], reverse=True)
154
+ return [item[1] for item in scored[:limit]]
155
+
156
+
157
+ def merge_hybrid(
158
+ fts_results: list[dict],
159
+ semantic_results: list[dict],
160
+ fts_weight: float,
161
+ semantic_weight: float,
162
+ max_chunks: int,
163
+ ) -> list[dict]:
164
+ merged: dict[str, dict] = {}
165
+
166
+ for rank, item in enumerate(fts_results):
167
+ key = item.get("chunk_id") or item.get("entity_id") or f"fts-{rank}"
168
+ score = fts_weight * (1.0 / (rank + 1))
169
+ merged[key] = {**item, "score": round(score, 4), "sources": [item.get("source", "fts")]}
170
+
171
+ for rank, item in enumerate(semantic_results):
172
+ key = item.get("chunk_id") or item.get("entity_id") or f"sem-{rank}"
173
+ bonus = semantic_weight * (item.get("score") or (1.0 / (rank + 1)))
174
+ if key in merged:
175
+ merged[key]["score"] = round(merged[key]["score"] + bonus, 4)
176
+ merged[key]["sources"].append("semantic")
177
+ else:
178
+ merged[key] = {**item, "score": round(bonus, 4), "sources": ["semantic"]}
179
+
180
+ ordered = sorted(merged.values(), key=lambda item: item.get("score", 0), reverse=True)
181
+ return ordered[:max_chunks]
182
+
183
+
184
+ def retrieve(query: str, mode: str) -> dict:
185
+ if not DB_PATH.is_file():
186
+ raise FileNotFoundError(
187
+ f"{DB_PATH} not found — run `memory-index rebuild` after install"
188
+ )
189
+
190
+ config = load_memory_retrieval_config()
191
+ max_chunks = int(config.get("max_chunks") or 20)
192
+ graph_depth = int(config.get("graph_depth") or 1)
193
+ fts_weight = float(config.get("fts_weight") or 0.6)
194
+ semantic_weight = float(config.get("semantic_weight") or 0.4)
195
+ semantic_enabled = bool(config.get("semantic"))
196
+ provider = str(config.get("provider") or "none")
197
+ model = str(config.get("model") or "text-embedding-3-small")
198
+
199
+ fts_results = search_chunks(query, max_chunks)
200
+ if not fts_results:
201
+ fts_results = search(query, max_chunks)
202
+
203
+ semantic_results: list[dict] = []
204
+ if mode in {"hybrid", "semantic"} and semantic_enabled and provider not in {"", "none"}:
205
+ try:
206
+ semantic_results = semantic_search(query, max_chunks, provider, model)
207
+ except RuntimeError:
208
+ if mode == "semantic":
209
+ raise
210
+ semantic_results = []
211
+
212
+ if mode == "semantic" and semantic_results:
213
+ results = semantic_results[:max_chunks]
214
+ elif mode == "hybrid" and semantic_results:
215
+ results = merge_hybrid(
216
+ fts_results, semantic_results, fts_weight, semantic_weight, max_chunks
217
+ )
218
+ else:
219
+ results = fts_results[:max_chunks]
220
+
221
+ seed_ids = []
222
+ for item in results:
223
+ if item.get("entity_id"):
224
+ seed_ids.append(item["entity_id"])
225
+ graph = expand_graph(seed_ids, graph_depth if mode != "fts" else 0)
226
+
227
+ return {
228
+ "query": query,
229
+ "mode": mode,
230
+ "semantic_enabled": semantic_enabled,
231
+ "results": results,
232
+ "graph_expansion": {
233
+ "entities": len(graph["entities"]),
234
+ "relations": len(graph["relations"]),
235
+ "entities_detail": graph["entities"],
236
+ "relations_detail": graph["relations"],
237
+ },
238
+ }
239
+
240
+
241
+ def cmd_retrieve(args: argparse.Namespace) -> int:
242
+ try:
243
+ package = retrieve(args.query, args.mode)
244
+ except FileNotFoundError as err:
245
+ return fail(str(err))
246
+ except RuntimeError as err:
247
+ return fail(str(err))
248
+
249
+ if args.json:
250
+ print(json.dumps(package, indent=2))
251
+ else:
252
+ print(
253
+ f"[{GATE}] {len(package['results'])} result(s) for {args.query!r} "
254
+ f"(mode={package['mode']})"
255
+ )
256
+ for item in package["results"]:
257
+ sources = ",".join(item.get("sources") or [item.get("source", "fts")])
258
+ label = item.get("snippet") or item.get("entity_id")
259
+ print(f" {item.get('chunk_id') or item.get('entity_id')} [{sources}]: {label}")
260
+ graph = package["graph_expansion"]
261
+ print(
262
+ f" graph: {graph['entities']} entities, {graph['relations']} relations"
263
+ )
264
+
265
+ return EXIT_OK
266
+
267
+
268
+ def build_parser() -> argparse.ArgumentParser:
269
+ parser = argparse.ArgumentParser(description="Hybrid retrieval over the memory index")
270
+ parser.add_argument("query", help="natural language or keyword query")
271
+ parser.add_argument(
272
+ "--mode",
273
+ choices=["fts", "hybrid", "semantic"],
274
+ default="hybrid",
275
+ help="retrieval strategy (default: hybrid)",
276
+ )
277
+ parser.add_argument("--json", action="store_true")
278
+ parser.set_defaults(func=cmd_retrieve)
279
+ return parser
280
+
281
+
282
+ def main(argv: list[str] | None = None) -> int:
283
+ parser = build_parser()
284
+ args = parser.parse_args(argv)
285
+ return args.func(args)
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Full-text search over the SQLite memory index (entity_fts).
2
+ """Full-text search over the SQLite memory index (chunks + entity metadata).
3
3
 
4
4
  python3 memory_search.py "authentication route"
5
5
  python3 memory_search.py "REQ-001" --limit 5 --json
@@ -17,9 +17,9 @@ import sys
17
17
  from pathlib import Path
18
18
 
19
19
  from _common import EXIT_FAILED, EXIT_OK, EXIT_USAGE
20
+ from _memory_config import DB_PATH
20
21
 
21
22
  GATE = "memory-search"
22
- DB_PATH = Path(".specs/memory/memory.db")
23
23
  DEFAULT_LIMIT = 10
24
24
  MAX_LIMIT = 50
25
25
 
@@ -31,26 +31,56 @@ def fail(message: str, code: int = EXIT_FAILED) -> int:
31
31
 
32
32
 
33
33
  def sanitize_fts_query(raw: str) -> str:
34
- """Return an FTS5-safe query string (token AND chain)."""
35
-
36
34
  tokens = re.findall(r"[A-Za-z0-9_\-]+", raw)
37
35
  if not tokens:
38
36
  raise ValueError("query must contain at least one searchable token")
39
37
  return " AND ".join(f'"{token}"' for token in tokens)
40
38
 
41
39
 
42
- def search(query: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
43
- if not DB_PATH.is_file():
44
- raise FileNotFoundError(
45
- f"{DB_PATH} not found — run `memory-index rebuild` after install"
40
+ def search_chunks(query: str, limit: int) -> list[dict]:
41
+ fts_query = sanitize_fts_query(query)
42
+ conn = sqlite3.connect(DB_PATH)
43
+ try:
44
+ rows = conn.execute(
45
+ """
46
+ SELECT c.id, c.entity_id, c.kind, c.source_path, c.text, c.updated_at, f.rank
47
+ FROM chunk_fts f
48
+ JOIN chunks c ON c.id = f.id
49
+ WHERE chunk_fts MATCH ?
50
+ ORDER BY rank
51
+ LIMIT ?
52
+ """,
53
+ (fts_query, limit),
54
+ ).fetchall()
55
+ finally:
56
+ conn.close()
57
+
58
+ results = []
59
+ for row in rows:
60
+ text = row[4]
61
+ snippet = text if len(text) <= 240 else text[:237] + "..."
62
+ results.append(
63
+ {
64
+ "chunk_id": row[0],
65
+ "entity_id": row[1],
66
+ "kind": row[2],
67
+ "source_path": row[3],
68
+ "snippet": snippet,
69
+ "updated_at": row[5],
70
+ "score": float(-row[6]) if row[6] is not None else 0.0,
71
+ "source": "fts-chunk",
72
+ }
46
73
  )
74
+ return results
47
75
 
76
+
77
+ def search_entities(query: str, limit: int) -> list[dict]:
48
78
  fts_query = sanitize_fts_query(query)
49
79
  conn = sqlite3.connect(DB_PATH)
50
80
  try:
51
81
  rows = conn.execute(
52
82
  """
53
- SELECT e.id, e.kind, e.label, e.source_path, e.updated_at
83
+ SELECT e.id, e.kind, e.label, e.source_path, e.updated_at, f.rank
54
84
  FROM entity_fts f
55
85
  JOIN entities e ON e.id = f.id
56
86
  WHERE entity_fts MATCH ?
@@ -64,16 +94,32 @@ def search(query: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
64
94
 
65
95
  return [
66
96
  {
67
- "id": row[0],
97
+ "chunk_id": None,
98
+ "entity_id": row[0],
68
99
  "kind": row[1],
69
- "label": row[2],
70
100
  "source_path": row[3],
101
+ "snippet": row[2],
71
102
  "updated_at": row[4],
103
+ "score": float(-row[5]) if row[5] is not None else 0.0,
104
+ "source": "fts-entity",
72
105
  }
73
106
  for row in rows
74
107
  ]
75
108
 
76
109
 
110
+ def search(query: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
111
+ if not DB_PATH.is_file():
112
+ raise FileNotFoundError(
113
+ f"{DB_PATH} not found — run `memory-index rebuild` after install"
114
+ )
115
+
116
+ chunk_hits = search_chunks(query, limit)
117
+ if chunk_hits:
118
+ return chunk_hits
119
+
120
+ return search_entities(query, limit)
121
+
122
+
77
123
  def cmd_search(args: argparse.Namespace) -> int:
78
124
  try:
79
125
  results = search(args.query, limit=args.limit)
@@ -89,7 +135,9 @@ def cmd_search(args: argparse.Namespace) -> int:
89
135
  else:
90
136
  print(f"[{GATE}] {len(results)} match(es) for {args.query!r}")
91
137
  for item in results:
92
- print(f" {item['id']} ({item['kind']}): {item['label']}")
138
+ label = item.get("snippet") or item.get("entity_id")
139
+ kind = item.get("kind")
140
+ print(f" {item.get('chunk_id') or item.get('entity_id')} ({kind}): {label}")
93
141
  if item.get("source_path"):
94
142
  print(f" {item['source_path']}")
95
143
 
@@ -52,7 +52,8 @@ Structural gates run **before** owner review, so they cannot drift when the mode
52
52
  | Before claiming feature complete | `npx @luizsantiago/spec-guardrails context-guard check-complete [feature]` |
53
53
  | Solution exploration (explicit) | `npx @luizsantiago/spec-guardrails solution-explore init <feature> --candidates A,B` |
54
54
  | Before exploration decision | `npx @luizsantiago/spec-guardrails solution-explore validate [feature]` |
55
- | Retrieve related context | `npx @luizsantiago/spec-guardrails memory-search "<terms>"` |
55
+ | Retrieve related context | `npx @luizsantiago/spec-guardrails memory-retrieve "<query>"` |
56
+ | Rebuild / embed memory index | `npx @luizsantiago/spec-guardrails memory-index rebuild` · `memory-index embed` |
56
57
  | On gate retry (Execute playbook) | `npx @luizsantiago/spec-guardrails execution-policy record-retry Tn` |
57
58
  | On each commit | `python3 .specs/guardrails/scripts/check_commit.py --message "<message>"` |
58
59
  | Before declaring a feature done | `python3 .specs/guardrails/scripts/validate_state.py [feature]` |
@@ -93,6 +93,38 @@ Lessons are owned by `lessons.py`, not by hand. See `lessons.md`.
93
93
  - Lazy artifacts: never scaffold empty files to look organized.
94
94
  - `STATE.md` has exactly one "Next Step" item. A list of five is not a handoff.
95
95
 
96
+ ## Retrieval ladder
97
+
98
+ Rebuild the derived index after meaningful `.specs/` changes:
99
+
100
+ ```bash
101
+ npx @luizsantiago/spec-guardrails memory-index rebuild
102
+ ```
103
+
104
+ | Need | Command |
105
+ | --- | --- |
106
+ | Exact entity + neighbors | `memory-query --from T1 --depth 2` |
107
+ | Keyword search in artifact bodies | `memory-search "oauth session"` |
108
+ | Ranked hybrid package (FTS + graph + optional semantic) | `memory-retrieve "silent session expiry"` |
109
+
110
+ Optional semantic retrieval stays **off** by default in `.specs/config.yaml`. Enable only when FTS + graph are not enough:
111
+
112
+ ```yaml
113
+ memory:
114
+ retrieval:
115
+ semantic: true
116
+ provider: openai # or ollama | hash (local/testing)
117
+ model: text-embedding-3-small
118
+ ```
119
+
120
+ Then embed after rebuild:
121
+
122
+ ```bash
123
+ npx @luizsantiago/spec-guardrails memory-index embed
124
+ ```
125
+
126
+ Markdown under `.specs/` remains the source of truth; the SQLite index is rebuildable.
127
+
96
128
  ## Next
97
129
 
98
130
  `git-handoff.md` — what to stage, how to word the commit, and why no auto-push.
@@ -49,6 +49,17 @@ effects:
49
49
  warn_read: []
50
50
  warn_delete: []
51
51
 
52
+ # Memory retrieval (optional — hybrid FTS + graph + semantic)
53
+ memory:
54
+ retrieval:
55
+ semantic: false
56
+ provider: none
57
+ model: text-embedding-3-small
58
+ max_chunks: 20
59
+ fts_weight: 0.6
60
+ semantic_weight: 0.4
61
+ graph_depth: 1
62
+
52
63
  # Project-specific overrides (appended on top of preset + rules above):
53
64
  # overrides:
54
65
  # rules: