@luisarg/memory-auto 0.1.0 → 0.1.1
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/package.json +35 -7
- package/server/__init__.py +1 -0
- package/server/cli.py +27 -0
- package/server/pyproject.toml +14 -0
- package/server/registry.py +141 -0
- package/server/server.py +399 -0
- package/server/store.py +827 -0
- package/server/uv.lock +783 -0
- package/vault/README.md +38 -0
- package/vault/tag-vocabulary.json +175 -0
- package/vault/templates/context.md +20 -0
- package/vault/templates/convention.md +13 -0
- package/vault/templates/decision.md +23 -0
- package/vault/templates/fact.md +14 -0
- package/vault/templates/idea.md +28 -0
- package/vault/templates/learning.md +23 -0
- package/vault/templates/profile.md +13 -0
- package/vault/templates/source.md +21 -0
- package/vault/type-registry.yaml +75 -0
package/server/store.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
"""Memory storage layer: SQLite (WAL + FTS5) backed by OKF Markdown files.
|
|
2
|
+
|
|
3
|
+
The Markdown files under `memory-vault/projects/<project>/<type>/` are the
|
|
4
|
+
source of truth. SQLite is a derived search index that can be rebuilt from
|
|
5
|
+
the Markdown at any time via `scripts/rebuild_index.py`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re as _re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from cli import get_memory_path
|
|
20
|
+
from registry import build_type_maps, load_type_registry
|
|
21
|
+
|
|
22
|
+
# ponytail: registry-derived globals, initialized once at module load.
|
|
23
|
+
_registry_maps = build_type_maps(load_type_registry(get_memory_path()))
|
|
24
|
+
|
|
25
|
+
VALID_TYPES: set[str] = _registry_maps["valid_types"]
|
|
26
|
+
_TYPE_DIR_MAP: dict[str, str] = _registry_maps["type_dir_map"]
|
|
27
|
+
_TYPE_LABEL_MAP: dict[str, str] = _registry_maps["type_label_map"]
|
|
28
|
+
_DIR_TO_SINGULAR: dict[str, str] = _registry_maps["dir_to_singular"]
|
|
29
|
+
_type_order: list[str] = _registry_maps["type_order"]
|
|
30
|
+
_extraction_types: list[dict] = _registry_maps["extraction_types"]
|
|
31
|
+
|
|
32
|
+
# Valid source_kind values for Source entries (per raw-source spec)
|
|
33
|
+
SOURCE_KINDS = frozenset({"article", "transcript", "pdf", "video", "link", "other"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SourceImmutableError(Exception):
|
|
37
|
+
"""Raised when attempting to modify an existing Source entry."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _slug(text: str, max_len: int = 50) -> str:
|
|
41
|
+
s = _re.sub(r"[^a-z0-9]+", "-", text.lower()[:max_len])
|
|
42
|
+
return s.strip("-") or "entry"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _first_non_heading_line(content: str) -> str:
|
|
46
|
+
"""Return the first line of `content` that is not a Markdown heading.
|
|
47
|
+
|
|
48
|
+
Used to derive a one-sentence `description` when the caller did not provide
|
|
49
|
+
one. Returns empty string if every line is a heading.
|
|
50
|
+
"""
|
|
51
|
+
for line in content.splitlines():
|
|
52
|
+
s = line.strip()
|
|
53
|
+
if not s:
|
|
54
|
+
continue
|
|
55
|
+
if s.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
return s[:200]
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_frontmatter(text: str) -> tuple[dict | None, str | None]:
|
|
62
|
+
"""Tiny YAML frontmatter parser for OKF-style blocks.
|
|
63
|
+
|
|
64
|
+
Handles flat `key: value` pairs, inline arrays `[a, b, c]`, and quoted
|
|
65
|
+
strings. Returns (frontmatter_dict, error). Error is None on success;
|
|
66
|
+
frontmatter_dict is None on failure.
|
|
67
|
+
"""
|
|
68
|
+
lines = text.splitlines()
|
|
69
|
+
if not lines or lines[0].rstrip() != "---":
|
|
70
|
+
return None, "no frontmatter block (file must start with `---`)"
|
|
71
|
+
end = None
|
|
72
|
+
for i in range(1, len(lines)):
|
|
73
|
+
if lines[i].rstrip() == "---":
|
|
74
|
+
end = i
|
|
75
|
+
break
|
|
76
|
+
if end is None:
|
|
77
|
+
return None, "unterminated frontmatter block"
|
|
78
|
+
|
|
79
|
+
fm: dict = {}
|
|
80
|
+
for raw in lines[1:end]:
|
|
81
|
+
stripped = raw.strip()
|
|
82
|
+
if not stripped or stripped.startswith("#"):
|
|
83
|
+
continue
|
|
84
|
+
if ":" not in stripped:
|
|
85
|
+
return None, f"malformed frontmatter line: {raw!r}"
|
|
86
|
+
key, _, value = stripped.partition(":")
|
|
87
|
+
key = key.strip()
|
|
88
|
+
value = value.strip()
|
|
89
|
+
fm[key] = _parse_value(value)
|
|
90
|
+
return fm, None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_value(raw: str):
|
|
94
|
+
if not raw:
|
|
95
|
+
return ""
|
|
96
|
+
if raw.startswith("[") and raw.endswith("]"):
|
|
97
|
+
inner = raw[1:-1].strip()
|
|
98
|
+
if not inner:
|
|
99
|
+
return []
|
|
100
|
+
return [item.strip().strip('"').strip("'") for item in inner.split(",")]
|
|
101
|
+
if (raw.startswith('"') and raw.endswith('"')) or (
|
|
102
|
+
raw.startswith("'") and raw.endswith("'")
|
|
103
|
+
):
|
|
104
|
+
return raw[1:-1]
|
|
105
|
+
if _re.fullmatch(r"-?\d+(\.\d+)?", raw):
|
|
106
|
+
return float(raw) if "." in raw else int(raw)
|
|
107
|
+
return raw
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write_okf_file(
|
|
111
|
+
memory_path: Path,
|
|
112
|
+
project: str | None,
|
|
113
|
+
entry_type: str, # plural dir name: decisions | facts | learnings | conventions | raw
|
|
114
|
+
content: str,
|
|
115
|
+
description: str,
|
|
116
|
+
tags: list[str] | None = None,
|
|
117
|
+
resource: str | None = None,
|
|
118
|
+
openspec_change_id: str | None = None,
|
|
119
|
+
confidence: float | None = None,
|
|
120
|
+
timestamp: str | None = None,
|
|
121
|
+
title: str | None = None,
|
|
122
|
+
extra_fields: dict[str, str] | None = None,
|
|
123
|
+
) -> Path:
|
|
124
|
+
"""Write an OKF Markdown file and return its path.
|
|
125
|
+
|
|
126
|
+
Slug collisions get a numeric suffix so distinct content with the same
|
|
127
|
+
first-line slug is not silently overwritten. When `project` is None the
|
|
128
|
+
file lands in `memory_path/raw/` (Source entries); `extra_fields` are
|
|
129
|
+
appended to the frontmatter after the standard fields.
|
|
130
|
+
"""
|
|
131
|
+
ts = timestamp or datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
132
|
+
date = ts[:10]
|
|
133
|
+
title = title or content.split("\n")[0][:80].strip() or description[:80]
|
|
134
|
+
slug = _slug(title)
|
|
135
|
+
|
|
136
|
+
if entry_type in ("facts", "raw"):
|
|
137
|
+
base_filename = f"{slug}.md"
|
|
138
|
+
else:
|
|
139
|
+
base_filename = f"{date}-{slug}.md"
|
|
140
|
+
|
|
141
|
+
if project is None:
|
|
142
|
+
okf_dir = memory_path / "raw"
|
|
143
|
+
else:
|
|
144
|
+
okf_dir = memory_path / "projects" / project / entry_type
|
|
145
|
+
okf_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
okf_path = okf_dir / base_filename
|
|
148
|
+
if okf_path.exists():
|
|
149
|
+
# Disambiguate by appending -2, -3, ...
|
|
150
|
+
stem = okf_path.stem
|
|
151
|
+
suffix_n = 2
|
|
152
|
+
while True:
|
|
153
|
+
candidate = okf_dir / f"{stem}-{suffix_n}.md"
|
|
154
|
+
if not candidate.exists():
|
|
155
|
+
okf_path = candidate
|
|
156
|
+
break
|
|
157
|
+
suffix_n += 1
|
|
158
|
+
|
|
159
|
+
type_label = _TYPE_LABEL_MAP.get(entry_type, entry_type[:-1].capitalize())
|
|
160
|
+
|
|
161
|
+
# OKF v0.1 frontmatter field order: type, title, description, resource,
|
|
162
|
+
# tags, timestamp. Custom fields (project, openspec_change_id, confidence,
|
|
163
|
+
# extra_fields) are appended after.
|
|
164
|
+
frontmatter_lines = [
|
|
165
|
+
"---",
|
|
166
|
+
f"type: {type_label}",
|
|
167
|
+
f"title: {title}",
|
|
168
|
+
f"description: {description}",
|
|
169
|
+
]
|
|
170
|
+
if resource:
|
|
171
|
+
frontmatter_lines.append(f"resource: {resource}")
|
|
172
|
+
frontmatter_lines.append(f"tags: {json.dumps(tags or [])}")
|
|
173
|
+
frontmatter_lines.append(f"timestamp: {ts}")
|
|
174
|
+
if project is not None:
|
|
175
|
+
frontmatter_lines.append(f"project: {project}")
|
|
176
|
+
if openspec_change_id:
|
|
177
|
+
frontmatter_lines.append(f"openspec_change_id: {openspec_change_id}")
|
|
178
|
+
if confidence is not None and entry_type in ("facts", "conventions"):
|
|
179
|
+
frontmatter_lines.append(f"confidence: {confidence}")
|
|
180
|
+
for key, value in (extra_fields or {}).items():
|
|
181
|
+
frontmatter_lines.append(f"{key}: {value}")
|
|
182
|
+
frontmatter_lines.append("---")
|
|
183
|
+
frontmatter_lines.append("")
|
|
184
|
+
frontmatter_lines.append(content)
|
|
185
|
+
|
|
186
|
+
okf_path.write_text("\n".join(frontmatter_lines), encoding="utf-8")
|
|
187
|
+
return okf_path
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_okf_file(path: Path) -> dict | None:
|
|
191
|
+
"""Parse an OKF Markdown file. Returns an entry dict or None on failure."""
|
|
192
|
+
try:
|
|
193
|
+
text = path.read_text(encoding="utf-8")
|
|
194
|
+
except Exception:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
fm, err = _parse_frontmatter(text)
|
|
198
|
+
if err or not fm:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
# Map type label (e.g., "Decision") -> dir (e.g., "decisions") -> singular
|
|
202
|
+
# (e.g., "decision"). Use a direct label->dir reverse map.
|
|
203
|
+
type_label = str(fm.get("type", ""))
|
|
204
|
+
label_to_dir = {v: k for k, v in _TYPE_LABEL_MAP.items()}
|
|
205
|
+
dir_name = label_to_dir.get(type_label, "")
|
|
206
|
+
entry_type = _DIR_TO_SINGULAR.get(dir_name, "")
|
|
207
|
+
if not entry_type:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
tags_raw = fm.get("tags", "[]")
|
|
211
|
+
if isinstance(tags_raw, list):
|
|
212
|
+
tags = tags_raw
|
|
213
|
+
elif isinstance(tags_raw, str):
|
|
214
|
+
try:
|
|
215
|
+
tags = json.loads(tags_raw)
|
|
216
|
+
except (json.JSONDecodeError, TypeError):
|
|
217
|
+
tags = []
|
|
218
|
+
else:
|
|
219
|
+
tags = []
|
|
220
|
+
|
|
221
|
+
confidence = 1.0
|
|
222
|
+
if "confidence" in fm:
|
|
223
|
+
try:
|
|
224
|
+
confidence = float(fm["confidence"])
|
|
225
|
+
except (TypeError, ValueError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
body_lines = text.split("---", 2)
|
|
229
|
+
content = body_lines[2].strip() if len(body_lines) >= 3 else ""
|
|
230
|
+
ts = str(fm.get("timestamp", ""))
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"id": path.stem,
|
|
234
|
+
"entry_type": entry_type,
|
|
235
|
+
"project": str(fm.get("project", path.parts[-3] if len(path.parts) >= 3 else "")),
|
|
236
|
+
"content": content,
|
|
237
|
+
"description": str(fm.get("description", "")),
|
|
238
|
+
"tags": json.dumps(tags),
|
|
239
|
+
"confidence": confidence,
|
|
240
|
+
"openspec_change_id": fm.get("openspec_change_id") or None,
|
|
241
|
+
"created_at": ts,
|
|
242
|
+
"updated_at": ts,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class MemoryStore:
|
|
247
|
+
"""SQLite + OKF Markdown dual storage. Markdown is source of truth."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, storage_path: Path):
|
|
250
|
+
self.storage_path = Path(storage_path)
|
|
251
|
+
self._db: sqlite3.Connection | None = None
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def db(self) -> sqlite3.Connection:
|
|
255
|
+
assert self._db is not None, "MemoryStore.initialize() must be called first"
|
|
256
|
+
return self._db
|
|
257
|
+
|
|
258
|
+
def initialize(self) -> None:
|
|
259
|
+
"""Open DB, set WAL, create tables + FTS5, validate integrity."""
|
|
260
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
(self.storage_path / "projects").mkdir(exist_ok=True)
|
|
262
|
+
|
|
263
|
+
db_path = self.storage_path / "memory.db"
|
|
264
|
+
if db_path.exists() and db_path.stat().st_size > 0:
|
|
265
|
+
try:
|
|
266
|
+
test_conn = sqlite3.connect(str(db_path))
|
|
267
|
+
row = test_conn.execute("PRAGMA integrity_check").fetchone()
|
|
268
|
+
test_conn.close()
|
|
269
|
+
if row[0].lower() != "ok":
|
|
270
|
+
raise RuntimeError(f"integrity check failed: {row[0]}")
|
|
271
|
+
except sqlite3.DatabaseError as e:
|
|
272
|
+
raise RuntimeError(f"corrupt database: {e}") from e
|
|
273
|
+
|
|
274
|
+
self._db = sqlite3.connect(str(db_path))
|
|
275
|
+
self._db.row_factory = sqlite3.Row
|
|
276
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
277
|
+
self.db.execute("PRAGMA foreign_keys=ON")
|
|
278
|
+
self.db.executescript(
|
|
279
|
+
"""
|
|
280
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
281
|
+
id TEXT PRIMARY KEY,
|
|
282
|
+
entry_type TEXT NOT NULL,
|
|
283
|
+
project TEXT NOT NULL,
|
|
284
|
+
content TEXT NOT NULL,
|
|
285
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
286
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
287
|
+
openspec_change_id TEXT,
|
|
288
|
+
dedup_key TEXT UNIQUE NOT NULL,
|
|
289
|
+
created_at TEXT NOT NULL,
|
|
290
|
+
updated_at TEXT NOT NULL
|
|
291
|
+
);
|
|
292
|
+
CREATE INDEX IF NOT EXISTS idx_project ON entries(project);
|
|
293
|
+
CREATE INDEX IF NOT EXISTS idx_entry_type ON entries(entry_type);
|
|
294
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
|
|
295
|
+
content, tags, content=entries, content_rowid=rowid
|
|
296
|
+
);
|
|
297
|
+
CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
|
|
298
|
+
INSERT INTO entries_fts(rowid, content, tags) VALUES (new.rowid, new.content, new.tags);
|
|
299
|
+
END;
|
|
300
|
+
CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
|
|
301
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags) VALUES ('delete', old.rowid, old.content, old.tags);
|
|
302
|
+
INSERT INTO entries_fts(rowid, content, tags) VALUES (new.rowid, new.content, new.tags);
|
|
303
|
+
END;
|
|
304
|
+
CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
|
|
305
|
+
INSERT INTO entries_fts(entries_fts, rowid, content, tags) VALUES ('delete', old.rowid, old.content, old.tags);
|
|
306
|
+
END;
|
|
307
|
+
"""
|
|
308
|
+
)
|
|
309
|
+
# The UNIQUE index is on dedup_key (enforced by the column constraint).
|
|
310
|
+
self.db.commit()
|
|
311
|
+
|
|
312
|
+
def _make_dedup_key(self, entry_type: str, project: str, content: str) -> str:
|
|
313
|
+
# Full-content hash per memory-store spec. No truncation.
|
|
314
|
+
key_str = f"{project}:{entry_type}:{content.strip().lower()}"
|
|
315
|
+
return hashlib.sha256(key_str.encode()).hexdigest()
|
|
316
|
+
|
|
317
|
+
def upsert_entry(
|
|
318
|
+
self,
|
|
319
|
+
entry_type: str,
|
|
320
|
+
project: str,
|
|
321
|
+
content: str,
|
|
322
|
+
tags: list[str] | None = None,
|
|
323
|
+
confidence: float = 1.0,
|
|
324
|
+
openspec_change_id: str | None = None,
|
|
325
|
+
min_confidence: float = 0.0,
|
|
326
|
+
description: str | None = None,
|
|
327
|
+
resource: str | None = None,
|
|
328
|
+
) -> dict:
|
|
329
|
+
"""Insert or update an entry. Returns dict with 'created'/'updated' flags.
|
|
330
|
+
|
|
331
|
+
Primary storage: OKF Markdown files under projects/<project>/<type>/.
|
|
332
|
+
Secondary index: SQLite entries table (kept in sync).
|
|
333
|
+
"""
|
|
334
|
+
if entry_type not in VALID_TYPES or entry_type in ("profile", "source"):
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"invalid entry_type: {entry_type!r}. "
|
|
337
|
+
f"Must be one of {VALID_TYPES - {'profile', 'source'}}"
|
|
338
|
+
)
|
|
339
|
+
if not content or not content.strip():
|
|
340
|
+
raise ValueError("content must not be empty")
|
|
341
|
+
if not project or not project.strip():
|
|
342
|
+
raise ValueError("project must not be empty")
|
|
343
|
+
if not (0.0 <= confidence <= 1.0):
|
|
344
|
+
raise ValueError(f"confidence must be between 0.0 and 1.0, got {confidence}")
|
|
345
|
+
if confidence < min_confidence:
|
|
346
|
+
raise ValueError(f"confidence {confidence} is below minimum {min_confidence}")
|
|
347
|
+
|
|
348
|
+
# Derive description from content when not provided.
|
|
349
|
+
if not description or not description.strip():
|
|
350
|
+
description = _first_non_heading_line(content) or content[:120]
|
|
351
|
+
description = description.strip()
|
|
352
|
+
|
|
353
|
+
tags = tags or []
|
|
354
|
+
tags_json = json.dumps(tags)
|
|
355
|
+
dedup_key = self._make_dedup_key(entry_type, project, content)
|
|
356
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
357
|
+
|
|
358
|
+
# 1. Dedup check against SQLite (derived index) before writing any file
|
|
359
|
+
existing = self.db.execute(
|
|
360
|
+
"SELECT * FROM entries WHERE dedup_key = ?", (dedup_key,)
|
|
361
|
+
).fetchone()
|
|
362
|
+
|
|
363
|
+
if existing:
|
|
364
|
+
self.db.execute(
|
|
365
|
+
"""UPDATE entries
|
|
366
|
+
SET content = ?, tags = ?, confidence = ?,
|
|
367
|
+
openspec_change_id = ?, updated_at = ?
|
|
368
|
+
WHERE dedup_key = ?""",
|
|
369
|
+
(content, tags_json, confidence, openspec_change_id, now, dedup_key),
|
|
370
|
+
)
|
|
371
|
+
self.db.commit()
|
|
372
|
+
row = self.db.execute(
|
|
373
|
+
"SELECT * FROM entries WHERE dedup_key = ?", (dedup_key,)
|
|
374
|
+
).fetchone()
|
|
375
|
+
result = dict(row)
|
|
376
|
+
result["created"] = False
|
|
377
|
+
result["updated"] = True
|
|
378
|
+
self._regenerate_project_index(project)
|
|
379
|
+
return result
|
|
380
|
+
else:
|
|
381
|
+
# 2. Write OKF file (primary storage) only for new entries
|
|
382
|
+
okf_type = _TYPE_DIR_MAP[entry_type]
|
|
383
|
+
_write_okf_file(
|
|
384
|
+
memory_path=self.storage_path,
|
|
385
|
+
project=project,
|
|
386
|
+
entry_type=okf_type,
|
|
387
|
+
content=content,
|
|
388
|
+
description=description,
|
|
389
|
+
tags=tags,
|
|
390
|
+
resource=resource,
|
|
391
|
+
openspec_change_id=openspec_change_id,
|
|
392
|
+
confidence=confidence if entry_type in ("fact", "convention") else None,
|
|
393
|
+
timestamp=now,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
entry_id = str(uuid.uuid4())
|
|
397
|
+
self._insert_row(
|
|
398
|
+
entry_id,
|
|
399
|
+
entry_type,
|
|
400
|
+
project,
|
|
401
|
+
content,
|
|
402
|
+
tags_json,
|
|
403
|
+
confidence,
|
|
404
|
+
openspec_change_id,
|
|
405
|
+
dedup_key,
|
|
406
|
+
now,
|
|
407
|
+
now,
|
|
408
|
+
)
|
|
409
|
+
row = self.db.execute(
|
|
410
|
+
"SELECT * FROM entries WHERE id = ?", (entry_id,)
|
|
411
|
+
).fetchone()
|
|
412
|
+
result = dict(row)
|
|
413
|
+
result["created"] = True
|
|
414
|
+
result["updated"] = False
|
|
415
|
+
self._regenerate_project_index(project)
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
def _insert_row(
|
|
419
|
+
self,
|
|
420
|
+
entry_id: str,
|
|
421
|
+
entry_type: str,
|
|
422
|
+
project: str,
|
|
423
|
+
content: str,
|
|
424
|
+
tags_json: str,
|
|
425
|
+
confidence: float,
|
|
426
|
+
openspec_change_id: str | None,
|
|
427
|
+
dedup_key: str,
|
|
428
|
+
created_at: str,
|
|
429
|
+
updated_at: str,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Insert (or replace) one row in the entries table and commit."""
|
|
432
|
+
self.db.execute(
|
|
433
|
+
"""INSERT OR REPLACE INTO entries
|
|
434
|
+
(id, entry_type, project, content, tags, confidence,
|
|
435
|
+
openspec_change_id, dedup_key, created_at, updated_at)
|
|
436
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
437
|
+
(
|
|
438
|
+
entry_id,
|
|
439
|
+
entry_type,
|
|
440
|
+
project,
|
|
441
|
+
content,
|
|
442
|
+
tags_json,
|
|
443
|
+
confidence,
|
|
444
|
+
openspec_change_id,
|
|
445
|
+
dedup_key,
|
|
446
|
+
created_at,
|
|
447
|
+
updated_at,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
self.db.commit()
|
|
451
|
+
|
|
452
|
+
def upsert_profile(
|
|
453
|
+
self,
|
|
454
|
+
project: str,
|
|
455
|
+
content: str,
|
|
456
|
+
tags: list[str] | None = None,
|
|
457
|
+
) -> dict:
|
|
458
|
+
"""Insert or update a profile entry for a project.
|
|
459
|
+
|
|
460
|
+
Profiles deduplicate by ``(project, entry_type)``, not by content hash.
|
|
461
|
+
Each project has at most one profile. No OKF file is written — profiles
|
|
462
|
+
are internal metadata, not human-readable entries.
|
|
463
|
+
"""
|
|
464
|
+
if not content or not content.strip():
|
|
465
|
+
raise ValueError("content must not be empty")
|
|
466
|
+
if not project or not project.strip():
|
|
467
|
+
raise ValueError("project must not be empty")
|
|
468
|
+
|
|
469
|
+
tags = tags or []
|
|
470
|
+
tags_json = json.dumps(tags)
|
|
471
|
+
dedup_key = f"profile:{project}"
|
|
472
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
473
|
+
entry_type = "profile"
|
|
474
|
+
|
|
475
|
+
existing = self.db.execute(
|
|
476
|
+
"SELECT * FROM entries WHERE project = ? AND entry_type = ?",
|
|
477
|
+
(project, entry_type),
|
|
478
|
+
).fetchone()
|
|
479
|
+
|
|
480
|
+
if existing:
|
|
481
|
+
self.db.execute(
|
|
482
|
+
"""UPDATE entries
|
|
483
|
+
SET content = ?, tags = ?, updated_at = ?
|
|
484
|
+
WHERE project = ? AND entry_type = ?""",
|
|
485
|
+
(content, tags_json, now, project, entry_type),
|
|
486
|
+
)
|
|
487
|
+
self.db.commit()
|
|
488
|
+
row = self.db.execute(
|
|
489
|
+
"SELECT * FROM entries WHERE project = ? AND entry_type = ?",
|
|
490
|
+
(project, entry_type),
|
|
491
|
+
).fetchone()
|
|
492
|
+
result = dict(row)
|
|
493
|
+
result["created"] = False
|
|
494
|
+
result["updated"] = True
|
|
495
|
+
return result
|
|
496
|
+
else:
|
|
497
|
+
entry_id = str(uuid.uuid4())
|
|
498
|
+
self.db.execute(
|
|
499
|
+
"""INSERT INTO entries
|
|
500
|
+
(id, entry_type, project, content, tags, confidence,
|
|
501
|
+
openspec_change_id, dedup_key, created_at, updated_at)
|
|
502
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
503
|
+
(
|
|
504
|
+
entry_id,
|
|
505
|
+
entry_type,
|
|
506
|
+
project,
|
|
507
|
+
content,
|
|
508
|
+
tags_json,
|
|
509
|
+
1.0,
|
|
510
|
+
None,
|
|
511
|
+
dedup_key,
|
|
512
|
+
now,
|
|
513
|
+
now,
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
self.db.commit()
|
|
517
|
+
row = self.db.execute(
|
|
518
|
+
"SELECT * FROM entries WHERE id = ?", (entry_id,)
|
|
519
|
+
).fetchone()
|
|
520
|
+
result = dict(row)
|
|
521
|
+
result["created"] = True
|
|
522
|
+
result["updated"] = False
|
|
523
|
+
return result
|
|
524
|
+
|
|
525
|
+
def store_source(
|
|
526
|
+
self,
|
|
527
|
+
url: str,
|
|
528
|
+
title: str,
|
|
529
|
+
description: str,
|
|
530
|
+
source_kind: str,
|
|
531
|
+
tags: list[str] | None = None,
|
|
532
|
+
content: str | None = None,
|
|
533
|
+
supersedes: str | None = None,
|
|
534
|
+
) -> dict:
|
|
535
|
+
"""Store a Source entry under memory/raw/<slug>.md.
|
|
536
|
+
|
|
537
|
+
Dedup key: (source_url, slug). Same URL returns existing slug.
|
|
538
|
+
Same slug + different content raises SourceImmutableError.
|
|
539
|
+
"""
|
|
540
|
+
if not url or not url.strip():
|
|
541
|
+
raise ValueError("url must not be empty")
|
|
542
|
+
if not title or not title.strip():
|
|
543
|
+
raise ValueError("title must not be empty")
|
|
544
|
+
if not description or not description.strip():
|
|
545
|
+
raise ValueError("description must not be empty")
|
|
546
|
+
if source_kind not in SOURCE_KINDS:
|
|
547
|
+
raise ValueError(
|
|
548
|
+
f"invalid source_kind: {source_kind!r}. "
|
|
549
|
+
f"Must be one of {sorted(SOURCE_KINDS)}"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
slug = _slug(title)
|
|
553
|
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
554
|
+
|
|
555
|
+
# Check dedup by source_url first (stored in dedup_key)
|
|
556
|
+
url_dedup_key = f"source:url:{url}"
|
|
557
|
+
existing_by_url = self.db.execute(
|
|
558
|
+
"SELECT * FROM entries WHERE dedup_key = ?",
|
|
559
|
+
(url_dedup_key,),
|
|
560
|
+
).fetchone()
|
|
561
|
+
if existing_by_url:
|
|
562
|
+
return {
|
|
563
|
+
"id": existing_by_url["id"],
|
|
564
|
+
"slug": existing_by_url["id"],
|
|
565
|
+
"created": False,
|
|
566
|
+
"updated": False,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# Slug collision: raise if a different URL reuses the slug with
|
|
570
|
+
# different content; otherwise _write_okf_file disambiguates with a
|
|
571
|
+
# numeric suffix.
|
|
572
|
+
raw_dir = self.storage_path / "raw"
|
|
573
|
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
okf_path = raw_dir / f"{slug}.md"
|
|
575
|
+
if okf_path.exists():
|
|
576
|
+
existing_text = okf_path.read_text(encoding="utf-8")
|
|
577
|
+
new_body = content or ""
|
|
578
|
+
if new_body and new_body not in existing_text:
|
|
579
|
+
raise SourceImmutableError(
|
|
580
|
+
f"source slug {slug!r} already exists with different content"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
extra_fields = {
|
|
584
|
+
"source_url": url,
|
|
585
|
+
"source_kind": source_kind,
|
|
586
|
+
"captured_at": now,
|
|
587
|
+
}
|
|
588
|
+
if supersedes:
|
|
589
|
+
extra_fields["supersedes"] = supersedes
|
|
590
|
+
okf_path = _write_okf_file(
|
|
591
|
+
memory_path=self.storage_path,
|
|
592
|
+
project=None,
|
|
593
|
+
entry_type="raw",
|
|
594
|
+
content=content or "",
|
|
595
|
+
description=description,
|
|
596
|
+
title=title,
|
|
597
|
+
tags=tags,
|
|
598
|
+
timestamp=now,
|
|
599
|
+
extra_fields=extra_fields,
|
|
600
|
+
)
|
|
601
|
+
slug = okf_path.stem
|
|
602
|
+
|
|
603
|
+
# Insert into SQLite
|
|
604
|
+
entry_id = slug
|
|
605
|
+
# Dedup key uses URL so same URL always maps to same entry
|
|
606
|
+
dedup_key = f"source:url:{url}"
|
|
607
|
+
tags_json = json.dumps(tags or [])
|
|
608
|
+
self._insert_row(
|
|
609
|
+
entry_id,
|
|
610
|
+
"source",
|
|
611
|
+
"",
|
|
612
|
+
content or "",
|
|
613
|
+
tags_json,
|
|
614
|
+
1.0,
|
|
615
|
+
None,
|
|
616
|
+
dedup_key,
|
|
617
|
+
now,
|
|
618
|
+
now,
|
|
619
|
+
)
|
|
620
|
+
return {
|
|
621
|
+
"id": entry_id,
|
|
622
|
+
"slug": slug,
|
|
623
|
+
"created": True,
|
|
624
|
+
"updated": False,
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
def _scan_sources(self) -> list[dict]:
|
|
628
|
+
"""Scan raw/ for Source entries, sorted by file name descending."""
|
|
629
|
+
raw_dir = self.storage_path / "raw"
|
|
630
|
+
if not raw_dir.is_dir():
|
|
631
|
+
return []
|
|
632
|
+
entries: list[dict] = []
|
|
633
|
+
for f in sorted(raw_dir.glob("*.md"), key=lambda p: p.name, reverse=True):
|
|
634
|
+
entry = _parse_okf_file(f)
|
|
635
|
+
if entry:
|
|
636
|
+
entry["project"] = ""
|
|
637
|
+
entries.append(entry)
|
|
638
|
+
return entries
|
|
639
|
+
|
|
640
|
+
def search_entries(
|
|
641
|
+
self,
|
|
642
|
+
project: str | None = None,
|
|
643
|
+
entry_type: str | None = None,
|
|
644
|
+
tags: list[str] | None = None,
|
|
645
|
+
query: str | None = None,
|
|
646
|
+
max_results: int = 50,
|
|
647
|
+
) -> list[dict]:
|
|
648
|
+
"""Search entries.
|
|
649
|
+
|
|
650
|
+
FTS queries rank by relevance (bm25: content weight 1.0, tags weight
|
|
651
|
+
3.0) with updated_at as tiebreak; each result gains a ``score`` field.
|
|
652
|
+
Non-FTS queries scan the OKF Markdown files (the source of truth) and
|
|
653
|
+
sort by updated_at desc. Tag filters apply before the result limit.
|
|
654
|
+
"""
|
|
655
|
+
if query and query.strip():
|
|
656
|
+
# FTS5 parses hyphens as column operators and bare words as
|
|
657
|
+
# boolean operators; quote each token to force literal matching.
|
|
658
|
+
fts_query = " ".join(f'"{t}"' for t in query.replace('"', "").split())
|
|
659
|
+
fts_sql = (
|
|
660
|
+
"SELECT rowid, bm25(entries_fts, 1.0, 3.0) AS score "
|
|
661
|
+
"FROM entries_fts WHERE entries_fts MATCH ?"
|
|
662
|
+
)
|
|
663
|
+
fts_rows = self.db.execute(fts_sql, (fts_query,)).fetchall()
|
|
664
|
+
rowids = [r[0] for r in fts_rows]
|
|
665
|
+
if not rowids:
|
|
666
|
+
return []
|
|
667
|
+
placeholders = ",".join("?" * len(rowids))
|
|
668
|
+
base_sql = (
|
|
669
|
+
f"SELECT e.*, f.score FROM entries e "
|
|
670
|
+
f"JOIN ({fts_sql}) f ON f.rowid = e.rowid "
|
|
671
|
+
f"WHERE e.rowid IN ({placeholders})"
|
|
672
|
+
)
|
|
673
|
+
params: list = [fts_query, *rowids]
|
|
674
|
+
if entry_type == "source":
|
|
675
|
+
base_sql += " AND e.entry_type = 'source'"
|
|
676
|
+
elif project or entry_type:
|
|
677
|
+
# Project-filtered and typed searches exclude Sources.
|
|
678
|
+
base_sql += " AND e.entry_type != 'source'"
|
|
679
|
+
if project:
|
|
680
|
+
base_sql += " AND e.project = ?"
|
|
681
|
+
params.append(project)
|
|
682
|
+
if entry_type:
|
|
683
|
+
base_sql += " AND e.entry_type = ?"
|
|
684
|
+
params.append(entry_type)
|
|
685
|
+
if tags:
|
|
686
|
+
tag_ph = ",".join("?" * len(tags))
|
|
687
|
+
base_sql += (
|
|
688
|
+
f" AND EXISTS (SELECT 1 FROM json_each(e.tags) "
|
|
689
|
+
f"WHERE json_each.value IN ({tag_ph}))"
|
|
690
|
+
)
|
|
691
|
+
params.extend(tags)
|
|
692
|
+
# bm25() returns negative scores: more negative = more relevant,
|
|
693
|
+
# so rank ascending; updated_at breaks ties.
|
|
694
|
+
base_sql += " ORDER BY f.score ASC, e.updated_at DESC LIMIT ?"
|
|
695
|
+
params.append(max_results)
|
|
696
|
+
rows = self.db.execute(base_sql, params).fetchall()
|
|
697
|
+
results = [dict(r) for r in rows]
|
|
698
|
+
for r in results:
|
|
699
|
+
r["score"] = round(r["score"], 3)
|
|
700
|
+
return results
|
|
701
|
+
|
|
702
|
+
results: list[dict] = []
|
|
703
|
+
projects_dir = self.storage_path / "projects"
|
|
704
|
+
if projects_dir.is_dir():
|
|
705
|
+
if project:
|
|
706
|
+
proj_candidates = [projects_dir / project]
|
|
707
|
+
else:
|
|
708
|
+
proj_candidates = sorted(p for p in projects_dir.iterdir() if p.is_dir())
|
|
709
|
+
|
|
710
|
+
for proj_dir in proj_candidates:
|
|
711
|
+
if not proj_dir.is_dir():
|
|
712
|
+
continue
|
|
713
|
+
proj_name = proj_dir.name
|
|
714
|
+
|
|
715
|
+
if entry_type:
|
|
716
|
+
if entry_type == "source":
|
|
717
|
+
# Sources are not in project dirs
|
|
718
|
+
continue
|
|
719
|
+
type_subdirs = [_TYPE_DIR_MAP.get(entry_type, entry_type + "s")]
|
|
720
|
+
else:
|
|
721
|
+
type_subdirs = list(_TYPE_DIR_MAP.values())
|
|
722
|
+
|
|
723
|
+
for tdir in type_subdirs:
|
|
724
|
+
type_path = proj_dir / tdir
|
|
725
|
+
if not type_path.is_dir():
|
|
726
|
+
continue
|
|
727
|
+
for f in sorted(
|
|
728
|
+
type_path.iterdir(), key=lambda p: p.name, reverse=True
|
|
729
|
+
):
|
|
730
|
+
if f.suffix == ".md":
|
|
731
|
+
entry = _parse_okf_file(f)
|
|
732
|
+
if entry:
|
|
733
|
+
entry["project"] = proj_name
|
|
734
|
+
results.append(entry)
|
|
735
|
+
|
|
736
|
+
# Include Sources when no project filter and no entry_type filter
|
|
737
|
+
# (or entry_type=source). Handles a missing projects dir too.
|
|
738
|
+
if not project and (entry_type is None or entry_type == "source"):
|
|
739
|
+
results.extend(self._scan_sources())
|
|
740
|
+
|
|
741
|
+
if tags:
|
|
742
|
+
results = [
|
|
743
|
+
r
|
|
744
|
+
for r in results
|
|
745
|
+
if any(t in json.loads(r.get("tags") or "[]") for t in tags)
|
|
746
|
+
]
|
|
747
|
+
|
|
748
|
+
results.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
|
749
|
+
return results[:max_results]
|
|
750
|
+
|
|
751
|
+
def export_entries(
|
|
752
|
+
self,
|
|
753
|
+
project: str,
|
|
754
|
+
entry_type: str | None = None,
|
|
755
|
+
tags: list[str] | None = None,
|
|
756
|
+
) -> list[dict]:
|
|
757
|
+
"""Export all entries for a project, optionally filtered by type/tags."""
|
|
758
|
+
return self.search_entries(
|
|
759
|
+
project=project,
|
|
760
|
+
entry_type=entry_type,
|
|
761
|
+
tags=tags,
|
|
762
|
+
max_results=100000,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def get_profile(
|
|
766
|
+
self,
|
|
767
|
+
project: str,
|
|
768
|
+
entry_type: str | None = None,
|
|
769
|
+
) -> list[dict]:
|
|
770
|
+
"""Retrieve profile entries for a project."""
|
|
771
|
+
sql = "SELECT * FROM entries WHERE project = ?"
|
|
772
|
+
params: list = [project]
|
|
773
|
+
if entry_type:
|
|
774
|
+
sql += " AND entry_type = ?"
|
|
775
|
+
params.append(entry_type)
|
|
776
|
+
sql += " ORDER BY updated_at DESC LIMIT 10"
|
|
777
|
+
rows = self.db.execute(sql, params).fetchall()
|
|
778
|
+
return [dict(r) for r in rows]
|
|
779
|
+
|
|
780
|
+
def _regenerate_project_index(self, project: str) -> None:
|
|
781
|
+
"""Regenerate `<project>/index.md` with links to all entries in the project.
|
|
782
|
+
|
|
783
|
+
Atomic write: write to `.tmp` then `os.replace`.
|
|
784
|
+
"""
|
|
785
|
+
proj_dir = self.storage_path / "projects" / project
|
|
786
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
787
|
+
|
|
788
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
789
|
+
lines: list[str] = [
|
|
790
|
+
"---",
|
|
791
|
+
"type: Index",
|
|
792
|
+
f"title: {project}",
|
|
793
|
+
f"description: Project index for {project}.",
|
|
794
|
+
"resource: ",
|
|
795
|
+
"tags: [index]",
|
|
796
|
+
f'timestamp: "{now}"',
|
|
797
|
+
"---",
|
|
798
|
+
"",
|
|
799
|
+
f"# {project}\n",
|
|
800
|
+
]
|
|
801
|
+
for tdir in _type_order:
|
|
802
|
+
tpath = proj_dir / tdir
|
|
803
|
+
if not tpath.is_dir():
|
|
804
|
+
continue
|
|
805
|
+
label = _TYPE_LABEL_MAP.get(tdir, tdir.capitalize())
|
|
806
|
+
entries = sorted(
|
|
807
|
+
(f for f in tpath.iterdir() if f.suffix == ".md"),
|
|
808
|
+
key=lambda p: p.name,
|
|
809
|
+
reverse=True,
|
|
810
|
+
)
|
|
811
|
+
if not entries:
|
|
812
|
+
continue
|
|
813
|
+
lines.append(f"## {label}\n")
|
|
814
|
+
for f in entries:
|
|
815
|
+
parsed = _parse_okf_file(f)
|
|
816
|
+
if not parsed:
|
|
817
|
+
continue
|
|
818
|
+
rel = f"{tdir}/{f.name}"
|
|
819
|
+
title = parsed.get("description") or f.stem
|
|
820
|
+
lines.append(f"- [{title}]({rel})")
|
|
821
|
+
lines.append("")
|
|
822
|
+
|
|
823
|
+
content = "\n".join(lines).rstrip() + "\n"
|
|
824
|
+
tmp_path = proj_dir / "index.md.tmp"
|
|
825
|
+
final_path = proj_dir / "index.md"
|
|
826
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
827
|
+
os.replace(tmp_path, final_path)
|