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
zikaron/mcp/spill.py ADDED
@@ -0,0 +1,328 @@
1
+ """Writing a tool result too large to return, to a file the model can actually read.
2
+
3
+ A harness caps what a tool result may contain. Claude Code's cap is stated in tokens, is never
4
+ named numerically, and an over-large result is replaced wholesale by an error notice — so the
5
+ consolidator sees no group, cannot decide it, and is served the same group again on the next call.
6
+ The run cannot advance past it.
7
+
8
+ **The harness already writes the result to a file, and that file is unreadable.** It holds the
9
+ tool's JSON on one line, and `Read` says outright that such a file "cannot be paginated by line":
10
+ measured, 31,247 of 104,179 characters returned and `offset`/`limit` refused. So recovering the
11
+ payload through the harness's own spill is not available, however the grant is arranged.
12
+
13
+ **What is available is writing the file ourselves.** `Read`'s cap applies per read, not per file —
14
+ a 206,719-character, 2,002-line file was read to its end in three calls — so a payload is fully
15
+ recoverable if, and only if, whoever wrote it made it line-paginable. That is the one thing the
16
+ harness does not do and this module does.
17
+
18
+ Both bounds here are **proofs rather than margins**, which is what keeps them from needing revision
19
+ as corpora grow or the harness moves a cap it has never published. A token spans at least one byte,
20
+ so bytes bound tokens for any content whatsoever — emoji, CJK, minified code — provided the counter
21
+ does not expand its input before counting, which byte-level BPE cannot. No characters-per-token
22
+ ratio appears anywhere below.
23
+ """
24
+
25
+ import atexit
26
+ import json
27
+ import os
28
+ from collections.abc import Iterable
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Final
32
+
33
+ #: The largest line this module will write, in UTF-8 bytes of the serialized line. At most this
34
+ #: many tokens, therefore, and `Read`'s measured per-read cap is 25,000 — so any line under this is
35
+ #: addressable, and a line is the smallest unit `Read` can address. Not a config key, because there
36
+ #: is no operator remedy for a single over-long record: the answer is to amend or retire it, and
37
+ #: raising a number would only move where the unreachable tail begins.
38
+ SPILL_MAX_LINE_BYTES: Final = 24_000
39
+
40
+ #: `json.dumps` indent. Structure goes onto its own lines, which is what makes `offset`/`limit`
41
+ #: work at all — but note what it does *not* do: indentation splits structure, never strings, so a
42
+ #: record's `content` remains exactly one line however this is set. That is why the line bound
43
+ #: above exists rather than being implied by pretty-printing.
44
+ _INDENT: Final = 2
45
+
46
+ #: `{store_key}-{tool}-{pid}-{random}`: four hyphen-separated fields, which is what lets a
47
+ #: name be read back for the pid without a regex, and what a shorter name is rejected against.
48
+ _NAME_FIELDS: Final = 4
49
+
50
+ #: `0600`. The file holds record prose verbatim, outside the store and outside the log rules.
51
+ _FILE_MODE: Final = 0o600
52
+
53
+
54
+ class PayloadLineTooLongError(Exception):
55
+ """A single value is too long to write on a line any reader could address.
56
+
57
+ Raised instead of writing a file whose tail is unreachable — the failure this whole module
58
+ exists to prevent, arriving through its own output. Carries the offending record's uuid where
59
+ one can be found, because unlike the threshold there is no key to lower: the remedy is to amend
60
+ or retire that record, and this is the only thing that can say which.
61
+ """
62
+
63
+ def __init__(self, *, uuid: str | None, line_bytes: int) -> None:
64
+ where = f"record {uuid}" if uuid else "a value in this payload"
65
+ # The tail stays uuid-neutral because `where` may not have named one: an instruction to
66
+ # report "that uuid" after a message that never gave one is worse than no instruction.
67
+ super().__init__(
68
+ f"{where} serializes to {line_bytes} bytes on one line, over the {SPILL_MAX_LINE_BYTES}"
69
+ " byte maximum a reader can address, so this payload cannot be delivered whole."
70
+ " Report this refusal and carry on with the run: shortening the record needs the"
71
+ " primary agent or the operator, and neither verb for it is yours."
72
+ )
73
+ self.uuid = uuid
74
+ self.line_bytes = line_bytes
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class SpillPolicy:
79
+ """Whether to spill, above what size, and where to put the file.
80
+
81
+ `enabled` comes from the harness seam rather than from a branch on the detected harness, and
82
+ it is one flag for both the capability and its use: a harness whose consolidator cannot read a
83
+ file must not be handed a path, and one that can gains nothing from being told about a file
84
+ that was never written.
85
+ """
86
+
87
+ enabled: bool
88
+ threshold_bytes: int
89
+ directory: Path
90
+ #: Identifies this store in a filename. The runtime directory is shared by every store this
91
+ #: user has — sockets disambiguate by the same hash — so without it an operator erasing a
92
+ #: secret cannot tell one store's spill files from another's without reading them.
93
+ store_key: str
94
+
95
+
96
+ def apply(result: object, *, policy: SpillPolicy, tool: str) -> object:
97
+ """`result` if it fits, else a pointer to a file holding it.
98
+
99
+ Args:
100
+ result: the value a tool handler would otherwise return.
101
+ policy: whether spilling is available here, and its bound.
102
+ tool: names the file, so a directory of them is readable by eye.
103
+
104
+ Returns:
105
+ `result` unchanged when it fits or when spilling is unavailable, otherwise a pointer
106
+ object. The pointer carries no `journal_entries` key, which is what stops it being read as
107
+ a served group by a consolidator that skimmed it.
108
+
109
+ Raises:
110
+ PayloadLineTooLongError: a single value would exceed `SPILL_MAX_LINE_BYTES`.
111
+ OSError: the file could not be written.
112
+ """
113
+ if not policy.enabled:
114
+ return result
115
+ # The threshold is compared against the **compact** encoding, because that is what the inline
116
+ # path would have sent and therefore what the harness would have had to carry. Indentation is
117
+ # added only once the decision to spill has been made: measuring the indented form would test a
118
+ # document that is never transmitted, and would put the decision a few percent above the
119
+ # quantity the default was calibrated against.
120
+ inline = json.dumps(result, ensure_ascii=False)
121
+ if len(inline.encode("utf-8")) <= policy.threshold_bytes:
122
+ return result
123
+ document = json.dumps(result, ensure_ascii=False, indent=_INDENT)
124
+ _refuse_unaddressable_line(result, document)
125
+ path = _write(document, directory=policy.directory, tool=tool, store_key=policy.store_key)
126
+ return {
127
+ "spilled": True,
128
+ "path": str(path),
129
+ "bytes": len(inline.encode("utf-8")),
130
+ "note": (
131
+ "This result was too large for this harness to deliver, so Zikaron wrote it to the "
132
+ "file above. That file is your payload: read it, to its end, and decide from it "
133
+ "exactly as you would have from a result delivered inline."
134
+ ),
135
+ }
136
+
137
+
138
+ def _refuse_unaddressable_line(result: object, document: str) -> None:
139
+ """Raise if any line of `document` is longer than a reader can address.
140
+
141
+ Checked against the rendered document rather than against the values it came from, because it
142
+ is the rendered line a reader has to fetch — an escape expansion that happens during
143
+ serialization would otherwise be invisible to a check run before it.
144
+ """
145
+ longest = max((len(line.encode("utf-8")) for line in document.splitlines()), default=0)
146
+ if longest <= SPILL_MAX_LINE_BYTES:
147
+ return
148
+ raise PayloadLineTooLongError(uuid=_uuid_of_longest_value(result), line_bytes=longest)
149
+
150
+
151
+ def _uuid_of_longest_value(result: object) -> str | None:
152
+ """The uuid of the record carrying the longest string in `result`, if it has one.
153
+
154
+ Best effort by design: the bound is enforced on the rendered line above, and this only makes
155
+ the failure actionable. A payload shape with no uuids still raises, just without naming one.
156
+ """
157
+ worst_uuid: str | None = None
158
+ worst_length = -1
159
+ stack: list[object] = [result]
160
+ while stack:
161
+ current = stack.pop()
162
+ if isinstance(current, dict):
163
+ uuid = current.get("uuid")
164
+ for value in current.values():
165
+ if isinstance(value, str):
166
+ # Measured as it will be *rendered*, not raw, so this ranks by the same
167
+ # quantity the refusal does. A value heavy in quotes, backslashes or newlines
168
+ # nearly doubles when escaped, and ranking raw lengths can therefore name a
169
+ # record that is not the one over the bound.
170
+ rendered = len(json.dumps(value, ensure_ascii=False).encode("utf-8"))
171
+ if rendered > worst_length:
172
+ worst_length = rendered
173
+ worst_uuid = uuid if isinstance(uuid, str) else None
174
+ elif isinstance(value, dict | list):
175
+ stack.append(value)
176
+ elif isinstance(current, list):
177
+ stack.extend(current)
178
+ return worst_uuid
179
+
180
+
181
+ def _write(document: str, *, directory: Path, tool: str, store_key: str) -> Path:
182
+ """Write `document` and return its path, at `0600`, with a name unique to this write.
183
+
184
+ **Unique per write rather than per group**, and nothing already written is removed to make
185
+ room. A re-serve must not overwrite a file a reader is partway through — and deleting the
186
+ previous spill would be worse than overwriting it: a `merge`/`promote`/`discard` conflict
187
+ response spills through this same path, so "delete the last one" would remove the group's own
188
+ payload while the consolidator is still dispositioning that group from it.
189
+
190
+ The name carries `store_key` because the runtime directory is shared by every store this user
191
+ has, and an operator erasing a secret needs to find one store's files without reading them.
192
+
193
+ Created at `0700` explicitly rather than at the process umask. The directory is the socket's
194
+ own home and so provably existed moments ago, vetted by whoever spawned the service; this
195
+ neither repeats that vetting nor silently relaxes it — the file itself is `0600`, `O_EXCL` and
196
+ unpredictably named, which is what the contents actually rest on.
197
+ """
198
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
199
+ path = directory / f"{store_key}-{tool}-{os.getpid()}-{os.urandom(8).hex()}.json"
200
+ # Opened through `os.open` with the mode supplied, rather than written and then chmod-ed: the
201
+ # window between those two is one in which record prose sits at the process umask.
202
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, _FILE_MODE)
203
+ try:
204
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
205
+ handle.write(document)
206
+ except BaseException:
207
+ # A tmpfs that filled mid-write would otherwise leave a truncated file behind, occupying
208
+ # the space that caused the failure and looking from the outside like a readable payload.
209
+ path.unlink(missing_ok=True)
210
+ raise
211
+ _written.append(path)
212
+ return path
213
+
214
+
215
+ #: Every spill this process has written and has not yet released.
216
+ _written: Final[list[Path]] = []
217
+
218
+
219
+ def release_finished(policy: SpillPolicy) -> None:
220
+ """Unlink every spill this process wrote before now. Call when the next group is requested.
221
+
222
+ **Safe for a narrower reason than it first appears, and the narrow one is what holds.** It is
223
+ *not* that the previous group becomes unreachable: authorization is rewritten per group rather
224
+ than globally, a failed request rewrites nothing at all, and an unfinished group is deliberately
225
+ re-served before the run advances — so a consolidator can and does come back to it. What makes
226
+ the release safe is that **every route back passes through a fresh serve, which spills a fresh
227
+ copy**, so a released file is never the last copy of anything still reachable.
228
+
229
+ **Releasing on any earlier signal would not be safe.** A `merge`/`promote`/`discard` conflict
230
+ response spills through this same path, so "release when writing the next file" would delete the
231
+ group's own payload while the consolidator is still reading it to disposition that very group.
232
+ The trigger is a new *group*, not a new *file*, and that distinction is the whole of it.
233
+
234
+ This bounds what is live to one group's files rather than a whole run's, and a run that ends
235
+ properly leaves nothing: a consolidator learns the run is over by calling for a group and
236
+ receiving `{done: true}`, and that call releases the last group first. What `sweep_stale`
237
+ reaches is the run that *stops without asking again* — a killed process, or a consolidator that
238
+ quits after the final `group_complete`.
239
+ """
240
+ if not policy.enabled:
241
+ return
242
+ _unlink_all(_written)
243
+ _written.clear()
244
+
245
+
246
+ def sweep_stale(policy: SpillPolicy) -> None:
247
+ """Unlink this store's spill files left behind by processes that are no longer running.
248
+
249
+ **This is the mechanism that actually reaches a killed process, and killed is how these
250
+ processes end.** A harness runs one MCP server per session and terminates it when the session
251
+ ends; a terminated process runs no `atexit` handler, and one killed outright could not run one
252
+ anyway. So cleanup cannot depend on this process's own exit — it depends on the *next* one's
253
+ start, which needs nothing from the dead.
254
+
255
+ Scoped by store key and by liveness, both read straight off the filename. A file whose pid is
256
+ still running is left alone: it may belong to a concurrent consolidator on the same store, and
257
+ it may be being read right now.
258
+
259
+ **One window this cannot close, named so it is not met as a bug.** The pid in the name is the
260
+ MCP *server's*; the reader is the harness session's own file-reading tool. If a server dies
261
+ mid-session and the harness starts a replacement, that replacement sweeps its predecessor's
262
+ files — possibly including the group the session is partway through reading. The failure is the
263
+ acceptable kind: the continuation read fails loudly with a missing file, and one more request
264
+ re-serves the group with a fresh copy. It is also the real reason the primary client must not
265
+ sweep — liveness alone would protect a *live* consolidator's files, but a primary sweeping at
266
+ every session start would widen exactly this dead-writer window.
267
+ """
268
+ if not policy.enabled:
269
+ return
270
+ ours = os.getpid()
271
+ stale = []
272
+ for path in policy.directory.glob(f"{policy.store_key}-*.json"):
273
+ pid = _pid_of(path.name)
274
+ if pid is None or pid == ours or _is_running(pid):
275
+ continue
276
+ stale.append(path)
277
+ _unlink_all(stale)
278
+
279
+
280
+ def _pid_of(name: str) -> int | None:
281
+ """The pid component of a spill filename, or `None` if this is not one of ours.
282
+
283
+ The name is `{store_key}-{tool}-{pid}-{random}.json`; the store key is hex and tool names carry
284
+ underscores rather than hyphens, so the pid is the second-to-last hyphen-separated field. An
285
+ unparseable name is left alone rather than guessed at — this function decides what to delete.
286
+ """
287
+ parts = name.removesuffix(".json").split("-")
288
+ if len(parts) < _NAME_FIELDS:
289
+ return None
290
+ try:
291
+ return int(parts[-2])
292
+ except ValueError:
293
+ return None
294
+
295
+
296
+ def _is_running(pid: int) -> bool:
297
+ """Whether `pid` is a live process. `PermissionError` means it exists under another user."""
298
+ try:
299
+ os.kill(pid, 0)
300
+ except ProcessLookupError:
301
+ return False
302
+ except PermissionError:
303
+ return True
304
+ return True
305
+
306
+
307
+ def _unlink_all(paths: Iterable[Path]) -> None:
308
+ for path in paths:
309
+ try:
310
+ path.unlink(missing_ok=True)
311
+ except OSError:
312
+ # One file this process cannot remove must not strand the rest.
313
+ continue
314
+
315
+
316
+ def _unlink_written_at_exit() -> None:
317
+ """Registered for a clean exit, which is not how these processes usually end.
318
+
319
+ Kept because it costs nothing and covers the case where one does exit normally — a test, an
320
+ embedding, a harness that terminates gracefully. It is explicitly **not** the mechanism the
321
+ design relies on; `sweep_stale` is, and the reason is measured: a real consolidation left four
322
+ spill files behind, because the harness killed the server rather than letting it exit.
323
+ """
324
+ _unlink_all(_written)
325
+ _written.clear()
326
+
327
+
328
+ atexit.register(_unlink_written_at_exit)
@@ -0,0 +1,67 @@
1
+ """The names of the tools each mode registers, as data.
2
+
3
+ D32's two tool sets, in one place that is neither a server module nor a test. It exists because
4
+ **the installer has to spell these names in shipped prose**: Claude Code addresses an MCP tool
5
+ as `mcp__<server>__<tool>` and the model sees that string verbatim
6
+ (`research/claude-code-installer-probe.md` §6), so the consolidator prompt and the skill body cannot
7
+ carry bare names under that harness. A second hand-maintained list of tool names is exactly the
8
+ drift this corpus keeps paying for — a name that stops matching produces a model told to call a tool
9
+ that does not exist, which improvises rather than failing — so the lists live here and
10
+ `tests/test_mcp_server.py` asserts them against the tools the **real** servers actually register.
11
+
12
+ **Every name is `zikaron_<subsystem>_<verb>`, and the subsystem segment earns its length.** One
13
+ process serves two stores that answer different questions — agent-authored memories, and fragments
14
+ of files nobody here authored — and a model choosing between them reads the name before it reads
15
+ the description. A bare `zikaron_search` beside `zikaron_knowledge_search` reads as the general
16
+ case of the other rather than as a different store, and the resulting call costs a round trip to
17
+ return a confidently empty answer about the wrong corpus.
18
+
19
+ Spelled out in full rather than assembled from a prefix constant, deliberately: these strings are
20
+ what a model is shown, what a shipped prompt names, and what a design table is compared against, so
21
+ searching the repository for one has to find every place it is written.
22
+
23
+ Stdlib-only and import-cheap by construction (it imports nothing at all), because `zikaron.install`
24
+ reads it and the MCP server modules it describes pull in `fastmcp`.
25
+ """
26
+
27
+ from typing import Final
28
+
29
+ #: The tools `register_primary_tools` decorates onto a `--mode primary` server: D32's five memory
30
+ #: verbs, plus the knowledge index's search and its six management verbs. Managing corpora is a
31
+ #: primary-agent job and nothing else — the consolidator has no use for a corpus of files, and
32
+ #: keeping every one of these out of that mode is the same structural guarantee the five memory
33
+ #: verbs already rely on.
34
+ PRIMARY_TOOLS: Final = frozenset(
35
+ {
36
+ "zikaron_memory_search",
37
+ "zikaron_memory_fetch",
38
+ "zikaron_memory_remember",
39
+ "zikaron_memory_amend",
40
+ "zikaron_memory_retire",
41
+ "zikaron_knowledge_search",
42
+ "zikaron_knowledge_list",
43
+ "zikaron_knowledge_status",
44
+ "zikaron_knowledge_add",
45
+ "zikaron_knowledge_remove",
46
+ "zikaron_knowledge_rename",
47
+ "zikaron_knowledge_refresh",
48
+ }
49
+ )
50
+
51
+ #: The four tools `register_consolidator_tools` decorates onto a `--mode consolidator` server.
52
+ #: Never registered in a primary process at all, which is the seam D7's claim rests on. They carry
53
+ #: the memory prefix because that is the store they rewrite; the mode a tool runs in is not part of
54
+ #: its name, since nothing a model reads tells it which mode it is in.
55
+ CONSOLIDATOR_TOOLS: Final = frozenset(
56
+ {
57
+ "zikaron_memory_next_group",
58
+ "zikaron_memory_merge",
59
+ "zikaron_memory_promote",
60
+ "zikaron_memory_discard",
61
+ }
62
+ )
63
+
64
+ #: Every tool name Zikaron ships. Which *server* owns each is deliberately **not** here:
65
+ #: server names belong to `zikaron.install.entries`, which already declares them for the
66
+ #: `mcpServers` map, and repeating them here would be the same drift this module exists to prevent.
67
+ ALL_TOOLS: Final = PRIMARY_TOOLS | CONSOLIDATOR_TOOLS
zikaron/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """The long-running server: Unix-socket JSON-RPC, the resolution preamble, and lifecycle."""
@@ -0,0 +1,126 @@
1
+ """The one place a difference between supported Python versions is allowed to live, as data.
2
+
3
+ Two `asyncio` behaviours differ across the versions this package supports, and both are load-bearing
4
+ for the service's shutdown path:
5
+
6
+ * **How many connections a server has attached.** 3.12 keeps an integer counter; 3.13 replaced it
7
+ with a set of attached transports. Both are private, both are incremented and decremented at the
8
+ same points in the accept pipeline, and there is no public accessor for the quantity in either.
9
+ * **Whether closing a Unix server unlinks its own socket file.** From 3.13, `create_unix_server`
10
+ removes the socket path on close unless told not to. On its self-stop and signal paths this
11
+ package unlinks the socket itself *before* closing the listener, so that a client arriving
12
+ mid-shutdown finds nothing to connect to; a second unlinker would make that ordering meaningless
13
+ wherever it holds. So the option is switched off wherever it exists. It does not exist on 3.12,
14
+ where passing it raises `TypeError`.
15
+
16
+ Rows are keyed by the version that *introduced* a behaviour, not by every version supported, and a
17
+ lookup takes the greatest key not above the version asked about. So a release that changed neither
18
+ of these needs no row, and a release newer than anything this package has been tested on gets the
19
+ newest row — which means a further change to either private name surfaces as a loud failure on the
20
+ first shutdown rather than as a silent fallback.
21
+
22
+ **Rows rather than `if sys.version_info >= (3, 13):`, for a mechanical reason.** Type checking runs
23
+ against a single configured version, and a version comparison is evaluated at analysis time, so the
24
+ losing branch is skipped — silently, even with unreachable-code warnings on. Measured here with a
25
+ deliberate type error in each position: inside the `if`, unreported; inside the `else`, reported; in
26
+ both rows of a keyed table, reported. A branch would ship one row unchecked on every interpreter. So
27
+ this module contains no version comparisons outside `row_for`, whose two are on its *argument*
28
+ rather than on the running version — nothing to constant-fold, and both rows stay checked.
29
+
30
+ Nothing else in this package or its tests reads the running version or either private attribute; a
31
+ test enforces that by scanning the tree, and this module and that test are its only two exemptions.
32
+ """
33
+
34
+ import asyncio
35
+ import platform
36
+ import sys
37
+ from collections.abc import Callable, Mapping
38
+ from dataclasses import dataclass
39
+ from typing import Final
40
+
41
+ #: The lowest version any row covers. A lookup below it is refused by name rather than left to the
42
+ #: bare `max()` below, which would say "max() arg is an empty sequence" and name nothing. This is
43
+ #: for an explicit caller passing a literal: a below-floor *interpreter* cannot reach it, since the
44
+ #: package uses syntax those releases cannot parse and the packaging metadata refuses to install
45
+ #: there, so the lookup on the running version never sees one.
46
+ _FLOOR: Final = (3, 12)
47
+
48
+
49
+ def _count_by_active_count(server: asyncio.Server) -> int:
50
+ """The integer counter, through 3.12."""
51
+ return int(server._active_count) # type: ignore[attr-defined]
52
+
53
+
54
+ def _count_by_clients(server: asyncio.Server) -> int:
55
+ """The set of attached transports, from 3.13."""
56
+ return len(server._clients) # type: ignore[attr-defined]
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class _Behaviour:
61
+ """One row: everything that differs from the row below it.
62
+
63
+ The server keyword arguments are held as a tuple of pairs rather than a mapping so that a caller
64
+ handed the built value cannot mutate what every later caller gets.
65
+ """
66
+
67
+ attached_connection_count: Callable[[asyncio.Server], int]
68
+ unix_server_kwargs: tuple[tuple[str, object], ...]
69
+
70
+
71
+ #: Keyed by the version that introduced each behaviour. 3.14 changed neither, so it has no row of
72
+ #: its own and resolves to 3.13's — adding a key here says "something changed in this release",
73
+ #: which is a different statement from "this release is supported".
74
+ _ROWS: Final[Mapping[tuple[int, int], _Behaviour]] = {
75
+ (3, 12): _Behaviour(
76
+ attached_connection_count=_count_by_active_count,
77
+ unix_server_kwargs=(),
78
+ ),
79
+ (3, 13): _Behaviour(
80
+ attached_connection_count=_count_by_clients,
81
+ unix_server_kwargs=(("cleanup_socket", False),),
82
+ ),
83
+ }
84
+
85
+
86
+ def row_for(version: tuple[int, int]) -> tuple[int, int]:
87
+ """Which row applies to `version`: the greatest key not above it.
88
+
89
+ Takes the version explicitly, with no default, so that the interesting case can be asked about
90
+ at all: the rule that matters most is the one that fires on a release no test process can be
91
+ running under.
92
+ """
93
+ if version < _FLOOR:
94
+ raise ValueError(
95
+ f"Python {version[0]}.{version[1]} is below this package's supported floor "
96
+ f"{_FLOOR[0]}.{_FLOOR[1]}"
97
+ )
98
+ return max(key for key in _ROWS if key <= version)
99
+
100
+
101
+ def _running_row() -> _Behaviour:
102
+ return _ROWS[row_for(sys.version_info[:2])]
103
+
104
+
105
+ def attached_connection_count(server: asyncio.Server) -> int:
106
+ """How many connections the server currently has attached.
107
+
108
+ This is the quantity `wait_closed()` itself waits on, which is why shutdown polls it rather than
109
+ its own set of handler tasks: a transport is attached synchronously, while the task that
110
+ registers the handler is scheduled separately and runs several event-loop turns later.
111
+ """
112
+ return _running_row().attached_connection_count(server)
113
+
114
+
115
+ def unix_server_kwargs() -> dict[str, object]:
116
+ """The keyword arguments `asyncio.start_unix_server` must be called with on this interpreter."""
117
+ return dict(_running_row().unix_server_kwargs)
118
+
119
+
120
+ def interpreter_version() -> str:
121
+ """This interpreter's version, as a string, for logging.
122
+
123
+ Read here rather than where it is logged so that reading the running version stays confined to
124
+ this module.
125
+ """
126
+ return platform.python_version()