@luisarg/memory-auto 0.1.0 → 0.1.2

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.
@@ -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)