zikaron 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 (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. zikaron-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,200 @@
1
+ """Signals 1 and 5 — writes per session and the zero-write rate; the write size distribution.
2
+
3
+ **Signal 1.** `schema.md` §"D30's six signals, as queries", row 1: per `session_id`,
4
+ `count(kind='remember')` and `count(kind='amend')` reported separately and summed, over a
5
+ denominator of `count(DISTINCT session_id)` restricted to **linked** sessions (`signals.sessions`) —
6
+ every session the service saw through both clients, whether or not it wrote. A linked session with a
7
+ `surface_call` and no `remember`/`amend` is a zero-write session; an unlinked one is excluded rather
8
+ than counted as zero-write, because an unlinked *writing* session would otherwise be miscounted as
9
+ one — the exact failure `write-policy.md` §3 names as the one to engineer against, since it biases
10
+ the rate in the direction that would confirm the under-writing prior this signal exists to test.
11
+
12
+ Reports **both halves of D11's loop from the write side**: `remember` volume alone answers "is the
13
+ agent recording at all", and the retire signal (`signals.retirement`) answers the repair half this
14
+ one does not — a session with plenty of `remember` and no `amend` is not yet evidence that repair
15
+ never fires, since a surfaced memory repaired by outright `retire` is legitimate under D11 and D16
16
+ and belongs to a different signal.
17
+
18
+ **Signal 5**, kept in this module rather than a separate one: both signals aggregate the same
19
+ authored-write events (`remember`, `amend`, and the authoring rows of `merge`/`promote`), and a
20
+ write's size is one further fact about the same write population signal 1 already counts — not a
21
+ fact about a different event population.
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+
26
+ import aiosqlite
27
+
28
+ from zikaron.core.events import EventKind
29
+ from zikaron.core.signals.sessions import LinkCoverage, link_coverage, linked_session_ids
30
+
31
+ _PER_SESSION_COUNTS = """
32
+ SELECT session_id,
33
+ SUM(CASE WHEN kind = ? THEN 1 ELSE 0 END) AS n_remember,
34
+ SUM(CASE WHEN kind = ? THEN 1 ELSE 0 END) AS n_amend
35
+ FROM event
36
+ WHERE session_id IN ({placeholders})
37
+ AND kind IN (?, ?)
38
+ GROUP BY session_id
39
+ """
40
+ #: The two `AS` labels above are documentation, not a wire the row is read through:
41
+ #: `writes_per_session` below unpacks each row positionally (`session_id, n_remember, n_amend`),
42
+ #: so which count is `n_remember` is decided by which `?` value binds to the first `CASE WHEN` at
43
+ #: parameter-construction time, never by the label text. Stated here because that makes the label
44
+ #: itself unable to cause or catch a defect, which a mutation on it would otherwise look like.
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class SessionWrites:
49
+ """One linked session's write counts. A session absent from the per-session query made
50
+ neither call, so both counts are `0` for it — the zero-write case this signal exists to find."""
51
+
52
+ session_id: str
53
+ n_remember: int
54
+ n_amend: int
55
+
56
+ @property
57
+ def n_writes(self) -> int:
58
+ """`remember` and `amend` summed, per the design's "reported separately and summed"."""
59
+ return self.n_remember + self.n_amend
60
+
61
+ @property
62
+ def wrote_nothing(self) -> bool:
63
+ return self.n_writes == 0
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class WritesPerSession:
68
+ """The full signal: every linked session's write counts, plus the coverage the cross-client
69
+ join depends on.
70
+
71
+ `n_linked_sessions` is the rate's own denominator, restated on the type rather than left to be
72
+ recomputed from `len(sessions)` — `sessions` already omits nothing, so the two do agree, but a
73
+ caller reading only this field should not have to know that to trust it names the denominator.
74
+ """
75
+
76
+ sessions: tuple[SessionWrites, ...]
77
+ link: LinkCoverage
78
+
79
+ @property
80
+ def n_linked_sessions(self) -> int:
81
+ return len(self.sessions)
82
+
83
+ @property
84
+ def n_zero_write_sessions(self) -> int:
85
+ return sum(1 for session in self.sessions if session.wrote_nothing)
86
+
87
+ @property
88
+ def zero_write_rate(self) -> float | None:
89
+ """`n_zero_write_sessions / n_linked_sessions`, or `None` over an empty denominator.
90
+
91
+ Bounded in `[0, 1]` by construction: the numerator counts a subset of the exact rows the
92
+ denominator counts (a session cannot be zero-write and also outside `sessions`, since every
93
+ linked session appears in `sessions` whether or not it wrote), so it can never exceed the
94
+ denominator it is drawn from.
95
+ """
96
+ if self.n_linked_sessions == 0:
97
+ return None
98
+ return self.n_zero_write_sessions / self.n_linked_sessions
99
+
100
+ @property
101
+ def total_remember(self) -> int:
102
+ return sum(session.n_remember for session in self.sessions)
103
+
104
+ @property
105
+ def total_amend(self) -> int:
106
+ return sum(session.n_amend for session in self.sessions)
107
+
108
+
109
+ async def writes_per_session(db: aiosqlite.Connection) -> WritesPerSession:
110
+ """Every linked session's `remember`/`amend` counts, with link coverage.
111
+
112
+ A linked session that made no `remember` and no `amend` call is absent from the SQL result
113
+ (`GROUP BY` produces no row for a group with nothing to group), so it is added back explicitly
114
+ with both counts `0` — the join must not silently drop the case this whole signal is for.
115
+ """
116
+ linked = await linked_session_ids(db)
117
+ counted: dict[str, SessionWrites] = {}
118
+ if linked:
119
+ placeholders = ", ".join("?" for _ in linked)
120
+ sql = _PER_SESSION_COUNTS.format(placeholders=placeholders)
121
+ params = (
122
+ EventKind.REMEMBER.value,
123
+ EventKind.AMEND.value,
124
+ *linked,
125
+ EventKind.REMEMBER.value,
126
+ EventKind.AMEND.value,
127
+ )
128
+ rows = await db.execute_fetchall(sql, params)
129
+ for session_id, n_remember, n_amend in rows:
130
+ counted[str(session_id)] = SessionWrites(
131
+ session_id=str(session_id), n_remember=int(n_remember), n_amend=int(n_amend)
132
+ )
133
+ sessions = tuple(
134
+ counted.get(session_id, SessionWrites(session_id=session_id, n_remember=0, n_amend=0))
135
+ for session_id in sorted(linked)
136
+ )
137
+ return WritesPerSession(sessions=sessions, link=await link_coverage(db))
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Signal 5 — write size distribution
142
+ # ---------------------------------------------------------------------------
143
+ #
144
+ # Kept in this module rather than a separate one: both signals aggregate the same authored-write
145
+ # events (`remember`, `amend`, and the authoring rows of `merge`/`promote`), and a write's size is
146
+ # one further fact about the same write count 1 already found — not a fact about a different event
147
+ # population.
148
+
149
+ _AUTHORED_SIZES = """
150
+ SELECT id, json_extract(detail, '$.token_count') AS token_count
151
+ FROM event
152
+ WHERE kind IN (?, ?)
153
+ UNION ALL
154
+ SELECT id, json_extract(detail, '$.token_count') AS token_count
155
+ FROM event
156
+ WHERE kind = ? AND json_extract(detail, '$.role') = 'target'
157
+ UNION ALL
158
+ SELECT id, json_extract(detail, '$.token_count') AS token_count
159
+ FROM event
160
+ WHERE kind = ? AND json_extract(detail, '$.role') IN ('created', 'flipped')
161
+ ORDER BY id
162
+ """
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class WriteSizeDistribution:
167
+ """`token_count` from every write that authored prose, in `event.id` order.
168
+
169
+ A distribution, not a rate (`schema.md`: "n/a (a distribution, not a rate)"), so this carries
170
+ the raw values rather than a summary statistic — histogramming, percentiles or a mean are a
171
+ caller's choice, and baking one in would answer a question about the shape of the data with a
172
+ single number chosen before the shape was known. Ordered by the underlying `event.id` (the
173
+ query's own `ORDER BY`, applied over the whole three-branch `UNION`) so two reads of one store
174
+ return byte-identical results, matching every other signal's own determinism guarantee rather
175
+ than leaving `UNION ALL`'s branch order to be assumed.
176
+ """
177
+
178
+ values: tuple[int, ...]
179
+
180
+
181
+ async def write_size_distribution(db: aiosqlite.Connection) -> WriteSizeDistribution:
182
+ """`token_count` from every write that authored prose: `remember`, `amend`, a merge's `target`
183
+ row, and a promotion's `created`/`flipped` row.
184
+
185
+ An absorbed `merge` row and every non-`created`/`flipped` `promote` row authored no prose, so
186
+ the design states their `token_count` as null (`schema.md`'s nullability table) rather than
187
+ absent — filtering by `role` here is what a query over `token_count IS NOT NULL` would do
188
+ implicitly, made explicit because the role is the actual reason those rows are excluded, not an
189
+ incidental correlate of it.
190
+ """
191
+ rows = await db.execute_fetchall(
192
+ _AUTHORED_SIZES,
193
+ (
194
+ EventKind.REMEMBER.value,
195
+ EventKind.AMEND.value,
196
+ EventKind.MERGE.value,
197
+ EventKind.PROMOTE.value,
198
+ ),
199
+ )
200
+ return WriteSizeDistribution(values=tuple(int(token_count) for _id, token_count in rows))
@@ -0,0 +1 @@
1
+ """Schema creation, open/close, migration posture, and file permissions."""
@@ -0,0 +1,202 @@
1
+ """How Zikaron opens a SQLite database, for every database it owns.
2
+
3
+ Two live today — the memory store and one file per knowledge base — and they differ in their
4
+ schemas, not in how a connection is established: both need `sqlite-vec` loaded, both need their own
5
+ pragmas applied, and both need a failure between connecting and finishing that setup to close the
6
+ connection rather than abandon it. Keeping one implementation is what stops the second database
7
+ growing a subtly different opener; the alternative was already measured to be expensive in this
8
+ project, where an unclosed `aiosqlite` connection keeps its process alive with nothing printed.
9
+
10
+ **Two things vary between callers, and both are parameters rather than assumptions.**
11
+
12
+ - **How a failed *connect* is named** — the same split `transactions.FailureMap` makes, for the same
13
+ reason. A memory store that will not open is a configuration error naming the store directory; a
14
+ knowledge-base database that will not open is a reportable state of one corpus, and not an error
15
+ at all when the file is simply absent.
16
+ - **Which pragmas to apply.** The two schemas do not want the same set, and the difference is
17
+ load-bearing rather than cosmetic: the knowledge schema declares no foreign key anywhere, so
18
+ `PRAGMA foreign_keys = ON` there would advertise a cascade that does not exist — which is the
19
+ precise wrong mental model its design spends a paragraph refusing. The set is therefore supplied
20
+ by whoever owns the schema, and there is deliberately **no default**: a caller that did not say
21
+ would get whichever set this module happened to import, which is exactly how a connection came to
22
+ carry a pragma its own design forbids.
23
+
24
+ Everything *after* the connect — the extension, the pragmas — raises the driver's own error for
25
+ every caller, because a database that opened and then refused a pragma is the same fault whoever
26
+ asked for it.
27
+ """
28
+
29
+ import asyncio
30
+ import threading
31
+ from collections.abc import Callable, Sequence
32
+ from pathlib import Path
33
+ from typing import Final
34
+ from urllib.parse import quote
35
+
36
+ import aiosqlite
37
+ import sqlite_vec
38
+
39
+ #: How a caller names a failed connect. Takes the driver's error and returns the exception to
40
+ #: raise in its place, with the original kept as the cause.
41
+ type ConnectFailure = Callable[[aiosqlite.Error], Exception]
42
+
43
+
44
+ #: How long to wait for the worker thread of a *failed* connect to finish. It is draining a single
45
+ #: queued sentinel, which takes microseconds; this bound exists so a wedged thread cannot hang a
46
+ #: caller rather than as a duration anything is expected to spend.
47
+ _ABANDONED_WORKER_JOIN_SECONDS: Final = 5.0
48
+
49
+
50
+ async def join_abandoned_worker(connector: aiosqlite.Connection) -> None:
51
+ """Wait for the worker thread of a connect that failed, so it cannot outlive this event loop.
52
+
53
+ **Why this is needed, reproduced before it was written.** `aiosqlite.connect()` starts a worker
54
+ thread, and a failed connect leaves it mid-shutdown: the library's own failure path calls
55
+ `stop()`, which *queues* a sentinel and returns without waiting. If the event loop closes before
56
+ the thread drains that sentinel — which is exactly what happens when a caller catches the error
57
+ and returns, the situation every caller of this function is in — the thread tries to deliver the
58
+ sentinel's result through `call_soon_threadsafe` on a dead loop, raises `RuntimeError: Event
59
+ loop is closed`, fails identically while trying to report that, and dies with the exception
60
+ unhandled. A test run surfaces it as `PytestUnhandledThreadExceptionWarning`.
61
+
62
+ **What that loses is the thread, and nothing else.** A connect that *failed* never produced a
63
+ `sqlite3.Connection` for anybody to drop — the driver raised instead of returning one, and the
64
+ stop the library queues behind it therefore finds no handle to close either. So this path
65
+ cannot leave a database open, and an `unclosed database` warning seen near it has a different
66
+ cause. *Superseded, in place: this docstring previously said the dropped connection "surfaces
67
+ separately as `ResourceWarning: unclosed database`", and the surrounding record called it one
68
+ bug with two symptoms. It is one symptom; the warnings counted alongside it came from raw
69
+ `sqlite3` connections in test helpers and were fixed separately.*
70
+
71
+ The number this is worth is in `test_store_connection.py`, which keeps the measurement as a
72
+ test because nothing else in the gate can tell the two states apart: coverage sees both
73
+ branches execute either way, and the matrix's warning filter errors only on deprecations.
74
+
75
+ The join runs off the event loop, so a thread that never exits costs this bound rather than
76
+ blocking the loop. `_thread` is private, and deliberately so — the library exposes no other way
77
+ to wait for a worker it has already told to stop, and `stop()`'s own return value is a future on
78
+ the loop that is about to close, which is the thing that cannot be relied on here.
79
+
80
+ **Absence is tolerated for a substitute only, and the type is what draws that line.** A test
81
+ that replaces `aiosqlite.connect` gets a plain coroutine with no worker behind it, and there is
82
+ genuinely nothing to wait for; the first version of this assumed the attribute and broke such a
83
+ test, which is how the tolerance was arrived at. A real `aiosqlite.Connection` that stopped
84
+ keeping its thread here is the opposite case — the wait would quietly become a no-op and the
85
+ defect would come back with nothing to announce it — so the check is on the type rather than on
86
+ mere presence, and `test_store_connection.py` pins the attribute against the installed library
87
+ so a version that moves it turns the matrix red instead of disabling this.
88
+ """
89
+ worker = getattr(connector, "_thread", None)
90
+ if not isinstance(worker, threading.Thread):
91
+ return
92
+ await asyncio.to_thread(worker.join, _ABANDONED_WORKER_JOIN_SECONDS)
93
+
94
+
95
+ async def _load_sqlite_vec(db: aiosqlite.Connection) -> None:
96
+ """Load the `sqlite-vec` extension on this connection, through `aiosqlite`'s own method.
97
+
98
+ `aiosqlite.Connection.load_extension` is the sanctioned path: the wrapped `sqlite3`
99
+ connection lives on `aiosqlite`'s own dedicated worker thread, so reaching into it directly
100
+ to call `sqlite_vec.load()` raises from the caller's thread instead.
101
+ """
102
+ await db.enable_load_extension(True)
103
+ try:
104
+ await db.load_extension(sqlite_vec.loadable_path())
105
+ finally:
106
+ await db.enable_load_extension(False)
107
+
108
+
109
+ async def open_connection(
110
+ db_path: Path,
111
+ *,
112
+ pragmas: Sequence[str],
113
+ existing_only: bool = False,
114
+ connect_failure: ConnectFailure | None = None,
115
+ ) -> tuple[aiosqlite.Connection, int]:
116
+ """Open `db_path` through `aiosqlite`, with the extension loaded and `pragmas` applied.
117
+
118
+ Returns the connection **and** the inode `db_path` resolved to at the moment this call's own
119
+ `aiosqlite.connect()` returned — needed so a caller can hand it onward to whatever later needs
120
+ to detect this exact file being replaced out from under an already-open connection
121
+ (`zikaron.service.lifecycle`'s inode-drift self-stop).
122
+
123
+ Never bare `sqlite3`: a handler that called it directly could hold the single-threaded event
124
+ loop for as long as SQLite's own `busy_timeout` retries, which is a genuine, previously
125
+ reproduced self-inflicted deadlock rather than a style concern. `aiosqlite` closes that
126
+ structurally by running the connection on its own thread.
127
+
128
+ A failure loading the extension or applying a pragma closes the connection before
129
+ propagating: `aiosqlite.connect` succeeding is not this function succeeding, and a caller
130
+ that only wraps its *own* work in `try`/`except` would otherwise be handed nothing to close
131
+ when the failure happened here instead.
132
+
133
+ **A known, deliberately accepted gap, settled on human authority rather than closed by
134
+ further engineering — read this before "improving" the capture below.** Several attempts
135
+ were made to close a much narrower race than the one this baseline actually needs to defend
136
+ against: a replacement landing in the specific, sub-millisecond window inside `aiosqlite`'s
137
+ own cross-thread connect handoff, between SQLite binding to a file on its worker thread and
138
+ this coroutine resuming to read the path. Pinning a file descriptor and connecting through
139
+ its own `/proc/self/fd/<n>` path — reasoned to sidestep pathname resolution entirely — was
140
+ measured, empirically, to *not* actually do so: `PRAGMA database_list` shows SQLite
141
+ canonicalizes that magic-symlink path back to the ordinary pathname internally, and a file
142
+ replaced at the path while an existing connection is live can make even an *already
143
+ established* connection fail on its next statement — meaning the assumption the whole
144
+ mechanism rested on was false, not merely incompletely implemented. Closing this properly
145
+ would require controlling SQLite's own VFS-level file handle directly (a custom VFS or
146
+ file-control integration), which is a materially larger undertaking than the value warrants
147
+ for a race with this shape: it requires an adversarial replacement to land inside a
148
+ sub-millisecond window at process startup, not the ordinary case this mechanism exists for
149
+ (a store deleted and recreated while the service has been sitting open and idle, which the
150
+ lifecycle poll closes completely). **The operator's own explicit direction is to accept this
151
+ narrow gap rather than pursue that undertaking**, and to keep the capture simple: read
152
+ `db_path.stat()` once, immediately after `await aiosqlite.connect()` returns, with no
153
+ `await` between the connect and the read — not provably instantaneous with SQLite's own
154
+ internal bind, but the tightest capture available without the VFS-level work this decision
155
+ declines, and correct for every case except the one named above.
156
+
157
+ Args:
158
+ db_path: the database file to open.
159
+ pragmas: the pragmas this database's own schema requires, applied in order. Required, not
160
+ defaulted: the two schemas want different sets, and a default is how one of them comes
161
+ to be opened under the other's.
162
+ existing_only: refuse to create `db_path` if it is absent, by opening it through a
163
+ `file:…?mode=rw` URI. The caller is left to say what absence means: for the memory
164
+ store it is a store that was never created, and for a knowledge base it is an empty
165
+ corpus, which is not an error at all.
166
+ connect_failure: what to raise when the connect itself fails. Omitted, the driver's own
167
+ error propagates. It is scoped to the connect deliberately — a caller naming a
168
+ missing store cannot also name a failed pragma, which describes a different fault.
169
+
170
+ Raises:
171
+ Exception: whatever `connect_failure` returns, if the connect failed and one was given.
172
+ aiosqlite.Error: the connect failed with no `connect_failure` given, or the extension
173
+ load or a pragma failed.
174
+ OSError: `db_path` could not be `stat`ed once the connection was established.
175
+ """
176
+ if existing_only:
177
+ connector = aiosqlite.connect(f"file:{quote(str(db_path))}?mode=rw", uri=True)
178
+ else:
179
+ connector = aiosqlite.connect(db_path)
180
+ try:
181
+ db = await connector
182
+ except aiosqlite.Error as error:
183
+ await join_abandoned_worker(connector)
184
+ if connect_failure is None:
185
+ raise
186
+ raise connect_failure(error) from error
187
+ try:
188
+ # The very first statement inside this `try`, with no `await` before it — a failure here
189
+ # (a permission error, the path having become unstatable) must close the just-established
190
+ # `db` exactly like a pragma failure a few lines below does; reading it *outside* this
191
+ # block would abandon a real, worker-thread-backed connection with nothing left to close
192
+ # it, which is a genuine resource leak this project's own history has already paid for
193
+ # once: a suite whose tests all completed in 0.76 s then hung indefinitely with an empty
194
+ # output pipe, because a non-daemon worker thread is joined before any `atexit` handler.
195
+ opened_inode = db_path.stat().st_ino
196
+ await _load_sqlite_vec(db)
197
+ for pragma in pragmas:
198
+ await db.execute(pragma)
199
+ except BaseException:
200
+ await db.close()
201
+ raise
202
+ return db, opened_inode
@@ -0,0 +1,215 @@
1
+ """The schema, as data: every `CREATE` statement `schema.md` §Tables specifies.
2
+
3
+ One statement per table or index, in creation order, so a schema change touches this module and
4
+ nothing that executes it. Foreign keys point forward in this list — `memory_chunk` before
5
+ `memory`, for instance, would fail — so the order here **is** the order the store must execute
6
+ them in, and `applier` functions elsewhere must not parallelize or reorder it.
7
+
8
+ Every literal here is a transcription of `schema.md`'s own DDL block; a test reads that block at
9
+ test time and compares, so drift between the design and this module fails a test rather than
10
+ surfacing as a table that silently does not match its own specification.
11
+ """
12
+
13
+ from typing import Final
14
+
15
+ #: The three pragmas `schema.md`'s DDL block opens with, applied to every connection this store
16
+ #: opens — creation and every later open alike, since a connection that skipped them would not
17
+ #: see concurrent readers correctly or retry under contention as the design requires.
18
+ PRAGMAS: Final[tuple[str, ...]] = (
19
+ "PRAGMA journal_mode = WAL",
20
+ "PRAGMA busy_timeout = 5000",
21
+ "PRAGMA foreign_keys = ON",
22
+ )
23
+
24
+ _MEMORY: Final = """
25
+ CREATE TABLE memory (
26
+ rowid INTEGER PRIMARY KEY,
27
+ uuid TEXT NOT NULL UNIQUE,
28
+ tier TEXT NOT NULL DEFAULT 'journal'
29
+ CHECK (tier IN ('journal', 'long_term')),
30
+ gist TEXT NOT NULL,
31
+ content TEXT NOT NULL,
32
+ active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
33
+ superseded_by TEXT REFERENCES memory(uuid) ON DELETE RESTRICT,
34
+ version INTEGER NOT NULL DEFAULT 1 CHECK (version >= 1),
35
+ created_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ session_id TEXT,
38
+ token_count INTEGER NOT NULL DEFAULT 0,
39
+
40
+ CHECK (length(trim(gist)) > 0),
41
+ CHECK (length(trim(content)) > 0),
42
+ CHECK (superseded_by IS NULL OR superseded_by <> uuid),
43
+ CHECK (superseded_by IS NULL OR active = 0)
44
+ )
45
+ """
46
+
47
+ _MEMORY_INDEXES: Final[tuple[str, ...]] = (
48
+ "CREATE INDEX idx_memory_unconsolidated ON memory(tier, active, created_at)",
49
+ "CREATE INDEX idx_memory_eligible ON memory(active, superseded_by)",
50
+ "CREATE INDEX idx_memory_superseded ON memory(superseded_by)",
51
+ "CREATE INDEX idx_memory_session ON memory(session_id)",
52
+ )
53
+
54
+ _MEMORY_FTS: Final = """
55
+ CREATE VIRTUAL TABLE memory_fts USING fts5 (
56
+ gist, content,
57
+ content = 'memory',
58
+ content_rowid= 'rowid',
59
+ tokenize = 'unicode61'
60
+ )
61
+ """
62
+
63
+ _MEMORY_CHUNK: Final = """
64
+ CREATE TABLE memory_chunk (
65
+ chunk_id INTEGER PRIMARY KEY,
66
+ memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE CASCADE,
67
+ part_index INTEGER NOT NULL,
68
+ token_count INTEGER NOT NULL,
69
+ truncated INTEGER NOT NULL DEFAULT 0 CHECK (truncated IN (0, 1)),
70
+ embed_model TEXT NOT NULL,
71
+ embed_dim INTEGER NOT NULL,
72
+ UNIQUE (memory_uuid, part_index)
73
+ )
74
+ """
75
+
76
+ _MEMORY_CHUNK_INDEX: Final = "CREATE INDEX idx_chunk_memory ON memory_chunk(memory_uuid)"
77
+
78
+ _EVENT: Final = """
79
+ CREATE TABLE event (
80
+ id INTEGER PRIMARY KEY,
81
+ at TEXT NOT NULL,
82
+ session_id TEXT,
83
+ client_kind TEXT NOT NULL CHECK (client_kind IN ('hook', 'mcp', 'consolidator')),
84
+ op_id TEXT NOT NULL,
85
+ kind TEXT NOT NULL CHECK (kind IN (
86
+ 'surface_call', 'surface', 'search', 'fetch', 'remember', 'amend', 'retire',
87
+ 'merge', 'promote', 'discard', 'dedup_offered', 'version_conflict',
88
+ 'no_receipt', 'group_served', 'consolidate_run'
89
+ )),
90
+ memory_uuid TEXT,
91
+ detail TEXT
92
+ )
93
+ """
94
+
95
+ _EVENT_INDEXES: Final[tuple[str, ...]] = (
96
+ "CREATE INDEX idx_event_kind_at ON event(kind, at)",
97
+ "CREATE INDEX idx_event_session ON event(session_id)",
98
+ "CREATE INDEX idx_event_session_kind ON event(session_id, client_kind)",
99
+ "CREATE INDEX idx_event_op ON event(op_id)",
100
+ "CREATE INDEX idx_event_uuid_at ON event(memory_uuid, at)",
101
+ )
102
+
103
+ _READ_RECEIPT: Final = """
104
+ CREATE TABLE read_receipt (
105
+ session_id TEXT NOT NULL,
106
+ client_kind TEXT NOT NULL,
107
+ memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE CASCADE,
108
+ version INTEGER NOT NULL,
109
+ at TEXT NOT NULL,
110
+ source TEXT NOT NULL CHECK (source IN ('fetch', 'group', 'conflict', 'own_write')),
111
+ PRIMARY KEY (session_id, client_kind, memory_uuid, version)
112
+ ) WITHOUT ROWID
113
+ """
114
+
115
+ _CONSOLIDATION_RUN: Final = """
116
+ CREATE TABLE consolidation_run (
117
+ run_id TEXT PRIMARY KEY,
118
+ session_id TEXT NOT NULL,
119
+ pid INTEGER NOT NULL,
120
+ started_at TEXT NOT NULL,
121
+ expires_at TEXT NOT NULL,
122
+ status TEXT NOT NULL CHECK (status IN ('active', 'complete', 'expired', 'abandoned',
123
+ 'taken_over'))
124
+ )
125
+ """
126
+
127
+ _CONSOLIDATION_GROUP: Final = """
128
+ CREATE TABLE consolidation_group (
129
+ group_id TEXT PRIMARY KEY,
130
+ run_id TEXT NOT NULL REFERENCES consolidation_run(run_id) ON DELETE CASCADE,
131
+ anchor_uuid TEXT REFERENCES memory(uuid) ON DELETE RESTRICT,
132
+ order_key TEXT NOT NULL,
133
+ shard_index INTEGER NOT NULL DEFAULT 1,
134
+ shard_count INTEGER NOT NULL DEFAULT 1,
135
+ status TEXT NOT NULL CHECK (status IN ('pending', 'served', 'complete', 'deferred')),
136
+ serve_count INTEGER NOT NULL DEFAULT 0,
137
+ served_at TEXT,
138
+ CHECK (shard_count >= 1),
139
+ CHECK (shard_index BETWEEN 1 AND shard_count),
140
+ CHECK (serve_count >= 0)
141
+ )
142
+ """
143
+
144
+ _CONSOLIDATION_GROUP_INDEX: Final = (
145
+ "CREATE INDEX idx_group_run_order "
146
+ "ON consolidation_group(run_id, status, order_key, shard_index)"
147
+ )
148
+
149
+ _CONSOLIDATION_GROUP_MEMBER: Final = """
150
+ CREATE TABLE consolidation_group_member (
151
+ group_id TEXT NOT NULL REFERENCES consolidation_group(group_id) ON DELETE CASCADE,
152
+ memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE RESTRICT,
153
+ version_seen INTEGER NOT NULL,
154
+ version_served INTEGER,
155
+ disposition TEXT CHECK (disposition IN
156
+ ('merged', 'promoted', 'discarded', 'vacated')),
157
+ disposed_at TEXT,
158
+ PRIMARY KEY (group_id, memory_uuid)
159
+ ) WITHOUT ROWID
160
+ """
161
+
162
+ _CONSOLIDATION_GROUP_MEMBER_INDEX: Final = (
163
+ "CREATE INDEX idx_group_member_open ON consolidation_group_member(group_id, disposition)"
164
+ )
165
+
166
+ _CONSOLIDATION_GROUP_CANDIDATE: Final = """
167
+ CREATE TABLE consolidation_group_candidate (
168
+ group_id TEXT NOT NULL REFERENCES consolidation_group(group_id) ON DELETE CASCADE,
169
+ memory_uuid TEXT NOT NULL REFERENCES memory(uuid) ON DELETE RESTRICT,
170
+ role TEXT NOT NULL CHECK (role IN ('anchor', 'candidate')),
171
+ version_served INTEGER NOT NULL,
172
+ rank INTEGER NOT NULL,
173
+ PRIMARY KEY (group_id, memory_uuid)
174
+ ) WITHOUT ROWID
175
+ """
176
+
177
+ _META: Final = "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
178
+
179
+ #: Every statement that creates something with **no** dependency on the validated `embed_dim`,
180
+ #: in the exact order `schema.md`'s DDL block presents them — which is also a valid dependency
181
+ #: order, since every `REFERENCES` in this list names a table created earlier in it.
182
+ FIXED_STATEMENTS: Final[tuple[str, ...]] = (
183
+ _MEMORY,
184
+ *_MEMORY_INDEXES,
185
+ _MEMORY_FTS,
186
+ _MEMORY_CHUNK,
187
+ _MEMORY_CHUNK_INDEX,
188
+ _EVENT,
189
+ *_EVENT_INDEXES,
190
+ _READ_RECEIPT,
191
+ _CONSOLIDATION_RUN,
192
+ _CONSOLIDATION_GROUP,
193
+ _CONSOLIDATION_GROUP_INDEX,
194
+ _CONSOLIDATION_GROUP_MEMBER,
195
+ _CONSOLIDATION_GROUP_MEMBER_INDEX,
196
+ _CONSOLIDATION_GROUP_CANDIDATE,
197
+ _META,
198
+ )
199
+
200
+
201
+ def memory_vec_statement(embed_dim: int) -> str:
202
+ """The one DDL statement `vec0` fixes a width into, at the validated effective `embed_dim`.
203
+
204
+ `schema.md` §"Creating the dense index" requires this width to be validated — and the
205
+ embedder that will fill the column checked against it — before this statement ever runs,
206
+ which is why it is a function rather than a member of `FIXED_STATEMENTS`: nothing may call it
207
+ with an unvalidated value.
208
+
209
+ Args:
210
+ embed_dim: the effective config's `embed_dim`, already checked to be `>= 1` and to equal
211
+ the width the configured embedder actually reports.
212
+ """
213
+ if embed_dim < 1:
214
+ raise ValueError(f"embed_dim must be >= 1, got {embed_dim}")
215
+ return f"CREATE VIRTUAL TABLE memory_vec USING vec0 (embedding float[{embed_dim}])"
@@ -0,0 +1,45 @@
1
+ """The one thing the store needs from an embedder: its width, and the model name it reports.
2
+
3
+ The store's dimension check must run before `memory_vec` is created, and creating a store embeds
4
+ nothing — so this is deliberately the *narrowest* thing an embedder can be asked for. `fastembed`
5
+ is a dependency of `zikaron-core` as a whole, not of this module: the indexing layer's `Encoder`
6
+ extends this protocol with the tokenizer and embedding calls a write path needs, and the store is
7
+ written against this narrow form so it never depends on them.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class Embedder(Protocol):
16
+ """What the store needs to check before it fixes a `vec0` column's width forever.
17
+
18
+ `vec0` fixes a column's width at `CREATE` time (`schema.md` §"Creating the dense index"), so
19
+ the store must know the width a configured model actually emits before any table exists. A
20
+ real implementation loads the model named by `model_name`; this protocol says nothing about
21
+ *how* `dim` is produced; only that it is available without embedding anything.
22
+ """
23
+
24
+ @property
25
+ def model_name(self) -> str:
26
+ """The model identifier this embedder reports, for comparison against `meta.embed_model`."""
27
+ ...
28
+
29
+ @property
30
+ def dim(self) -> int:
31
+ """The width of every vector this embedder would produce."""
32
+ ...
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class FakeEmbedder:
37
+ """An `Embedder` that reports a width without loading anything.
38
+
39
+ The store's own tests use this to exercise the width check deterministically and without
40
+ `fastembed`'s cold-start cost; nothing about the store's creation or open path can tell it
41
+ apart from a real one, since both are used purely through the protocol above.
42
+ """
43
+
44
+ model_name: str
45
+ dim: int