@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 +2 -1
- package/index.js +5 -1
- package/lib/constants.js +3 -0
- package/lib/gates.js +1 -0
- package/lib/memory-index.js +36 -0
- package/package.json +1 -1
- package/scripts/_memory_config.py +97 -0
- package/scripts/_memory_embed.py +116 -0
- package/scripts/memory_index.py +261 -12
- package/scripts/memory_retrieve.py +289 -0
- package/scripts/memory_search.py +60 -12
- package/skills/agent-architecture.md +2 -1
- package/skills/references/memory.md +32 -0
- package/templates/config.yaml.example +11 -0
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.
|
|
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
|
|
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
package/lib/memory-index.js
CHANGED
|
@@ -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.
|
|
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}")
|
package/scripts/memory_index.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
309
|
+
title,
|
|
189
310
|
str(lesson.get("source") or ""),
|
|
190
311
|
now,
|
|
191
312
|
)
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
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())
|
package/scripts/memory_search.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Full-text search over the SQLite memory index (
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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-
|
|
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:
|