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,466 @@
1
+ """`python -m zikaron.knowledge` — the knowledge-base verbs, for a person at a shell.
2
+
3
+ Flow, argument parsing and printing live here; every decision lives in `zikaron.core.knowledge`,
4
+ which the agent-facing tools will call through the same functions. This module deliberately holds
5
+ no rule of its own, so the two surfaces cannot diverge on what a verb means.
6
+
7
+ **Nothing here waits for a build.** `add` and `refresh` start one and return, because a build is
8
+ minutes of work over a whole directory tree and holding a shell for it would make the two verbs that
9
+ create a corpus the two slowest things this command does. The build is its own command with its own
10
+ entry point, which is both what gets spawned and what an operator runs by hand when they want to
11
+ watch one.
12
+ """
13
+
14
+ import argparse
15
+ import shlex
16
+ import sys
17
+ from collections.abc import Mapping, Sequence
18
+ from pathlib import Path
19
+ from typing import Final
20
+
21
+ import aiosqlite
22
+
23
+ from zikaron.core.config.resolution import EffectiveConfig
24
+ from zikaron.core.knowledge import builds, lifecycle, lock, meta, reporting, state
25
+ from zikaron.knowledge import scope
26
+ from zikaron.knowledge.indexer import detach
27
+
28
+ _UNSET = "—"
29
+
30
+
31
+ _MODULE_INVOCATION: Final = "python -m zikaron.knowledge"
32
+
33
+
34
+ def _parser(prog: str) -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog=prog,
37
+ description="Manage this project's knowledge bases: named, indexed corpora of text files.",
38
+ )
39
+ parser.add_argument(
40
+ "--project",
41
+ type=Path,
42
+ default=None,
43
+ help="the project whose store to act on. The default is the harness's own project "
44
+ "directory where it names one, else the current directory.",
45
+ )
46
+ verbs = parser.add_subparsers(dest="verb", required=True)
47
+
48
+ verbs.add_parser("list", help="every knowledge base, and whether each one is usable")
49
+
50
+ adding = verbs.add_parser("add", help="register a corpus and create its database")
51
+ adding.add_argument("name", help="what to call it. Free-form; lower-cased when stored")
52
+ adding.add_argument(
53
+ "--path",
54
+ type=Path,
55
+ required=True,
56
+ help="the directory to index. A relative path is taken from this shell's working "
57
+ "directory, as any other command's would be — the agent-facing tool takes one from the "
58
+ "project root instead, since it has no shell to be relative to.",
59
+ )
60
+ adding.add_argument(
61
+ "--description",
62
+ required=True,
63
+ help="what this corpus holds. Required: it is what lets a later caller choose between "
64
+ "corpora without searching every one of them.",
65
+ )
66
+ adding.add_argument(
67
+ "--include",
68
+ action="append",
69
+ default=[],
70
+ metavar="GLOB",
71
+ help="repeatable. Matched against the path relative to --path, where '*' crosses '/' — so "
72
+ "'*.md' reaches every markdown file and 'docs/*' means the whole tree under docs.",
73
+ )
74
+ adding.add_argument(
75
+ "--exclude",
76
+ action="append",
77
+ default=[],
78
+ metavar="GLOB",
79
+ help="repeatable, and applied before --include. Same matching as --include.",
80
+ )
81
+ adding.add_argument(
82
+ "--git-mode",
83
+ type=meta.GitMode,
84
+ choices=tuple(meta.GitMode),
85
+ default=meta.GitMode.TRACKED,
86
+ help="how much git is consulted. Degrades to 'off' outside a work tree, and says so.",
87
+ )
88
+ adding.add_argument(
89
+ "--max-file-bytes",
90
+ type=int,
91
+ default=None,
92
+ help="this corpus's own size cap (default: from configuration). Over-cap files are "
93
+ "skipped and counted, never truncated.",
94
+ )
95
+
96
+ removing = verbs.add_parser("remove", help="destroy a knowledge base and its database")
97
+ removing.add_argument("name")
98
+ removing.add_argument(
99
+ "--yes",
100
+ action="store_true",
101
+ help="actually do it. Without this the call reports what would be destroyed and stops.",
102
+ )
103
+
104
+ renaming = verbs.add_parser("rename", help="change a knowledge base's name")
105
+ renaming.add_argument("name")
106
+ renaming.add_argument("new_name", metavar="new-name")
107
+
108
+ reporting_parser = verbs.add_parser("status", help="the detail behind a knowledge base's state")
109
+ reporting_parser.add_argument("name", nargs="?", default=None)
110
+
111
+ refreshing = verbs.add_parser("refresh", help="build a knowledge base's index")
112
+ refreshing.add_argument(
113
+ "name",
114
+ nargs="?",
115
+ default=None,
116
+ help="which knowledge base to build. Omit it to reach every one, each checked on its own.",
117
+ )
118
+ refreshing.add_argument(
119
+ "--full",
120
+ action="store_true",
121
+ help="reindex every file rather than only what changed. For when the thing that moved is "
122
+ "not the files: a changed chunk budget, or content the index was handed through a filter "
123
+ "that has changed since.",
124
+ )
125
+ refreshing.add_argument(
126
+ "--force-unlock",
127
+ action="store_true",
128
+ help="clear a build lock nothing will clear on its own — one recorded by another machine, "
129
+ "which this one cannot probe. Refuses while a process on this host still answers to the "
130
+ "recorded pid.",
131
+ )
132
+
133
+ return parser
134
+
135
+
136
+ def main(argv: Sequence[str] | None = None, *, prog: str = _MODULE_INVOCATION) -> int:
137
+ """Run one verb, printing an account of what happened. Returns a process exit status.
138
+
139
+ Returns rather than calling `sys.exit`, so a test drives the whole command in-process and
140
+ reads both the status and the output. `__main__` is the only place a status becomes an exit.
141
+ """
142
+ args = _parser(prog).parse_args(sys.argv[1:] if argv is None else argv)
143
+ # Carried on the namespace rather than threaded through four signatures, because the only thing
144
+ # that needs it is the follow line a build prints, and the namespace is already what every verb
145
+ # is handed.
146
+ args.invocation = prog
147
+ return scope.execute(lambda: _run(args), printer=lambda line: print(line, file=sys.stderr))
148
+
149
+
150
+ async def _run(args: argparse.Namespace) -> int:
151
+ """Open the store once, run the verb, and close it however it ends."""
152
+ async with scope.open_store(args.project) as store:
153
+ return await _dispatch(args, store)
154
+
155
+
156
+ async def _dispatch(args: argparse.Namespace, store: scope.OpenStore) -> int:
157
+ store_dir, db, config = store.directory, store.connection, store.config
158
+ if args.verb == "list":
159
+ _print_listing(await reporting.list_bases(store_dir, db, config), detailed=False)
160
+ return 0
161
+ if args.verb == "status":
162
+ _print_listing(await reporting.status(store_dir, db, config, name=args.name), detailed=True)
163
+ return 0
164
+ if args.verb == "add":
165
+ return await _add(args, store)
166
+ if args.verb == "rename":
167
+ renamed = await lifecycle.rename(
168
+ store_dir, db, config, name=args.name, new_name=args.new_name
169
+ )
170
+ print(f"renamed to {renamed.summary.name!r}")
171
+ return 0
172
+ if args.verb == "remove":
173
+ return await _remove(args, store_dir, db, config)
174
+ if args.verb == "refresh":
175
+ return await _refresh(args, store)
176
+ # Unreachable through the command, which refuses an unknown verb before dispatch. Loud rather
177
+ # than a trailing `return`, because the branch a fall-through would land in is the destructive
178
+ # one — a verb added to the parser and forgotten here must fail, not remove a knowledge base.
179
+ raise NotImplementedError(f"no handler for the verb {args.verb!r}")
180
+
181
+
182
+ async def _add(args: argparse.Namespace, store: scope.OpenStore) -> int:
183
+ created = await lifecycle.add(
184
+ store.directory,
185
+ store.connection,
186
+ store.config,
187
+ lifecycle.AddRequest(
188
+ name=args.name,
189
+ root=args.path,
190
+ description=args.description,
191
+ include_globs=tuple(args.include),
192
+ exclude_globs=tuple(args.exclude),
193
+ git_mode=args.git_mode,
194
+ max_file_bytes=args.max_file_bytes,
195
+ ),
196
+ )
197
+ print(f"added {created.knowledge_base.name!r}")
198
+ print(f"database {created.database_path}")
199
+ # The state a corpus is created in, before its first build has committed anything. It is the
200
+ # honest answer rather than a placeholder: the database exists and its settings are sound, and
201
+ # nothing has been indexed into it yet.
202
+ print(f"state {created.status.summary.state.value}")
203
+ if created.git_mode_effective is not created.git_mode:
204
+ print(
205
+ f"\ngit-mode {created.git_mode.value!r} will not take effect: {args.path} is not "
206
+ f"inside a git work tree, so this corpus is built as "
207
+ f"{created.git_mode_effective.value!r}."
208
+ )
209
+ _start_build(created.knowledge_base.name, store, full=False, prog=args.invocation)
210
+ _explain_detachment()
211
+ return 0
212
+
213
+
214
+ async def _refresh(args: argparse.Namespace, store: scope.OpenStore) -> int:
215
+ """Clear a lock if asked to, check what can be checked, and start a build in the background.
216
+
217
+ Returns non-zero when any named corpus could not be built, so a shell sees a refusal as a
218
+ failure — but never for `already_indexing`, which is the idempotent case: a refresh that met a
219
+ running build got what it asked for, and a loop of them must not read as a loop of failures.
220
+ """
221
+ if args.force_unlock:
222
+ if args.name is None:
223
+ _refuse("--force-unlock clears one knowledge base's lock, so name one")
224
+ return 1
225
+ _report_unlock(await lifecycle.unlock(store.directory, store.connection, name=args.name))
226
+ # Everything decidable without reading a file is decided here, in front of whoever ran this:
227
+ # the build itself detaches, and a refusal it raised would go to a discarded stream.
228
+ names = None if args.name is None else [args.name]
229
+ planned = await builds.plan(store.directory, store.connection, store.config, names=names)
230
+ if not planned:
231
+ print("no knowledge bases in this project")
232
+ started = False
233
+ refused = False
234
+ for entry in planned:
235
+ if entry.may_start:
236
+ _start_build(entry.knowledge_base.name, store, full=args.full, prog=args.invocation)
237
+ started = True
238
+ else:
239
+ refused = _report_obstacle(entry) or refused
240
+ if started:
241
+ _explain_detachment()
242
+ return 1 if refused else 0
243
+
244
+
245
+ def _refuse(message: str) -> None:
246
+ """Report a refusal where every other refusal from this command goes.
247
+
248
+ Standard error, so that redirecting a sweep's report leaves the reasons it could not build
249
+ something visible — and so a refusal reads the same whether it came from here or from the one
250
+ handler that turns a raised one into a line.
251
+ """
252
+ print(f"refused {message}", file=sys.stderr)
253
+
254
+
255
+ def _report_obstacle(entry: builds.PlannedBuild) -> bool:
256
+ """Say why one corpus was not built, and whether that counts as a failed command.
257
+
258
+ Three outcomes rather than two, because this command's two prefixes mean different things and
259
+ one of these is neither.
260
+
261
+ `already_indexing` is not a refusal at all: the corpus is being built, which is what was asked
262
+ for. It is ordinary output and it does not fail the command, so a loop of refreshes against a
263
+ running build does not read as a loop of failures.
264
+
265
+ An unreadable database is a *failure* rather than a refusal, and takes the prefix this command
266
+ gives a failure everywhere else. A refusal is this system declining something it understood —
267
+ the name is unknown, the root is gone, a build holds the lock — and names what to do instead.
268
+ A database that will not open is the driver's own error travelling out unrenamed, because
269
+ nothing invented here would say what went wrong better and nothing the caller can do fixes it.
270
+ """
271
+ name = entry.knowledge_base.name
272
+ if entry.obstacle is builds.BuildObstacle.ALREADY_INDEXING:
273
+ print(f"building {name!r} is already being built; nothing was queued")
274
+ return False
275
+ if entry.obstacle is builds.BuildObstacle.UNREADABLE:
276
+ print(f"failed {name}: {entry.refusal}", file=sys.stderr)
277
+ return True
278
+ _refuse(f"{name}: {entry.refusal}")
279
+ return True
280
+
281
+
282
+ def _report_unlock(cleared: lock.LockHolder | None) -> None:
283
+ if cleared is None:
284
+ print("unlocked no lock was recorded; nothing to clear")
285
+ return
286
+ print(f"unlocked cleared the lock held by {cleared.describe()}")
287
+
288
+
289
+ def _start_build(name: str, store: scope.OpenStore, *, full: bool, prog: str) -> None:
290
+ """Spawn one build and print the command that reproduces it in the foreground.
291
+
292
+ The foreground command is printed in full rather than described, because it is the only way to
293
+ recover the *reason* a detached build failed: its own output goes nowhere, so what is left is
294
+ to run the identical command where its output can be seen. It is the argv the spawn reports
295
+ rather than a second construction of it, so the two cannot differ.
296
+
297
+ Printed per build rather than once, because a sweep starts several and each has its own argv;
298
+ a single example would leave whoever met a failure in the second corpus to reconstruct the
299
+ command for it.
300
+ """
301
+ argv = detach.spawn(name, project=store.project, full=full)
302
+ print(f"building {name!r} in the background")
303
+ # Quoted, because a name is free-form and two-word ones are ordinary — an unquoted one pasted
304
+ # back is a command that fails to parse, which is worse than useless in a line offered as the
305
+ # way to follow a build.
306
+ # `prog` rather than a literal: under `uv tool install` no interpreter is on `PATH`, so a
307
+ # hard-coded `python -m` line is a command the first corpus a user ever builds tells them to
308
+ # run and which then fails with `No module named zikaron`.
309
+ print(f"follow {prog} status {shlex.quote(name)}")
310
+ print(f"foreground {shlex.join(argv)}")
311
+
312
+
313
+ def _explain_detachment() -> None:
314
+ """The one note every started build shares, printed after them rather than once per build."""
315
+ print("\nA detached build's output is discarded. To watch one, or to see why one failed, run")
316
+ print("the foreground command printed beside it.")
317
+
318
+
319
+ def _what_goes(found: reporting.Status) -> str:
320
+ """How much a removal would destroy, or that nobody can say.
321
+
322
+ A corpus whose database is present and will not open may hold a fully built index — refused for
323
+ a permission reason, or for a `schema_version` this build does not know — so counting it as
324
+ empty is a confident number nothing backs, on the one verb nothing undoes. Its own counts say
325
+ what can be *served*, which is not what somebody about to delete it is asking.
326
+ """
327
+ if found.details is None and found.summary.state is state.KnowledgeState.ERROR:
328
+ return "an unknown amount — its database cannot be read"
329
+ chunks = 0 if found.details is None else found.details.chunks
330
+ return f"{found.summary.files_indexed} files, {chunks} chunks"
331
+
332
+
333
+ async def _remove(
334
+ args: argparse.Namespace,
335
+ store_dir: Path,
336
+ db: aiosqlite.Connection,
337
+ config: EffectiveConfig,
338
+ ) -> int:
339
+ if not args.yes:
340
+ listing = await reporting.status(store_dir, db, config, name=args.name)
341
+ (found,) = listing.knowledge_bases
342
+ print(f"would destroy {found.summary.name!r}: {_what_goes(found)}, and its database.")
343
+ print("Pass --yes to actually do it.")
344
+ return 1
345
+ removed = await lifecycle.remove(store_dir, db, config, name=args.name)
346
+ print(f"removed {removed.knowledge_base.name!r}")
347
+ for path in removed.files_unlinked:
348
+ print(f"unlinked {path}")
349
+ return 0
350
+
351
+
352
+ def _print_listing(listing: reporting.Listing, *, detailed: bool) -> None:
353
+ if not listing.knowledge_bases:
354
+ print("no knowledge bases in this project")
355
+ for report in listing.knowledge_bases:
356
+ _print_summary(report.summary)
357
+ if detailed:
358
+ _print_details(report)
359
+ _print_orphans(listing.orphans)
360
+
361
+
362
+ def _print_summary(summary: reporting.Summary) -> None:
363
+ remaining = _UNSET if summary.files_remaining is None else str(summary.files_remaining)
364
+ print(f"\n{summary.name}")
365
+ print(f" {summary.description}")
366
+ print(f" state {summary.state.value} files {summary.files_indexed} remaining {remaining}")
367
+
368
+
369
+ def _joined(values: Sequence[str]) -> str:
370
+ """Patterns as a caller would type them, or a marker when there are none."""
371
+ return ", ".join(values) if values else _UNSET
372
+
373
+
374
+ def _by_reason(counts: Mapping[str, int]) -> str:
375
+ """Every skip reason with a count, or a marker when nothing was skipped.
376
+
377
+ Reasons that fired are listed rather than the whole nine every time, because the question this
378
+ answers is *why is a file missing from the corpus* and a wall of zeroes buries the one that is
379
+ not. The all-zero case says so explicitly instead of printing an empty line, since "nothing was
380
+ skipped" and "this was not measured" are different answers.
381
+ """
382
+ fired = [f"{reason} {count}" for reason, count in sorted(counts.items()) if count]
383
+ return ", ".join(fired) if fired else "none"
384
+
385
+
386
+ def _print_details(report: reporting.Status) -> None:
387
+ """The diagnostic half, or why there is none.
388
+
389
+ A corpus with nothing to report is in one of two conditions, and they call for opposite
390
+ reassurances. A database that is **absent** has genuinely never been built, and saying so is
391
+ the whole answer. A database that is **present and will not open** — refused for a permission
392
+ reason, or for a schema this build does not know — may hold a completely built corpus, so
393
+ telling a reader nothing has been built here is a confident claim about something nobody can
394
+ measure. The state line above already names which of the two it is — `reindex_required` or
395
+ `error`; this line is what a reader takes the meaning from.
396
+ """
397
+ details = report.details
398
+ if details is None:
399
+ if report.summary.state is state.KnowledgeState.ERROR:
400
+ print(" (its database is present and cannot be read — what it holds is unknown)")
401
+ else:
402
+ print(" (no database to read — nothing has been built here yet)")
403
+ return
404
+ effective = _UNSET if details.git_mode_effective is None else details.git_mode_effective.value
405
+ print(f" root {details.root_path}")
406
+ print(f" git-mode {details.git_mode.value} (last build used {effective})")
407
+ print(f" include {_joined(details.include_globs)}")
408
+ print(f" exclude {_joined(details.exclude_globs)}")
409
+ print(f" chunks {details.chunks} bytes {details.bytes_indexed}")
410
+ print(f" max-file {details.max_file_bytes} bytes")
411
+ print(f" seen {details.files_seen} skipped {details.files_skipped}")
412
+ print(f" skipped {_by_reason(details.skipped)}")
413
+ print(
414
+ f" searches {details.searches} ({details.searches_empty} empty), "
415
+ f"results {details.results_returned} ({details.results_stale} stale)"
416
+ )
417
+ print(
418
+ f" last scan {details.last_scan_started_at or _UNSET} .. "
419
+ f"{details.last_scan_completed_at or _UNSET}"
420
+ )
421
+ if details.lock is not None:
422
+ print(f" build {_lock_line(details.lock)}")
423
+
424
+
425
+ def _age(seconds: float) -> str:
426
+ """A duration a person can read at a glance, coarse on purpose.
427
+
428
+ The question a lock's age answers is *has this been sitting here* — minutes or days — so a
429
+ second-exact figure would be precision nobody acts on, over a number large enough to have to be
430
+ counted digit by digit.
431
+ """
432
+ for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
433
+ if seconds >= size:
434
+ return f"{int(seconds // size)}{unit}"
435
+ return f"{int(seconds)}s"
436
+
437
+
438
+ def _lock_line(report: reporting.LockReport) -> str:
439
+ """Who is building this corpus, and what this machine can say about whether they still are.
440
+
441
+ The live case says *a process answers to that pid* rather than *the build is running*, because
442
+ that is all a pid probe establishes: after a crash the number is free, and a reused one looks
443
+ exactly like the indexer that recorded it.
444
+ """
445
+ who = "an unreadable pid" if report.pid is None else f"pid {report.pid}"
446
+ age = _UNSET if report.age_seconds is None else _age(report.age_seconds)
447
+ if report.live is True:
448
+ standing = "a process on this host still answers to that pid"
449
+ elif report.live is False:
450
+ standing = "no longer running; the next build takes the lock over"
451
+ else:
452
+ standing = (
453
+ "not something this machine can check; if you are sure it is gone, clear it with "
454
+ "`refresh --force-unlock`"
455
+ )
456
+ return f"held {age} by {who} on {report.host or _UNSET} — {standing}"
457
+
458
+
459
+ def _print_orphans(orphans: Sequence[reporting.Orphan]) -> None:
460
+ if not orphans:
461
+ return
462
+ print(f"\n{len(orphans)} orphaned database(s) — no knowledge base refers to these:")
463
+ for orphan in orphans:
464
+ name = orphan.breadcrumb_name or "<unreadable>"
465
+ print(f" {orphan.path} was {name!r} {orphan.size_bytes} bytes")
466
+ print("Nothing will open or delete them. Remove them by hand when you are sure.")
@@ -0,0 +1,133 @@
1
+ """Which store a knowledge-base command acts on, how it opens it, and how a failure reads.
2
+
3
+ Shared by the management command and by the indexer, because both are commands over one store and
4
+ a second copy of this would let them disagree about which project they were pointed at — which is
5
+ a silent, destructive kind of disagreement: two stores, two registries, and a knowledge base that
6
+ exists to one command and not to the other.
7
+
8
+ **These commands open `memory.db` directly** rather than going through the service. Neither
9
+ is on a latency path, both run when no harness process need exist, and their writes are small
10
+ transactions — which is what WAL and `busy_timeout` are for. The service reads the same registry,
11
+ and writes it too on the management verbs, so this is concurrent access between two writers rather
12
+ than a table with a single owner. Requiring a service would make managing and building corpora
13
+ depend on a harness being live.
14
+ """
15
+
16
+ import asyncio
17
+ import os
18
+ from collections.abc import AsyncIterator, Callable, Coroutine
19
+ from contextlib import asynccontextmanager
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ import aiosqlite
24
+
25
+ from zikaron.core.config.resolution import (
26
+ EffectiveConfig,
27
+ default_system_config_path,
28
+ project_config_path,
29
+ resolve,
30
+ )
31
+ from zikaron.core.errors import ZikaronError
32
+ from zikaron.core.knowledge.errors import KnowledgeError, RegistryUnavailableError
33
+ from zikaron.core.store.store import Store
34
+ from zikaron.harness import detect
35
+ from zikaron.service import paths as service_paths
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class OpenStore:
40
+ """One project's store, open and ready for a knowledge-base verb.
41
+
42
+ Carries the directory as well as the connection because the two are used for different things
43
+ — the registry lives in the connection, and each knowledge base's own file lives under the
44
+ directory — and a verb given one without the other could address a knowledge base belonging to
45
+ a different store.
46
+
47
+ `project` is the directory the store was derived *from*, kept because a command that starts a
48
+ build has to tell the child which project to act on, and the child takes a project rather than
49
+ a store. Passing the resolved value is what stops the two processes resolving differently.
50
+ """
51
+
52
+ project: Path
53
+ directory: Path
54
+ connection: aiosqlite.Connection
55
+ config: EffectiveConfig
56
+
57
+
58
+ def project_scope(project: Path | None) -> Path:
59
+ """Which project a command acts on.
60
+
61
+ Resolved through the same harness seam both thin clients use, so a command and the agent's own
62
+ tools address one store rather than two. An explicit project wins over the harness, since
63
+ naming one is the caller saying they mean a different project from the one they are sitting in.
64
+ """
65
+ return project if project is not None else detect.current_spec().store_scope_dir(Path.cwd())
66
+
67
+
68
+ def configuration(store_dir: Path) -> EffectiveConfig:
69
+ """The effective configuration for this store: the system layer, then the project's override."""
70
+ return resolve(
71
+ default_system_config_path(os.environ.get("XDG_CONFIG_HOME"), Path.home()),
72
+ project_config_path(store_dir),
73
+ )
74
+
75
+
76
+ @asynccontextmanager
77
+ async def open_store(project: Path | None) -> AsyncIterator[OpenStore]:
78
+ """Open this project's store, and close it however the command ends.
79
+
80
+ Opened through the store's own open path rather than by connecting to the file, so a command
81
+ inherits every check that path runs — permissions, the symlink refusal, `meta` validation — and
82
+ refuses a store this build cannot read instead of writing into it.
83
+
84
+ **A store that will not open is reported as the registry being unavailable**, because that is
85
+ what it means here: the list of knowledge bases lives in `memory.db`, so none of them is
86
+ discoverable while it cannot be read, however intact each corpus's own database is. Saying so
87
+ is the point — the failure this rules out is a command that answers *no knowledge bases*, which
88
+ is a real answer for a project that has none and indistinguishable from this one.
89
+
90
+ Raises:
91
+ RegistryUnavailableError: the store's own database could not be opened or read.
92
+ """
93
+ scope = project_scope(project)
94
+ directory = service_paths.store_dir(scope)
95
+ config = configuration(directory)
96
+ try:
97
+ store = await Store.open(directory, config)
98
+ except (aiosqlite.Error, OSError) as error:
99
+ raise RegistryUnavailableError(
100
+ f"the store at {directory} could not be read, so no knowledge base in this project "
101
+ f"can be found: {error}"
102
+ ) from error
103
+ async with store:
104
+ yield OpenStore(
105
+ project=scope, directory=directory, connection=store.connection, config=config
106
+ )
107
+
108
+
109
+ def execute(
110
+ work: Callable[[], Coroutine[object, object, int]], *, printer: Callable[[str], None]
111
+ ) -> int:
112
+ """Run one command's work, turning every predictable failure into a status and a message.
113
+
114
+ Returns a status rather than exiting, so a test can drive a whole command in-process and read
115
+ both what it returned and what it printed.
116
+
117
+ Args:
118
+ work: builds the coroutine to run. A factory rather than a coroutine, so nothing is
119
+ created until there is an event loop to run it on.
120
+ printer: where a failure is reported. Commands write theirs to standard error.
121
+ """
122
+ try:
123
+ return asyncio.run(work())
124
+ except KnowledgeError as error:
125
+ printer(f"refused: {error}")
126
+ except ZikaronError as error:
127
+ printer(f"refused: {error.message} ({error.detail()})")
128
+ except (aiosqlite.Error, OSError) as error:
129
+ # Every predictable refusal is raised above. This is the remainder — a full disk, a
130
+ # revoked permission, a database that will not open — reported as a failed command rather
131
+ # than as a traceback, which tells whoever ran it nothing they can act on.
132
+ printer(f"failed: {error}")
133
+ return 1
@@ -0,0 +1,6 @@
1
+ """The MCP server: translate a tool call into an RPC, return the answer.
2
+
3
+ Deliberately not stdlib-only, unlike `zikaron.hook` — see `design/coding-standards.md` §6 for the
4
+ cost-model reasoning (one `fastmcp` import per agent spawn, not per user message) and
5
+ `design/architecture.md` §Components for the measured import cost.
6
+ """