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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|