abstractnote 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. abstractnote/__init__.py +18 -0
  2. abstractnote/cli.py +240 -0
  3. abstractnote/flows/__init__.py +2 -0
  4. abstractnote/flows/build.py +970 -0
  5. abstractnote/flows/bundle.py +62 -0
  6. abstractnote/flows/contract.py +88 -0
  7. abstractnote/flows/graph.py +540 -0
  8. abstractnote/flows/json/note-capture.json +2280 -0
  9. abstractnote/flows/json/note-drain.json +2012 -0
  10. abstractnote/flows/json/note-mine.json +2392 -0
  11. abstractnote/flows/json/note-process.json +1853 -0
  12. abstractnote/flows/json/note-recall.json +1876 -0
  13. abstractnote/flows/json/note-reflect.json +2057 -0
  14. abstractnote/flows/prompts_fallback.py +119 -0
  15. abstractnote/flows/runner.py +320 -0
  16. abstractnote/flows/schemas_fallback.py +175 -0
  17. abstractnote/gateway/__init__.py +24 -0
  18. abstractnote/gateway/appspec.py +10 -0
  19. abstractnote/gateway/router.py +571 -0
  20. abstractnote/gateway/tools.py +74 -0
  21. abstractnote/ids.py +177 -0
  22. abstractnote/importers/__init__.py +142 -0
  23. abstractnote/importers/__main__.py +3 -0
  24. abstractnote/importers/agora.py +203 -0
  25. abstractnote/importers/claude_sessions.py +415 -0
  26. abstractnote/importers/cli.py +113 -0
  27. abstractnote/importers/files.py +72 -0
  28. abstractnote/importers/markdown.py +165 -0
  29. abstractnote/ingest/__init__.py +7 -0
  30. abstractnote/ingest/blobs.py +47 -0
  31. abstractnote/ingest/capture.py +265 -0
  32. abstractnote/ingest/extract.py +118 -0
  33. abstractnote/ingest/normalise.py +95 -0
  34. abstractnote/model.py +229 -0
  35. abstractnote/process/__init__.py +5 -0
  36. abstractnote/process/context.py +218 -0
  37. abstractnote/process/merge.py +60 -0
  38. abstractnote/process/prompts/__init__.py +39 -0
  39. abstractnote/process/prompts/extract.md +30 -0
  40. abstractnote/process/prompts/link.md +32 -0
  41. abstractnote/process/prompts/mine_dossier.md +13 -0
  42. abstractnote/process/prompts/mine_findings.md +13 -0
  43. abstractnote/process/prompts/mine_plan.md +13 -0
  44. abstractnote/process/prompts/quick_filing.md +23 -0
  45. abstractnote/process/prompts/reflect.md +10 -0
  46. abstractnote/process/prompts/synthesize.md +17 -0
  47. abstractnote/process/resolve.py +96 -0
  48. abstractnote/process/revise.py +410 -0
  49. abstractnote/process/schemas.py +377 -0
  50. abstractnote/process/vocabulary.py +68 -0
  51. abstractnote/recall/__init__.py +1 -0
  52. abstractnote/recall/changes.py +191 -0
  53. abstractnote/recall/expand.py +379 -0
  54. abstractnote/recall/insights.py +62 -0
  55. abstractnote/recall/reflect.py +202 -0
  56. abstractnote/recall/review.py +191 -0
  57. abstractnote/recall/search.py +205 -0
  58. abstractnote/recall/shapes.py +52 -0
  59. abstractnote/recall/then_now.py +100 -0
  60. abstractnote/store/__init__.py +12 -0
  61. abstractnote/store/archive.py +166 -0
  62. abstractnote/store/embeddings.py +275 -0
  63. abstractnote/store/kg.py +219 -0
  64. abstractnote/store/notebook.py +829 -0
  65. abstractnote/store/notes.py +1190 -0
  66. abstractnote/store/queue.py +159 -0
  67. abstractnote/store/schema.py +348 -0
  68. abstractnote/store/vector_index.py +88 -0
  69. abstractnote/tools/__init__.py +215 -0
  70. abstractnote/tools/capture.py +121 -0
  71. abstractnote/tools/context.py +19 -0
  72. abstractnote/tools/process.py +22 -0
  73. abstractnote/tools/queue.py +23 -0
  74. abstractnote/tools/recall.py +64 -0
  75. abstractnote-0.1.0.dist-info/METADATA +145 -0
  76. abstractnote-0.1.0.dist-info/RECORD +80 -0
  77. abstractnote-0.1.0.dist-info/WHEEL +5 -0
  78. abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
  79. abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
  80. abstractnote-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,829 @@
1
+ """``Notebook``: one directory, two stores, one revision log.
2
+
3
+ Layout of a notebook directory (``<plane>/abstractnote/`` inside a gateway)::
4
+
5
+ note.sqlite3 the note index (truth tables + projections)
6
+ kg.sqlite3 AbstractMemory SQLiteTripleStore + SQLiteJournal (claims)
7
+ blobs/ content-addressed attachment bytes
8
+ extracted/ text extracted from attachments
9
+
10
+ Revisions are the truth. :meth:`Notebook.apply_revision` is the only path that
11
+ changes entities, claims, relations, topics and tasks, and it is idempotent.
12
+ Write order: revision row (intent) -> index projection (one transaction that
13
+ sets ``applied_index``) -> vectors -> AbstractMemory projection -> ``applied_kg``.
14
+ :meth:`Notebook.recover` replays revisions whose flags are not both set.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ import sqlite3
22
+ import threading
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence
25
+
26
+ from .. import __version__, ids
27
+ from ..model import Revision, RevisionOp
28
+ from .embeddings import (
29
+ HashingEmbedder,
30
+ TextEmbedder,
31
+ VectorCache,
32
+ embedder_for_pin,
33
+ embedder_model_id,
34
+ vector_key,
35
+ )
36
+ from .kg import ClaimStore, claim_assertion, kinds_from_index
37
+ from .notes import NoteStore, _j, _uj, entity_text, note_vector_text
38
+
39
+ NOTE_DB = "note.sqlite3"
40
+ KG_DB = "kg.sqlite3"
41
+ NOTE_EMBED_BUDGET = 8000 # characters of a note handed to the embedder (the stored note is never cut)
42
+
43
+ DEFAULT_SETTINGS: Dict[str, Any] = {
44
+ "filing_model": {"provider": None, "model": None},
45
+ "answering_model": {"provider": None, "model": None},
46
+ "reflect_schedule": {"every": "7d"},
47
+ # The processor automation's throughput: up to ``max_items`` notes per drain, one drain ``every``.
48
+ # Equal to abstractnote.flows.build DRAIN_DEFAULTS / the note-drain trigger (a test keeps them equal).
49
+ "processor_schedule": {"every": "2m", "max_items": 20},
50
+ "process_inline_default": False,
51
+ }
52
+ PROCESSOR_MAX_ITEMS = 200
53
+
54
+
55
+ def _check_processor_schedule(value: Any) -> Dict[str, Any]:
56
+ """``{every: "<n>s|m|h|d", max_items: 1..200}`` or a plain sentence (ValueError)."""
57
+ if not isinstance(value, dict) or set(value) - {"every", "max_items"}:
58
+ raise ValueError("The processor schedule is {every, max_items}.")
59
+ every = str(value.get("every", DEFAULT_SETTINGS["processor_schedule"]["every"])).strip()
60
+ if not (every[:-1].isdigit() and every[-1:] in ("s", "m", "h", "d") and int(every[:-1]) > 0):
61
+ raise ValueError("The processor interval must be a number with s, m, h or d, for example 2m.")
62
+ max_items = value.get("max_items", DEFAULT_SETTINGS["processor_schedule"]["max_items"])
63
+ if isinstance(max_items, bool) or not isinstance(max_items, int) or not 1 <= max_items <= PROCESSOR_MAX_ITEMS:
64
+ raise ValueError(f"The processor takes between 1 and {PROCESSOR_MAX_ITEMS} notes at a time.")
65
+ return {"every": every, "max_items": max_items}
66
+
67
+
68
+ class _Keys:
69
+ """Vector keys for the index projection of one revision."""
70
+
71
+ def __init__(self, cache: VectorCache, rev: Dict[str, Any]) -> None:
72
+ self.cache = cache
73
+ self.rev = rev
74
+ self.texts: List[str] = []
75
+
76
+ def entity(self, text: str) -> str:
77
+ self.texts.append(text)
78
+ return vector_key(text, self.cache.model_id)
79
+
80
+ def claim(self, payload: Dict[str, Any], conn: sqlite3.Connection) -> str:
81
+ from abstractmemory.canonical_text import canonical_text
82
+
83
+ kinds = {r[0]: r[1] for r in conn.execute(
84
+ "SELECT entity_id, kind FROM entities WHERE entity_id IN (?, ?)",
85
+ (payload["subject_id"], payload.get("object_id") or ""))}
86
+ text = canonical_text(claim_assertion(payload, self.rev, kinds.get(payload["subject_id"]),
87
+ kinds.get(payload.get("object_id") or "")))
88
+ self.texts.append(text)
89
+ return vector_key(text, self.cache.model_id)
90
+
91
+
92
+ class Notebook:
93
+ """A notebook directory: the note index, the AbstractMemory projection, blobs and the queue."""
94
+
95
+ def __init__(self, root: Path, store: NoteStore, cache: VectorCache, kg: ClaimStore) -> None:
96
+ self.root = Path(root)
97
+ self.store = store
98
+ self.cache = cache
99
+ self.kg = kg
100
+ self._apply_lock = threading.RLock()
101
+ from .queue import Queue
102
+
103
+ self.queue = Queue(store)
104
+ from .vector_index import NoteVectorIndex
105
+
106
+ self.note_vectors = NoteVectorIndex(store)
107
+ # Injected processing helpers (bound by abstractnote.tools.bind or by callers)
108
+ self.extractors: Dict[str, Callable[..., Any]] = {}
109
+
110
+ # ------------------------------------------------------------------ open / close
111
+
112
+ @classmethod
113
+ def open(cls, root: "str | os.PathLike[str]", *, embedder: Optional[TextEmbedder] = None,
114
+ recover: bool = True) -> "Notebook":
115
+ """Open (and create when missing) a notebook directory.
116
+
117
+ ``embedder`` selects the embedding space. A new notebook without one uses
118
+ the deterministic :class:`HashingEmbedder`. A notebook pinned to another
119
+ model must be opened with that model (or rebuilt with ``reembed=True``).
120
+ """
121
+ root = Path(root).expanduser()
122
+ root.mkdir(parents=True, exist_ok=True)
123
+ (root / "blobs").mkdir(exist_ok=True)
124
+ (root / "extracted").mkdir(exist_ok=True)
125
+ store = NoteStore(str(root / NOTE_DB))
126
+ try:
127
+ cache = cls._make_cache(store, embedder)
128
+ kg = ClaimStore(root / KG_DB, cache)
129
+ except BaseException:
130
+ store.close()
131
+ raise
132
+ nb = cls(root, store, cache, kg)
133
+ if recover and nb.cache.embedder is not None:
134
+ nb.recover()
135
+ return nb
136
+
137
+ @staticmethod
138
+ def _make_cache(store: NoteStore, embedder: Optional[TextEmbedder], *, force_new_pin: bool = False
139
+ ) -> VectorCache:
140
+ pin = None if force_new_pin else store.meta_get("embedding_pin")
141
+ if pin:
142
+ if embedder is None:
143
+ embedder = embedder_for_pin(pin)
144
+ if embedder is None:
145
+ # Read-only for meaning: everything that needs no new vector still works.
146
+ cache = VectorCache(store.conn, store.lock, None, pin.get("model_id"), pin.get("dimension"))
147
+ cache.missing_embedder = (
148
+ f"This notebook's vectors were made with the embedding model {pin.get('model_id')!r}; "
149
+ "open it with that embedder to save notes, process them or search by meaning, or rebuild "
150
+ "it with reembed to change models.")
151
+ return cache
152
+ known = embedder_model_id(embedder)
153
+ if known and pin.get("model_id") and known != pin["model_id"] and pin["model_id"] != "unknown":
154
+ raise ValueError(
155
+ f"This notebook is pinned to the embedding model {pin['model_id']!r} but the embedder given is "
156
+ f"{known!r}; rebuild with reembed to change models."
157
+ )
158
+ return VectorCache(store.conn, store.lock, embedder, pin.get("model_id"), pin.get("dimension"))
159
+ if embedder is None:
160
+ embedder = HashingEmbedder()
161
+ model_id = embedder_model_id(embedder) or "unknown"
162
+ dimension = getattr(embedder, "dimension", None)
163
+ cache = VectorCache(store.conn, store.lock, embedder, model_id, dimension)
164
+ store.meta_set("embedding_pin", {"model_id": model_id, "dimension": dimension,
165
+ "pinned_at": ids.utc_now()})
166
+ return cache
167
+
168
+ def _sync_pin(self) -> None:
169
+ pin = self.store.meta_get("embedding_pin") or {}
170
+ if pin.get("dimension") is None and self.cache.dimension is not None:
171
+ pin["dimension"] = self.cache.dimension
172
+ self.store.meta_set("embedding_pin", pin)
173
+
174
+ def close(self) -> None:
175
+ self.kg.close()
176
+ self.store.close()
177
+
178
+ def __enter__(self) -> "Notebook":
179
+ return self
180
+
181
+ def __exit__(self, *exc: Any) -> None:
182
+ self.close()
183
+
184
+ @property
185
+ def embedder(self) -> Optional[TextEmbedder]:
186
+ return self.cache.embedder
187
+
188
+ @property
189
+ def read_only(self) -> Optional[str]:
190
+ """Why this notebook cannot embed (opened without the embedder it is pinned to), or None."""
191
+ return self.cache.missing_embedder if self.cache.embedder is None else None
192
+
193
+ def require_embedder(self) -> None:
194
+ """Raise the plain sentence when an operation would need new vectors and there is no embedder."""
195
+ if self.cache.embedder is None:
196
+ raise ValueError(self.cache.missing_embedder)
197
+
198
+ # ------------------------------------------------------------------ revisions
199
+
200
+ def apply_revision(self, revision: "Revision | Dict[str, Any]", *, _fail_after_index: bool = False
201
+ ) -> Dict[str, Any]:
202
+ """Apply a revision to both stores. Re-applying the same ``revision_id`` is a no-op
203
+ that returns the stored result. Raises on invalid ops (nothing is applied)."""
204
+ rev = self._rev_dict(revision)
205
+ self.require_embedder()
206
+ with self._apply_lock:
207
+ existing = self.store.revision_row(rev["revision_id"])
208
+ if existing is not None:
209
+ if existing["abandoned"]:
210
+ raise ValueError(f"The revision {rev['revision_id']} failed before and was set aside.")
211
+ if existing["applied_index"] and existing["applied_kg"]:
212
+ return self._result(existing, already_applied=True)
213
+ return self._finish(self.store.revision_dict(existing), _fail_after_index=_fail_after_index)
214
+ self.store.insert_revision(rev)
215
+ return self._finish(rev, _fail_after_index=_fail_after_index)
216
+
217
+ def _rev_dict(self, revision: "Revision | Dict[str, Any]") -> Dict[str, Any]:
218
+ if isinstance(revision, Revision):
219
+ data = revision.model_dump()
220
+ else:
221
+ data = dict(revision)
222
+ data.setdefault("recorded_at", None)
223
+ data["recorded_at"] = data["recorded_at"] or ids.utc_now()
224
+ data["at"] = ids.normalize_time(data["at"], field="revision time")
225
+ ops = []
226
+ for op in data.get("ops") or []:
227
+ o = op.model_dump() if isinstance(op, RevisionOp) else RevisionOp(**op).model_dump()
228
+ ops.append(o)
229
+ data["ops"] = ops
230
+ if not data.get("revision_id"):
231
+ raise ValueError("A revision needs a revision_id.")
232
+ return data
233
+
234
+ def _finish(self, rev: Dict[str, Any], *, _fail_after_index: bool = False) -> Dict[str, Any]:
235
+ row = self.store.revision_row(rev["revision_id"])
236
+ stats: Dict[str, Any] = {}
237
+ if not row["applied_index"]:
238
+ keys = _Keys(self.cache, rev)
239
+ try:
240
+ stats = self.store.project(rev, keys=keys)
241
+ except Exception as exc:
242
+ self.store.abandon_revision(rev["revision_id"], f"{type(exc).__name__}: {exc}")
243
+ raise
244
+ self.cache.ensure(keys.texts)
245
+ self._sync_pin()
246
+ result = dict(_uj(row["result_json"], {}) or {})
247
+ result.update({k: v for k, v in stats.items() if k not in ("touched_entities", "touched_claims")})
248
+ with self.store.lock:
249
+ self.store.conn.execute("UPDATE revisions SET result_json=? WHERE revision_id=?",
250
+ (_j(result), rev["revision_id"]))
251
+ if _fail_after_index:
252
+ raise RuntimeError("Simulated stop between the index and the AbstractMemory commits.")
253
+ row = self.store.revision_row(rev["revision_id"])
254
+ if not row["applied_kg"]:
255
+ with self.store.lock:
256
+ kinds = kinds_from_index(self.store.conn, rev)
257
+ self.kg.project(rev, kinds)
258
+ self.store.set_applied_kg(rev["revision_id"])
259
+ return self._result(self.store.revision_row(rev["revision_id"]))
260
+
261
+ def _result(self, row: sqlite3.Row, *, already_applied: bool = False) -> Dict[str, Any]:
262
+ result = dict(_uj(row["result_json"], {}) or {})
263
+ out = {
264
+ "revision_id": row["revision_id"], "summary": row["summary"],
265
+ "entities_created": result.get("entities_created", []),
266
+ "entities_merged": result.get("entities_merged", []),
267
+ "claims_added": result.get("claims_added", []),
268
+ "claims_reinforced": result.get("claims_reinforced", []),
269
+ "claims_superseded": result.get("claims_superseded", []),
270
+ "claims_retracted": result.get("claims_retracted", []),
271
+ "tasks_opened": result.get("tasks_opened", []),
272
+ "tasks_closed": result.get("tasks_closed", []),
273
+ "warnings": list(_uj(row["warnings_json"], []) or []),
274
+ }
275
+ if already_applied:
276
+ out["already_applied"] = True
277
+ return out
278
+
279
+ def recover(self) -> List[str]:
280
+ """Finish every revision whose projections are not both applied. Returns their ids."""
281
+ done = []
282
+ with self._apply_lock:
283
+ for row in self.store.pending_revisions():
284
+ self._finish(self.store.revision_dict(row))
285
+ done.append(row["revision_id"])
286
+ return done
287
+
288
+ def revision(self, revision_id: str) -> Optional[Dict[str, Any]]:
289
+ row = self.store.revision_row(revision_id)
290
+ return self.store.revision_dict(row) if row else None
291
+
292
+ # ------------------------------------------------------------------ user actions (M5)
293
+
294
+ def _user_revision(self, action: str, target: str, ops: List[Dict[str, Any]], account: Optional[str],
295
+ summary: str, *, client: str = "web", occurred_at: Optional[str] = None) -> Dict[str, Any]:
296
+ at = ids.normalize_time(occurred_at) if occurred_at else ids.utc_now()
297
+ rev = {
298
+ "revision_id": ids.revision_id_for_action(action, target, at), "note_id": None, "action": action,
299
+ "target": target, "at": at, "ops": ops, "summary": summary,
300
+ "source": {"client": client, "account": account},
301
+ }
302
+ return self.apply_revision(rev)
303
+
304
+ def close_task(self, task_id: str, account: Optional[str], note: Optional[str] = None, *,
305
+ client: str = "web", occurred_at: Optional[str] = None) -> Dict[str, Any]:
306
+ row = self.store.q1("SELECT * FROM tasks WHERE task_id=?", (task_id,))
307
+ if row is None:
308
+ raise ValueError("That task is not in this notebook.")
309
+ if row["status"] != "open":
310
+ raise ValueError("That task is already closed.")
311
+ summary = f"Closed the task “{row['text']}”." + (f" {note}" if note else "")
312
+ return self._user_revision("task.close", task_id, [{"op": "task.close", "payload": {
313
+ "task_id": task_id, "text": row["text"], "by_note_id": None, "note": note}}], account, summary,
314
+ client=client, occurred_at=occurred_at)
315
+
316
+ def retract_claim(self, claim_id: str, account: Optional[str], reason: str, *, client: str = "web",
317
+ occurred_at: Optional[str] = None) -> Dict[str, Any]:
318
+ row = self.store.claim_row(claim_id)
319
+ if row is None:
320
+ raise ValueError("That statement is not in this notebook.")
321
+ if row["status"] == "retracted":
322
+ raise ValueError("That statement was already retracted.")
323
+ if not str(reason or "").strip():
324
+ raise ValueError("Say why the statement is wrong.")
325
+ # The person says it was never true: the window closes where it opens (an empty window),
326
+ # never at the moment they said so, which can be before the claim's start.
327
+ never = row["valid_from"] or row["observed_at"]
328
+ return self._user_revision("claim.retract", claim_id, [{"op": "claim.retract", "payload": {
329
+ "claim_id": claim_id, "reason": reason, "valid_until": never}}], account,
330
+ f"Retracted a statement: {reason}", client=client, occurred_at=occurred_at)
331
+
332
+ def unmerge_entity(self, entity_id: str, from_id: str, account: Optional[str], reason: str, *,
333
+ client: str = "web", occurred_at: Optional[str] = None) -> Dict[str, Any]:
334
+ row = self.store.entity_row(from_id)
335
+ if row is None or self.store.entity_row(entity_id) is None:
336
+ raise ValueError("That entity is not in this notebook.")
337
+ if row["merged_into"] != entity_id:
338
+ raise ValueError("Those two entities are not merged.")
339
+ return self._user_revision("entity.unmerge", f"{entity_id}|{from_id}", [{"op": "entity.unmerge", "payload": {
340
+ "entity_id": entity_id, "from_id": from_id, "reason": reason or "undone by the user"}}], account,
341
+ f"Separated {row['name']} again.", client=client, occurred_at=occurred_at)
342
+
343
+ # ------------------------------------------------------------------ capture and filing (0002)
344
+
345
+ def capture(self, text: str = "", **kwargs: Any) -> Any:
346
+ """Store a note now; see :func:`abstractnote.ingest.capture.capture`. Returns a ``CaptureResult``."""
347
+ from ..ingest.capture import capture
348
+
349
+ kwargs.setdefault("vision", self.extractors.get("vision"))
350
+ kwargs.setdefault("stt", self.extractors.get("stt"))
351
+ return capture(self, text, **kwargs)
352
+
353
+ def file_note(self, note_id: str, **kwargs: Any) -> Dict[str, Any]:
354
+ from ..ingest.capture import file_note
355
+
356
+ return file_note(self, note_id, **kwargs)
357
+
358
+ def note(self, note_id: str) -> Optional[Dict[str, Any]]:
359
+ """A note in the pinned shape of ``GET /note/notes/{id}``: ``{note_id, title, text, kind, occurred_at,
360
+ received_at, status, topics, summary, source, location, attachments, revision, entities, claims, revisits,
361
+ insight}``.
362
+
363
+ ``revision`` is the note's applied revision (or null); ``revisits`` lists the notes linked to it by
364
+ ``ex:revisits`` in either direction as ``NoteRef``; ``insight`` is ``{kind, evidence_note_ids, entity_ids,
365
+ acked_at}`` for an insight note (``kind`` is its ``insight_kind``) and null for every other note.
366
+ """
367
+ from ..recall import shapes
368
+
369
+ row = self.store.note_row(note_id)
370
+ if row is None:
371
+ return None
372
+ atts = self.store.attachments_of(note_id)
373
+ for a in atts:
374
+ path = self.root / "extracted" / f"{a['sha256']}.txt"
375
+ a["extracted_text"] = path.read_text(encoding="utf-8") if a["extracted_chars"] and path.exists() else None
376
+ rev = self.store.q1("SELECT * FROM revisions WHERE note_id=? AND applied_index=1 ORDER BY seq DESC LIMIT 1",
377
+ (note_id,))
378
+ revisits = self.store.q(
379
+ "SELECT n.* FROM note_links l JOIN notes n ON n.note_id=l.dst_note_id WHERE l.src_note_id=?"
380
+ " AND l.predicate='ex:revisits' UNION SELECT n.* FROM note_links l JOIN notes n"
381
+ " ON n.note_id=l.src_note_id WHERE l.dst_note_id=? AND l.predicate='ex:revisits'"
382
+ " ORDER BY occurred_at, note_id", (note_id, note_id))
383
+ return {
384
+ "note_id": row["note_id"], "title": row["title"], "text": row["text"], "kind": row["kind"],
385
+ "occurred_at": row["occurred_at"], "received_at": row["received_at"], "status": row["status"],
386
+ "topics": self.store.note_topics(note_id), "summary": row["summary"] or row["one_line"],
387
+ "source": _uj(row["source_json"], {}), "location": _uj(row["location_json"]),
388
+ "attachments": atts,
389
+ "revision": self.store.revision_dict(rev) if rev else None,
390
+ "entities": [b for b in (self.store.entity_brief(r[0]) for r in self.store.q(
391
+ "SELECT entity_id FROM entity_notes WHERE note_id=? ORDER BY entity_id", (note_id,))) if b],
392
+ "claims": [self.store.claim_dict(r) for r in self.store.q(
393
+ "SELECT * FROM claims WHERE note_id=? ORDER BY seq", (note_id,))],
394
+ "revisits": [shapes.note_ref(r) for r in revisits],
395
+ "insight": shapes.insight_facts(row),
396
+ }
397
+
398
+ def note_links(self, note_id: str) -> Dict[str, Any]:
399
+ """Every link of a note (revisits both ways, same bytes, references) with how it matched."""
400
+ out = {"outgoing": [], "incoming": []}
401
+ for r in self.store.q("SELECT * FROM note_links WHERE src_note_id=? ORDER BY created_at", (note_id,)):
402
+ out["outgoing"].append({"note_id": r["dst_note_id"], "predicate": r["predicate"],
403
+ "matched_by": r["matched_by"], "score": r["score"],
404
+ "detail": _uj(r["detail_json"])})
405
+ for r in self.store.q("SELECT * FROM note_links WHERE dst_note_id=? ORDER BY created_at", (note_id,)):
406
+ out["incoming"].append({"note_id": r["src_note_id"], "predicate": r["predicate"],
407
+ "matched_by": r["matched_by"], "score": r["score"],
408
+ "detail": _uj(r["detail_json"])})
409
+ return out
410
+
411
+ def attachment_bytes(self, note_id: str, attachment_id: str) -> Dict[str, Any]:
412
+ """``{data, mime, filename, size}`` of one attachment of a note."""
413
+ row = self.store.q1("SELECT * FROM attachments WHERE note_id=? AND attachment_id=?", (note_id, attachment_id))
414
+ if row is None:
415
+ raise ValueError("That attachment is not part of this note.")
416
+ from ..ingest.blobs import BlobStore
417
+
418
+ data = BlobStore(self.root / "blobs").read(row["sha256"])
419
+ return {"data": data, "mime": row["mime"], "filename": row["filename"], "size": row["size"]}
420
+
421
+ def archive_note(self, note_id: str) -> Dict[str, Any]:
422
+ """Archive a note (never deleted); it leaves lists and search but keeps its links."""
423
+ if self.store.note_row(note_id) is None:
424
+ raise ValueError("That note is not in this notebook.")
425
+ now = ids.utc_now()
426
+ with self.store.tx() as c:
427
+ c.execute("UPDATE notes SET archived_at=COALESCE(archived_at, ?), status='archived' WHERE note_id=?",
428
+ (now, note_id))
429
+ return {"note_id": note_id, "status": "archived", "archived_at": self.store.note_row(note_id)["archived_at"]}
430
+
431
+ def ack_insight(self, note_id: str) -> Dict[str, Any]:
432
+ row = self.store.note_row(note_id)
433
+ if row is None or not row["insight_kind"]:
434
+ raise ValueError("That insight is not in this notebook.")
435
+ now = ids.utc_now()
436
+ with self.store.tx() as c:
437
+ c.execute("UPDATE notes SET acked_at=COALESCE(acked_at, ?) WHERE note_id=?", (now, note_id))
438
+ return {"note_id": note_id, "acked_at": self.store.note_row(note_id)["acked_at"]}
439
+
440
+ # ------------------------------------------------------------------ reads (store-level)
441
+
442
+ def beliefs_at(self, at: str, *, entity_id: Optional[str] = None, subject_id: Optional[str] = None
443
+ ) -> List[Dict[str, Any]]:
444
+ return self.store.beliefs_at(at, entity_id=entity_id, subject_id=subject_id)
445
+
446
+ def graph(self, *, focus: Optional[Sequence[str]] = None, at: Optional[str] = None, depth: int = 1,
447
+ since: Optional[str] = None, until: Optional[str] = None, kinds: Optional[Sequence[str]] = None,
448
+ min_count: int = 1, limit: int = 200, as_of: Optional[str] = None,
449
+ as_received: Optional[str] = None) -> Dict[str, Any]:
450
+ """Nodes and edges as of ``at``; ``as_received`` keeps what the archive held on that date
451
+ (docs/data-model.md "Time")."""
452
+ return self.store.graph(focus=focus, at=at or as_of, depth=depth, since=since, until=until, kinds=kinds,
453
+ min_count=min_count, limit=limit, as_received=as_received)
454
+
455
+ def day(self, day: str, tz: Optional[str] = None, as_received: Optional[str] = None) -> Dict[str, Any]:
456
+ return self.store.day(day, tz, as_received)
457
+
458
+ def timeline(self, since: Optional[str] = None, until: Optional[str] = None, granularity: str = "day"
459
+ ) -> Dict[str, Any]:
460
+ return self.store.timeline(since=since, until=until, granularity=granularity)
461
+
462
+ def notes(self, **kwargs: Any) -> Dict[str, Any]:
463
+ return self.store.list_notes(**kwargs)
464
+
465
+ def entities(self, **kwargs: Any) -> Dict[str, Any]:
466
+ return self.store.list_entities(**kwargs)
467
+
468
+ def revisions(self, **kwargs: Any) -> Dict[str, Any]:
469
+ return self.store.revisions_feed(**kwargs)
470
+
471
+ def topics(self) -> Dict[str, Any]:
472
+ return {"items": self.store.topics()}
473
+
474
+ def tasks(self, status: Optional[str] = "open", entity: Optional[str] = None, limit: int = 100
475
+ ) -> Dict[str, Any]:
476
+ """``{items: [{task_id, note_id, text, status, due, closed_at}]}`` (``status=None`` for all)."""
477
+ keys = ("task_id", "note_id", "text", "status", "due", "closed_at")
478
+ return {"items": [{k: t[k] for k in keys} for t in self.store.tasks(status=status, entity=entity,
479
+ limit=limit)]}
480
+
481
+ def insights(self, kind: Optional[str] = None, acked: Optional[bool] = None, limit: int = 50
482
+ ) -> Dict[str, Any]:
483
+ """``{items: [Insight]}``, newest first."""
484
+ from ..recall import shapes
485
+ from .notes import clamp_limit
486
+
487
+ sql = "SELECT * FROM notes WHERE insight_kind IS NOT NULL AND archived_at IS NULL"
488
+ params: List[Any] = []
489
+ if kind:
490
+ sql += " AND insight_kind=?"
491
+ params.append(kind)
492
+ if acked is True:
493
+ sql += " AND acked_at IS NOT NULL"
494
+ elif acked is False:
495
+ sql += " AND acked_at IS NULL"
496
+ sql += " ORDER BY received_at DESC, note_id DESC LIMIT ?"
497
+ params.append(clamp_limit(limit))
498
+ return {"items": [shapes.insight(r) for r in self.store.q(sql, params)]}
499
+
500
+ def changes(self, since: Optional[str] = None, limit: int = 50, as_received: Optional[str] = None
501
+ ) -> Dict[str, Any]:
502
+ """Since you last looked; see :func:`abstractnote.recall.changes.changes`."""
503
+ from ..recall.changes import changes
504
+
505
+ return changes(self, since, limit, as_received)
506
+
507
+ def overview(self, days: int = 30, *, now: Optional[str] = None) -> Dict[str, Any]:
508
+ from ..recall.changes import overview
509
+
510
+ return overview(self, days, now=now)
511
+
512
+ def cues(self, limit: int = 3) -> Dict[str, Any]:
513
+ from ..recall.changes import cues
514
+
515
+ return cues(self, limit)
516
+
517
+ def review(self, limit: int = 50) -> Dict[str, Any]:
518
+ """Proposed matches and disagreements for a person (``GET /note/review``); see :mod:`abstractnote.recall.review`."""
519
+ from ..recall.review import review
520
+
521
+ return review(self, limit)
522
+
523
+ def review_item(self, review_id: str) -> Optional[Dict[str, Any]]:
524
+ """One item of :meth:`review` by id (None when it is not waiting for review)."""
525
+ from ..recall.review import _public, review_item
526
+
527
+ item = review_item(self, review_id)
528
+ return _public(item) if item else None
529
+
530
+ def confirm_match(self, review_id: str, account: Optional[str], *, client: str = "web",
531
+ occurred_at: Optional[str] = None) -> Dict[str, Any]:
532
+ """Confirm a proposed match: a user-action revision ``match.confirm`` (see :mod:`abstractnote.recall.review`)."""
533
+ from ..recall.review import confirm
534
+
535
+ return confirm(self, review_id, account, client=client, occurred_at=occurred_at)
536
+
537
+ # ------------------------------------------------------------------ recall (0006)
538
+
539
+ def search(self, query: str = "", **kwargs: Any) -> Dict[str, Any]:
540
+ """Quick recall: full-text plus vector over notes, entities and claims, with facets."""
541
+ from ..recall.search import search
542
+
543
+ return search(self, query, **kwargs)
544
+
545
+ def entity(self, entity_id: str, at: Optional[str] = None, as_received: Optional[str] = None) -> Dict[str, Any]:
546
+ """The entity card as of ``at`` (merged ids resolve to the surviving entity); ``as_received`` builds it from
547
+ what the archive held on that date."""
548
+ from ..recall.expand import entity_card
549
+
550
+ return entity_card(self, entity_id, at, as_received)
551
+
552
+ def entity_record(self, entity_id: str) -> Dict[str, Any]:
553
+ """The entry's own record, not followed to the surviving entity of a merge (``GET /note/entities/{id}?
554
+ as_merged=true``): ``{entity_id, kind, name, aliases, summary, first_seen, last_seen, note_count, merged_into,
555
+ merged_at, merge, notes}``. ``merge`` is the revision that joined it into ``merged_into`` (``{revision_id, at,
556
+ recorded_at, reason, summary, note_id}``) or null when it is not merged; ``notes`` are its own notes."""
557
+ from ..recall import shapes
558
+
559
+ brief = self.store.entity_brief(entity_id)
560
+ if brief is None:
561
+ raise ValueError("That entity is not in this notebook.")
562
+ row = self.store.entity_row(entity_id)
563
+ merge = None
564
+ if row["merged_into"]:
565
+ m = self.store.q1("SELECT m.*, r.summary, r.note_id, r.recorded_at FROM merges m JOIN revisions r ON"
566
+ " r.revision_id=m.revision_id WHERE m.from_id=? AND m.into_id=? AND m.undone_at IS NULL"
567
+ " ORDER BY m.at DESC LIMIT 1", (entity_id, row["merged_into"]))
568
+ if m is not None:
569
+ merge = {"revision_id": m["revision_id"], "at": m["at"], "recorded_at": m["recorded_at"],
570
+ "reason": m["reason"], "summary": m["summary"], "note_id": m["note_id"]}
571
+ notes = self.store.q("SELECT n.* FROM entity_notes en JOIN notes n ON n.note_id=en.note_id WHERE"
572
+ " en.entity_id=? AND n.archived_at IS NULL ORDER BY n.occurred_at DESC LIMIT 10",
573
+ (entity_id,))
574
+ return {**brief, "merged_at": row["merged_at"], "merge": merge, "notes": [shapes.note_ref(r) for r in notes]}
575
+
576
+ def entity_timeline(self, entity_id: str, since: Optional[str] = None, until: Optional[str] = None
577
+ ) -> Dict[str, Any]:
578
+ from ..recall.expand import entity_timeline
579
+
580
+ return entity_timeline(self, entity_id, since, until)
581
+
582
+ def then_now(self, entity_id: str) -> Dict[str, Any]:
583
+ from ..recall.then_now import then_now
584
+
585
+ return then_now(self, entity_id)
586
+
587
+ def relation(self, a: str, b: str, since: Optional[str] = None, until: Optional[str] = None) -> Dict[str, Any]:
588
+ """The relationship between two entities through time (M6)."""
589
+ from ..recall.expand import relation
590
+
591
+ return relation(self, a, b, since, until)
592
+
593
+ def claim(self, claim_id: str) -> Dict[str, Any]:
594
+ """Why do I believe this: the evidence chain of a claim."""
595
+ from ..recall.expand import claim_chain
596
+
597
+ return claim_chain(self, claim_id)
598
+
599
+ def expand(self, note_ids: Optional[Sequence[str]] = None, entity_ids: Optional[Sequence[str]] = None,
600
+ as_of: Optional[str] = None) -> Dict[str, Any]:
601
+ from ..recall.expand import expand
602
+
603
+ return expand(self, note_ids or [], entity_ids or [], as_of)
604
+
605
+ def write_insight(self, kind: str, title: str, body: str, evidence_note_ids: Optional[Sequence[str]] = None,
606
+ entity_ids: Optional[Sequence[str]] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
607
+ from ..recall.insights import write_insight
608
+
609
+ return write_insight(self, kind, title, body, evidence_note_ids, entity_ids, run_id)
610
+
611
+ def open_loops(self, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None) -> Dict[str, Any]:
612
+ from ..recall.reflect import open_loops
613
+
614
+ return open_loops(self, limit, since, now=now)
615
+
616
+ def contradictions(self, limit: int = 20, since: Optional[str] = None) -> Dict[str, Any]:
617
+ from ..recall.reflect import contradictions
618
+
619
+ return contradictions(self, limit, since)
620
+
621
+ def resurface(self, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None) -> Dict[str, Any]:
622
+ from ..recall.reflect import resurface
623
+
624
+ return resurface(self, limit, since, now=now)
625
+
626
+ def anniversaries(self, limit: int = 20, since: Optional[str] = None, *, now: Optional[str] = None
627
+ ) -> Dict[str, Any]:
628
+ from ..recall.reflect import anniversaries
629
+
630
+ return anniversaries(self, limit, since, now=now)
631
+
632
+ # ------------------------------------------------------------------ settings and status
633
+
634
+ def settings(self) -> Dict[str, Any]:
635
+ stored = self.store.meta_get("settings", {}) or {}
636
+ out = {k: (dict(v) if isinstance(v, dict) else v) for k, v in DEFAULT_SETTINGS.items()}
637
+ for k, v in stored.items():
638
+ if k in out:
639
+ out[k] = v
640
+ return out
641
+
642
+ def update_settings(self, changes: Dict[str, Any]) -> Dict[str, Any]:
643
+ unknown = sorted(set(changes) - set(DEFAULT_SETTINGS))
644
+ if unknown:
645
+ raise ValueError(f"Unknown setting: {', '.join(unknown)}.")
646
+ if "processor_schedule" in changes:
647
+ changes = {**changes, "processor_schedule": _check_processor_schedule(changes["processor_schedule"])}
648
+ current = self.settings()
649
+ current.update(changes)
650
+ self.store.meta_set("settings", current)
651
+ return current
652
+
653
+ def processor_throughput(self) -> Dict[str, Any]:
654
+ """``{max_items, interval}`` of the processor: what its automation was created with (recorded by
655
+ ``POST /note/processor/ensure`` under ``processor_schedule_applied``) while it exists, else the stored
656
+ ``processor_schedule`` setting it will be created with."""
657
+ applied = self.store.meta_get("processor_schedule_applied") if self.store.meta_get(
658
+ "processor_automation_id") else None
659
+ sched = applied if isinstance(applied, dict) else self.settings()["processor_schedule"]
660
+ return {"max_items": int(sched["max_items"]), "interval": str(sched["every"])}
661
+
662
+ def meta(self, key: str, default: Any = None) -> Any:
663
+ return self.store.meta_get(key, default)
664
+
665
+ def set_meta(self, key: str, value: Any) -> None:
666
+ if key in ("schema_version", "embedding_pin", "settings"):
667
+ raise ValueError(f"The {key} entry is managed by the notebook.")
668
+ self.store.meta_set(key, value)
669
+
670
+ def status(self) -> Dict[str, Any]:
671
+ from importlib.metadata import PackageNotFoundError, version
672
+
673
+ try:
674
+ am_version: Optional[str] = version("abstractmemory")
675
+ except PackageNotFoundError: # pragma: no cover
676
+ am_version = None
677
+ pending = len(self.store.pending_revisions())
678
+ return {
679
+ "notebook": str(self.root),
680
+ "counts": self.store.counts(),
681
+ "queue": self.queue.stats(),
682
+ "embedding_pin": self.store.meta_get("embedding_pin"),
683
+ "kg_embedding_pin": self.kg.embedding_pin(),
684
+ "kg_assertions": self.kg.count(),
685
+ "pending_revisions": pending,
686
+ "read_only": self.read_only,
687
+ "settings": self.settings(),
688
+ "automations": {"processor_id": self.store.meta_get("processor_automation_id"),
689
+ "reflect_id": self.store.meta_get("reflect_automation_id")},
690
+ "versions": {"abstractnote": __version__, "schema": int(self.store.meta_get("schema_version", 0)),
691
+ "abstractmemory": am_version},
692
+ }
693
+
694
+ # ------------------------------------------------------------------ rebuild
695
+
696
+ def rebuild(self, *, reembed: bool = False, embedder: Optional[TextEmbedder] = None) -> Dict[str, Any]:
697
+ """Replay notes and revisions into fresh index projections and a fresh triple store.
698
+
699
+ Without ``reembed`` stored vectors are reused (no embedder calls for known
700
+ texts). With ``reembed`` every vector is recomputed with ``embedder`` (or the
701
+ current one) and the notebook is pinned to that model.
702
+ """
703
+ with self._apply_lock:
704
+ before = self.store.counts()
705
+ new_embedder = embedder or self.cache.embedder
706
+ if new_embedder is None:
707
+ raise ValueError(self.cache.missing_embedder)
708
+ self.kg.close()
709
+ with self.store.lock:
710
+ self.store.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
711
+ self.store.close()
712
+ note_path, kg_path = self.root / NOTE_DB, self.root / KG_DB
713
+ old_note = self.root / (NOTE_DB + ".pre-rebuild")
714
+ old_kg = self.root / (KG_DB + ".pre-rebuild")
715
+ for p in (old_note, old_kg):
716
+ _remove_db(p)
717
+ os.replace(note_path, old_note)
718
+ _remove_db_sidecars(note_path)
719
+ if kg_path.exists():
720
+ os.replace(kg_path, old_kg)
721
+ _remove_db_sidecars(kg_path)
722
+ try:
723
+ store = NoteStore(str(note_path))
724
+ self._copy_truth(store, old_note, reembed=reembed)
725
+ cache = self._make_cache(store, new_embedder, force_new_pin=reembed)
726
+ self.store, self.cache = store, cache
727
+ self.queue.store = store
728
+ self.note_vectors.store = store
729
+ self.note_vectors.reset()
730
+ self._reindex_notes(reembed=reembed)
731
+ self.kg = ClaimStore(kg_path, cache)
732
+ replayed = 0
733
+ for row in self.store.q("SELECT * FROM revisions WHERE abandoned=0 ORDER BY seq"):
734
+ self._finish(self.store.revision_dict(row))
735
+ replayed += 1
736
+ self._sync_pin()
737
+ except BaseException:
738
+ try:
739
+ self.kg.close()
740
+ except Exception:
741
+ pass
742
+ try:
743
+ self.store.close()
744
+ except Exception:
745
+ pass
746
+ _remove_db(note_path)
747
+ _remove_db(kg_path)
748
+ os.replace(old_note, note_path)
749
+ if old_kg.exists():
750
+ os.replace(old_kg, kg_path)
751
+ self.store = NoteStore(str(note_path))
752
+ self.cache = self._make_cache(self.store, new_embedder if not reembed else None)
753
+ self.queue.store = self.store
754
+ self.note_vectors.store = self.store
755
+ self.note_vectors.reset()
756
+ self.kg = ClaimStore(kg_path, self.cache)
757
+ raise
758
+ _remove_db(old_note)
759
+ _remove_db(old_kg)
760
+ return {"replayed_revisions": replayed, "before": before, "after": self.store.counts(),
761
+ "reembedded": reembed, "embedding_pin": self.store.meta_get("embedding_pin")}
762
+
763
+ def _copy_truth(self, store: NoteStore, old_path: Path, *, reembed: bool) -> None:
764
+ with store.lock:
765
+ c = store.conn
766
+ c.execute("ATTACH DATABASE ? AS old", (str(old_path),))
767
+ try:
768
+ c.execute("BEGIN IMMEDIATE")
769
+ c.execute("DELETE FROM meta")
770
+ c.execute("INSERT INTO meta SELECT * FROM old.meta" + (" WHERE key!='embedding_pin'" if reembed else ""))
771
+ tables = ["notes", "attachments", "note_links", "queue", "context_packets", "revisions"]
772
+ if not reembed:
773
+ tables.append("vectors")
774
+ for table in tables:
775
+ new_cols = [r[1] for r in c.execute(f"PRAGMA main.table_info({table})")]
776
+ old_cols = {r[1] for r in c.execute(f"PRAGMA old.table_info({table})")}
777
+ collist = ",".join(col for col in new_cols if col in old_cols)
778
+ c.execute(f"INSERT INTO main.{table}({collist}) SELECT {collist} FROM old.{table}")
779
+ c.execute("UPDATE revisions SET applied_index=0, applied_kg=0, result_json=NULL WHERE abandoned=0")
780
+ c.execute("UPDATE notes SET summary=NULL")
781
+ c.execute("COMMIT")
782
+ except BaseException:
783
+ c.execute("ROLLBACK")
784
+ raise
785
+ finally:
786
+ c.execute("DETACH DATABASE old")
787
+
788
+ def _reindex_notes(self, *, reembed: bool = False) -> None:
789
+ """Note FTS rows, filing topics and (when missing or re-embedding) note vectors."""
790
+ rows = self.store.q("SELECT n.note_id, n.title, n.text, n.filing_topics_json, n.occurred_at, n.vector_key,"
791
+ " v.vector_key AS have FROM notes n LEFT JOIN vectors v ON v.vector_key=n.vector_key"
792
+ " ORDER BY n.seq")
793
+ texts = []
794
+ with self.store.tx() as c:
795
+ for r in rows:
796
+ extracted = self.extracted_texts(r["note_id"])
797
+ self.store.refresh_note_fts(c, r["note_id"], extracted)
798
+ for topic in _uj(r["filing_topics_json"], []) or []:
799
+ self.store.assign_topic(c, r["note_id"], topic, by="filing", revision_id=None, at=r["occurred_at"])
800
+ if reembed or (r["vector_key"] and not r["have"]):
801
+ text = note_vector_text(r["title"], r["text"], extracted, NOTE_EMBED_BUDGET)
802
+ if text.strip():
803
+ texts.append((r["note_id"], text))
804
+ if texts:
805
+ keys = self.cache.ensure([t for _, t in texts])
806
+ with self.store.tx() as c:
807
+ c.executemany("UPDATE notes SET vector_key=? WHERE note_id=?",
808
+ [(k, nid) for (nid, _), k in zip(texts, keys)])
809
+
810
+ def extracted_texts(self, note_id: str) -> List[str]:
811
+ out = []
812
+ for a in self.store.attachments_of(note_id):
813
+ path = self.root / "extracted" / f"{a['sha256']}.txt"
814
+ if a["extracted_chars"] and path.exists():
815
+ out.append(path.read_text(encoding="utf-8"))
816
+ return out
817
+
818
+
819
+ def _remove_db_sidecars(path: Path) -> None:
820
+ for suffix in ("-wal", "-shm"):
821
+ p = Path(str(path) + suffix)
822
+ if p.exists():
823
+ p.unlink()
824
+
825
+
826
+ def _remove_db(path: Path) -> None:
827
+ if path.exists():
828
+ path.unlink()
829
+ _remove_db_sidecars(path)