aether-context 0.3.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.
aether_context/cli.py ADDED
@@ -0,0 +1,1191 @@
1
+ # aether-context (Unlimited Context)
2
+ # Copyright (c) 2026 Aether AI
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """``aether-context`` console script — init / pool-resize / doctor / bench.
5
+
6
+ Thin by design: every command is a few lines that call into the library. The CLI never
7
+ contains engine logic; it wires argparse to :mod:`aether_context.config`, the Ollama probe in
8
+ :mod:`aether_context.local_llm`, and the bench script.
9
+
10
+ Commands
11
+ --------
12
+ ``aether-context setup [--pool N] [--model M] [--dir D] [--yes]``
13
+ The guided first run: size the pool, check the local model, and verify the engine end to
14
+ end in a throwaway directory. Same non-tty discipline as ``init`` — with ``--pool``/``--yes``
15
+ it is a single non-interactive command, so it is safe in a Dockerfile or a CI step.
16
+
17
+ ``aether-context init [--pool N] [--dir D]``
18
+ Initialize / re-initialize the pool config. **Non-tty safe** (build plan §12 CRITICAL):
19
+ the interactive slider only runs when stdin is a real tty; otherwise the size comes from
20
+ ``--pool N`` (>=5), then ``$AETHER_POOL_GB``, then the 5 GB default — it never blocks.
21
+
22
+ ``aether-context --pool N [--dir D]``
23
+ Resize the pool (non-destructive re-index): rewrite ``pool_gb`` in the persisted config
24
+ without touching the on-disk payloads. Rejects ``N < 5`` with the floor reason.
25
+
26
+ ``aether-context doctor [--model M] [--dir D]``
27
+ Runs **fully offline**. Checks the three things that ever go wrong (docs/local-models.md):
28
+ Ollama reachable? model pulled? free RAM vs the configured index? — and prints the *exact*
29
+ fix command for each. Never raises on a down daemon; reports it as a fixable condition.
30
+
31
+ ``aether-context bench [--quick] [--model M] [--json]``
32
+ Delegates to ``bench/drift_vs_window.py`` (engine ON vs OFF). Hermetic by default.
33
+
34
+ No ``print`` discipline note: this is the *one* place user-facing text is intentional, so the
35
+ CLI uses ``print`` for its report. The library proper (everything under ``aether_context`` that
36
+ is imported by ``Session``) stays silent and uses the logging seam.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import importlib.util
42
+ import json
43
+ import os
44
+ import shutil
45
+ import sys
46
+ import urllib.error
47
+ import urllib.request
48
+ from dataclasses import dataclass
49
+ from pathlib import Path
50
+ from types import ModuleType
51
+ from typing import Any, Sequence
52
+
53
+ from aether_context import __version__
54
+ from aether_context.config import (
55
+ BYTES_PER_GB,
56
+ POOL_GB_FLOOR,
57
+ PoolConfig,
58
+ free_disk_bytes,
59
+ reach_tokens,
60
+ )
61
+ from aether_context.errors import AetherContextError, PoolBudgetError
62
+ from aether_context.session import Session
63
+ from aether_context.ui import Console
64
+
65
+ #: Environment variable read for the pool size when no ``--pool`` flag is given (non-tty).
66
+ _ENV_POOL_GB = "AETHER_POOL_GB"
67
+ #: Environment variable for the Ollama host the doctor probes (overrides the default).
68
+ _ENV_OLLAMA_HOST = "OLLAMA_HOST"
69
+ #: Default Ollama host (mirrors local_llm.DEFAULT_OLLAMA_HOST without importing the adapter).
70
+ _DEFAULT_OLLAMA_HOST = "http://localhost:11434"
71
+ #: In-RAM ANN index size per GB of pool (MB). From the README table: 5 GB -> ~145 MB,
72
+ #: 10 GB -> ~291 MB, ... i.e. ~29 MB of resident index per GB of reach (the HNSW graph +
73
+ #: compact vector representation, *not* the full float32 vector store which lives on disk).
74
+ _INDEX_MB_PER_GB = 29
75
+ #: Short timeout (s) for the doctor's reachability probe — fail fast, stay offline-friendly.
76
+ _PROBE_TIMEOUT = 2.0
77
+ #: Suggested first model for `setup` — small enough to pull on a laptop, good enough to be useful.
78
+ _SUGGESTED_MODEL = "qwen2.5"
79
+
80
+
81
+ def _ui(stream: Any = None) -> Console:
82
+ """A :class:`Console` bound to the *current* ``sys.stdout`` (or ``stream``).
83
+
84
+ Built per call rather than once at import because the stream is swapped underneath us —
85
+ by pytest's ``capsys``, by a shell pipe, by a redirect — and the color/unicode decision has
86
+ to describe where the text is actually going.
87
+ """
88
+ return Console(stream if stream is not None else sys.stdout)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Argument parser.
93
+ # ---------------------------------------------------------------------------
94
+ def build_parser() -> argparse.ArgumentParser:
95
+ """Construct the argparse parser for the ``aether-context`` console script.
96
+
97
+ Exposes the ``init`` / ``doctor`` / ``bench`` subcommands plus a top-level ``--pool N``
98
+ resize shorthand (so ``aether-context --pool 10`` works with no subcommand). Returns the
99
+ parser; callers run ``parser.parse_args(argv)``.
100
+ """
101
+ parser = argparse.ArgumentParser(
102
+ prog="aether-context",
103
+ description=(
104
+ "Unlimited Context — virtual memory for an LLM's attention. "
105
+ "Init/resize the local pool, diagnose your setup, or run the bench."
106
+ ),
107
+ )
108
+ parser.add_argument("--version", action="version", version=f"aether-context {__version__}")
109
+ # top-level resize shorthand: `aether-context --pool 10`
110
+ parser.add_argument(
111
+ "--pool", type=int, default=None, metavar="N",
112
+ help="resize the pool to N GB (>=5), non-destructive; runs with no subcommand.",
113
+ )
114
+ parser.add_argument(
115
+ "--dir", type=str, default=None, metavar="D",
116
+ help="pool directory (default: ~/.aether-context).",
117
+ )
118
+
119
+ subparsers = parser.add_subparsers(dest="command")
120
+
121
+ p_setup = subparsers.add_parser(
122
+ "setup", help="guided first run: size the pool, check the model, verify the engine."
123
+ )
124
+ p_setup.add_argument("--pool", type=int, default=None, metavar="N", help="pool size in GB (>=5).")
125
+ p_setup.add_argument("--dir", type=str, default=None, metavar="D", help="pool directory.")
126
+ p_setup.add_argument(
127
+ "--model", type=str, default=None, metavar="M",
128
+ help=f"local model to check for (default: {_SUGGESTED_MODEL}).",
129
+ )
130
+ p_setup.add_argument(
131
+ "--yes", action="store_true",
132
+ help="take the defaults without prompting (for scripts, Dockerfiles, CI).",
133
+ )
134
+ p_setup.add_argument("--host", type=str, default=None, metavar="URL", help="Ollama host.")
135
+
136
+ p_init = subparsers.add_parser(
137
+ "init", help="initialize the pool (interactive slider on a tty; else --pool/env/5GB)."
138
+ )
139
+ p_init.add_argument("--pool", type=int, default=None, metavar="N", help="pool size in GB (>=5).")
140
+ p_init.add_argument("--dir", type=str, default=None, metavar="D", help="pool directory.")
141
+
142
+ p_doctor = subparsers.add_parser(
143
+ "doctor", help="diagnose Ollama reachability, model pull, and RAM-vs-index (offline)."
144
+ )
145
+ p_doctor.add_argument("--model", type=str, default=None, metavar="M", help="model to check.")
146
+ p_doctor.add_argument("--dir", type=str, default=None, metavar="D", help="pool directory.")
147
+ p_doctor.add_argument("--host", type=str, default=None, metavar="URL", help="Ollama host.")
148
+
149
+ p_bench = subparsers.add_parser(
150
+ "bench", help="run the engine ON-vs-OFF bench (hermetic mock by default)."
151
+ )
152
+ p_bench.add_argument("--model", type=str, default="mock", metavar="M", help="model spec.")
153
+ p_bench.add_argument("--quick", action="store_true", help="shorter build (CI smoke).")
154
+ p_bench.add_argument("--json", action="store_true", help="machine-readable report.")
155
+
156
+ p_run = subparsers.add_parser(
157
+ "run", help="run one task through the engine and print the result + a status line."
158
+ )
159
+ p_run.add_argument("task", type=str, help="the task/prompt to run.")
160
+ _add_session_flags(p_run)
161
+
162
+ p_chat = subparsers.add_parser(
163
+ "chat", help="interactive REPL with slash-commands (/clear /new /status /quit ...)."
164
+ )
165
+ _add_session_flags(p_chat)
166
+
167
+ p_status = subparsers.add_parser(
168
+ "status", help="print pool GB / slices / reach / hit rate / pool-mode / index."
169
+ )
170
+ _add_session_flags(p_status)
171
+
172
+ p_clear = subparsers.add_parser(
173
+ "clear", help="empty the pool (this dir). --all removes the whole pool dir."
174
+ )
175
+ p_clear.add_argument("--dir", type=str, default=None, metavar="D", help="pool directory.")
176
+ p_clear.add_argument(
177
+ "--all", action="store_true",
178
+ help="remove the entire pool dir (always confirms; non-tty needs --yes).",
179
+ )
180
+ p_clear.add_argument(
181
+ "--yes", action="store_true",
182
+ help="proceed without an interactive prompt (required for non-tty destructive clears).",
183
+ )
184
+
185
+ return parser
186
+
187
+
188
+ #: Allowed values for the session-config flags (mirror PoolConfig's validators).
189
+ _POOL_MODES = ("separate", "shared")
190
+ _INDEX_KINDS = ("flat", "hnsw", "tiered")
191
+ #: Default model for the run/chat surface — offline-safe so a clean clone just works.
192
+ _DEFAULT_MODEL = "mock"
193
+
194
+
195
+ def _add_session_flags(sub: argparse.ArgumentParser) -> None:
196
+ """Attach the shared session-config flags (--model/--pool/--pool-mode/--index/--dir)."""
197
+ sub.add_argument(
198
+ "--model", type=str, default=_DEFAULT_MODEL, metavar="M",
199
+ help="model spec (default: mock — runs fully offline).",
200
+ )
201
+ sub.add_argument(
202
+ "--pool", type=int, default=None, metavar="N", help="pool size in GB (>=5).",
203
+ )
204
+ sub.add_argument(
205
+ "--pool-mode", type=str, default="separate", choices=_POOL_MODES,
206
+ metavar="{separate,shared}", help="pool sharing mode (default: separate).",
207
+ )
208
+ sub.add_argument(
209
+ "--index", type=str, default="flat", choices=_INDEX_KINDS,
210
+ metavar="{flat,hnsw,tiered}",
211
+ help="ANN index kind (default: flat). 'tiered' is reserved and runs flat for now.",
212
+ )
213
+ sub.add_argument(
214
+ "--no-mpo-chain", dest="mpo_chain", action="store_false", default=True,
215
+ help="disable the MPO context chain (retrieval falls back to plain cosine). On by default.",
216
+ )
217
+ sub.add_argument("--dir", type=str, default=None, metavar="D", help="pool directory.")
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Entry point.
222
+ # ---------------------------------------------------------------------------
223
+ def main(argv: Sequence[str] | None = None) -> int:
224
+ """Parse ``argv`` and dispatch to the matching command. Returns a process exit code.
225
+
226
+ A bare invocation (no command, no ``--pool``) prints help and returns 0. Each command
227
+ returns 0 on success and a non-zero code on a reported failure (so the script is usable in
228
+ CI). Typed library errors are caught and rendered with their ``.hint``; nothing escapes.
229
+ """
230
+ parser = build_parser()
231
+ args = parser.parse_args(argv)
232
+
233
+ try:
234
+ if args.command == "setup":
235
+ return _cmd_setup(args)
236
+ if args.command == "init":
237
+ return _cmd_init(args)
238
+ if args.command == "doctor":
239
+ return _cmd_doctor(args)
240
+ if args.command == "bench":
241
+ return _cmd_bench(args)
242
+ if args.command == "run":
243
+ return _cmd_run(args)
244
+ if args.command == "chat":
245
+ return _cmd_chat(args)
246
+ if args.command == "status":
247
+ return _cmd_status(args)
248
+ if args.command == "clear":
249
+ return _cmd_clear(args)
250
+ # no subcommand: a top-level --pool is a resize; otherwise show help.
251
+ if args.pool is not None:
252
+ return _cmd_resize(args)
253
+ ui = _ui()
254
+ ui.banner("Unlimited Context", f"aether-context {__version__}")
255
+ ui.line()
256
+ ui.line(" New here? One command sets everything up:")
257
+ ui.command("aether-context setup")
258
+ ui.line()
259
+ parser.print_help()
260
+ return 0
261
+ except AetherContextError as exc:
262
+ # typed, hinted failure: render it cleanly (never a traceback to the user).
263
+ err = _ui(sys.stderr)
264
+ err.line(f"{err.glyph('fail')} {err.style('error:', 'bold', 'red')} {exc.message}")
265
+ err.line(f" {err.style('fix:', 'dim')} {err.style(exc.hint, 'bold')}")
266
+ return 1
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # setup — the guided first run.
271
+ # ---------------------------------------------------------------------------
272
+ def _cmd_setup(args: argparse.Namespace) -> int:
273
+ """Walk a new install through pool sizing, the model check, and an end-to-end verify.
274
+
275
+ Three steps, in the order things actually go wrong:
276
+
277
+ 1. **Pool** — the one number the user has to choose. Same resolution order as ``init``
278
+ (flag → tty slider → ``$AETHER_POOL_GB`` → floor), so ``--pool``/``--yes`` makes this
279
+ whole command non-interactive and it never blocks on a pipe.
280
+ 2. **Model** — reachability of the local daemon, reported but *not* fatal: the engine runs
281
+ offline against the mock model, and saying otherwise would be a lie that sends people
282
+ hunting for a daemon they do not need yet.
283
+ 3. **Verify** — a real encode/retrieve round trip. Run against a throwaway directory, not
284
+ the pool just configured, so a successful setup leaves a genuinely empty pool behind.
285
+
286
+ Returns 0 when the pool is configured and the engine verified; 1 if either failed. A
287
+ missing local model is a warning and does not change the exit code.
288
+ """
289
+ ui = _ui()
290
+ pool_dir = _resolve_dir(args.dir)
291
+
292
+ ui.banner("Unlimited Context", f"aether-context {__version__}")
293
+ ui.line()
294
+ ui.note("Virtual memory for an LLM's attention — local-first, numpy-only core.")
295
+ ui.note(f"Pool directory: {pool_dir}")
296
+
297
+ # --- 1. pool ---------------------------------------------------------------------
298
+ ui.step(1, 3, "Pool size")
299
+ ui.note("The pool is local DISK reserved for context reach. Resize any time.")
300
+ pool_gb = _resolve_pool_gb(getattr(args, "pool", None), pool_dir, assume_yes=args.yes)
301
+ _check_disk_for_pool(pool_gb, pool_dir)
302
+ cfg = _write_config(pool_dir, pool_gb)
303
+ reach = reach_tokens(cfg.pool_gb)
304
+ ui.check("ok", f"pool configured at {cfg.dir}")
305
+ ui.field("size:", f"{cfg.pool_gb} GB")
306
+ ui.field("reach:", f"~{reach / 1e9:.2f}B tokens")
307
+ ui.field("index:", f"{cfg.index} dim {cfg.dim} slice {cfg.slice_tokens} tok")
308
+ free = free_disk_bytes(pool_dir)
309
+ if free is not None:
310
+ # Just the number: a pool is typically a low single-digit percentage of a modern disk,
311
+ # so a meter here would sit at zero bars and read as broken rather than as reassuring.
312
+ ui.field("disk:", f"{free / BYTES_PER_GB:.1f} GB free")
313
+
314
+ # --- 2. model --------------------------------------------------------------------
315
+ ui.step(2, 3, "Local model")
316
+ host = _resolve_host(args.host)
317
+ model = args.model or _SUGGESTED_MODEL
318
+ reachable = _probe_ollama(host)
319
+ if not reachable:
320
+ ui.check(
321
+ "warn", f"no Ollama daemon at {host}",
322
+ "install from https://ollama.com, then `ollama serve`",
323
+ )
324
+ ui.note("Not a blocker — everything below runs offline against the mock model.")
325
+ elif _model_is_pulled(host, model):
326
+ ui.check("ok", f"model '{model}' is pulled and ready at {host}")
327
+ else:
328
+ ui.check("warn", f"model '{model}' is not pulled", f"ollama pull {model}")
329
+
330
+ # --- 3. verify -------------------------------------------------------------------
331
+ ui.step(3, 3, "Verify the engine")
332
+ ok = _verify_engine(ui)
333
+
334
+ # --- next steps ------------------------------------------------------------------
335
+ ui.heading("You're ready")
336
+ if reachable:
337
+ ui.command(f"aether-context chat --model ollama/{model}", "talk to your local model")
338
+ else:
339
+ ui.command("aether-context chat --model mock", "works offline, right now")
340
+ ui.command("aether-context run \"summarize this repo\"", "one-shot task")
341
+ ui.command("aether-context status", "pool, reach, hit rate")
342
+ ui.command("aether-context doctor", "diagnose anything that breaks")
343
+ ui.line()
344
+ return 0 if ok else 1
345
+
346
+
347
+ def _verify_engine(ui: Console) -> bool:
348
+ """Prove the install works: one real run through a Session in a throwaway pool.
349
+
350
+ Deliberately uses the mock model and a temporary directory — this has to pass with no
351
+ daemon, no network and no model pulled, and it must not leave slices in the pool the user
352
+ just sized. A failure here is the one thing in ``setup`` that is genuinely broken, so it
353
+ reports the exception message rather than a cheerful guess.
354
+ """
355
+ import tempfile
356
+
357
+ with tempfile.TemporaryDirectory(prefix="aether-verify-") as tmp:
358
+ session = None
359
+ try:
360
+ session = Session(model=_DEFAULT_MODEL, pool_gb=POOL_GB_FLOOR, pool_dir=Path(tmp))
361
+ session.run("verify the aether-context install")
362
+ used = int(session.status_dict()["slices_used"])
363
+ except Exception as exc: # noqa: BLE001 - report any breakage, never traceback at the user
364
+ ui.check("fail", f"engine check failed: {exc}", "aether-context doctor")
365
+ return False
366
+ finally:
367
+ if session is not None:
368
+ session.close()
369
+ ui.check("ok", f"engine round trip passed ({used} slice(s) encoded and retrieved)")
370
+ return True
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # init.
375
+ # ---------------------------------------------------------------------------
376
+ def _cmd_init(args: argparse.Namespace) -> int:
377
+ """Initialize the pool config. Non-tty safe: prompt only on a real tty.
378
+
379
+ Resolution order for the size: explicit ``--pool`` → interactive slider (tty only) →
380
+ ``$AETHER_POOL_GB`` → the 5 GB default. A size below the floor is rejected with the reason.
381
+ """
382
+ pool_dir = _resolve_dir(args.dir)
383
+ pool_gb = _resolve_pool_gb(getattr(args, "pool", None), pool_dir)
384
+ _check_disk_for_pool(pool_gb, pool_dir) # reject a pool that won't fit on disk
385
+ cfg = _write_config(pool_dir, pool_gb) # raises PoolBudgetError if < floor
386
+ reach = reach_tokens(cfg.pool_gb)
387
+ ui = _ui()
388
+ ui.check("ok", f"initialized pool at {cfg.dir}")
389
+ ui.field("pool size:", f"{cfg.pool_gb} GB (reach ~= {reach / 1e9:.2f}B tokens)")
390
+ ui.field("index:", f"{cfg.index} dim: {cfg.dim} slice: {cfg.slice_tokens} tok")
391
+ free = free_disk_bytes(pool_dir)
392
+ if free is not None:
393
+ ui.field("disk:", f"{free / BYTES_PER_GB:.1f} GB free at {pool_dir}")
394
+ ui.line()
395
+ ui.note("Next: `aether-context setup` to check your model and verify the engine.")
396
+ return 0
397
+
398
+
399
+ def _resolve_pool_gb(flag: int | None, pool_dir: Path, *, assume_yes: bool = False) -> int:
400
+ """Resolve the pool size without ever blocking on a non-tty stdin.
401
+
402
+ ``flag`` (``--pool``) wins. Else, only if stdin is an interactive tty — and the caller did
403
+ not pass ``--yes`` — do we run the slider (which shows free disk and rejects a size that
404
+ won't fit). Else ``$AETHER_POOL_GB`` if set and numeric. Else the 5 GB default.
405
+ """
406
+ if flag is not None:
407
+ return int(flag)
408
+ if not assume_yes and sys.stdin is not None and sys.stdin.isatty():
409
+ return _prompt_pool_gb(pool_dir)
410
+ env = os.environ.get(_ENV_POOL_GB)
411
+ if env is not None and env.strip().isdigit():
412
+ return int(env.strip())
413
+ return POOL_GB_FLOOR
414
+
415
+
416
+ def _prompt_pool_gb(pool_dir: Path) -> int:
417
+ """Interactive pool-size selector (the README slider). Only called on a real tty.
418
+
419
+ The pool is **local disk** the engine reserves for context reach, so the slider shows how
420
+ much disk is free, marks sizes that won't fit, and re-prompts (not just below the floor,
421
+ but also when a pick exceeds free disk). Empty input takes the 5 GB default; EOF falls back
422
+ to the default.
423
+ """
424
+ ui = _ui()
425
+ free = free_disk_bytes(pool_dir)
426
+ free_gb = (free / BYTES_PER_GB) if free is not None else None
427
+ ui.line()
428
+ ui.line(" Choose a pool size — the local DISK reserved for context reach:")
429
+ if free_gb is not None:
430
+ ui.note(f"{free_gb:.1f} GB free at {pool_dir}")
431
+ for gb in (5, 10, 15, 20):
432
+ fits = free_gb is None or gb <= free_gb
433
+ reach = f"reach ~= {reach_tokens(gb) / 1e9:.2f}B tokens"
434
+ label = f" {ui.glyph('ok' if fits else 'fail')} {gb:>2} GB {ui.style(reach, 'dim')}"
435
+ ui.line(label if fits else f"{label} {ui.style(chr(40) + 'not enough free disk)', 'dim')}")
436
+ for _attempt in range(3):
437
+ try:
438
+ raw = input(f" pool GB [{POOL_GB_FLOOR}]: ").strip()
439
+ except EOFError:
440
+ return POOL_GB_FLOOR
441
+ if not raw:
442
+ return POOL_GB_FLOOR
443
+ if not raw.isdigit():
444
+ ui.note("enter a whole number of GB (e.g. 5, 10, 20).")
445
+ continue
446
+ value = int(raw)
447
+ if value < POOL_GB_FLOOR:
448
+ ui.note(f"{value} GB is below the {POOL_GB_FLOOR} GB floor; pick at least {POOL_GB_FLOOR}.")
449
+ continue
450
+ if free_gb is not None and value > free_gb:
451
+ ui.note(f"{value} GB won't fit — only {free_gb:.1f} GB free at {pool_dir}. Pick smaller.")
452
+ continue
453
+ return value
454
+ return POOL_GB_FLOOR
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # resize (top-level --pool).
459
+ # ---------------------------------------------------------------------------
460
+ def _cmd_resize(args: argparse.Namespace) -> int:
461
+ """Resize the pool to ``--pool N`` GB (non-destructive). Rejects ``N < floor``.
462
+
463
+ "Non-destructive" = we only rewrite ``pool_gb`` in the persisted config; the on-disk
464
+ vectors/sidecar payloads are left in place (a later pool open re-indexes around the new
465
+ size). We load any existing config first so other settings (index/dim/...) are preserved.
466
+ """
467
+ pool_dir = _resolve_dir(args.dir)
468
+ existing = PoolConfig.load(pool_dir) # preserves index/dim/slice; defaults if absent
469
+ _check_disk_for_pool(int(args.pool), pool_dir) # reject a resize that won't fit on disk
470
+ cfg = _write_config(
471
+ pool_dir, int(args.pool),
472
+ index=existing.index, dim=existing.dim, slice_tokens=existing.slice_tokens,
473
+ mode=existing.mode,
474
+ )
475
+ print(f"resized pool at {cfg.dir} to {cfg.pool_gb} GB "
476
+ f"(reach ~= {reach_tokens(cfg.pool_gb) / 1e9:.2f}B tokens)")
477
+ print(" re-index is non-destructive: your encoded slices are preserved.")
478
+ return 0
479
+
480
+
481
+ def _write_config(pool_dir: Path, pool_gb: int, **fields: object) -> PoolConfig:
482
+ """Build + persist a PoolConfig (validates the floor inside ``__post_init__``)."""
483
+ cfg = PoolConfig(pool_gb=pool_gb, dir=pool_dir, **fields) # type: ignore[arg-type]
484
+ cfg.save()
485
+ return cfg
486
+
487
+
488
+ def _check_disk_for_pool(pool_gb: int, pool_dir: Path) -> None:
489
+ """Reject a pool that won't fit on local disk (no-op if free space can't be probed).
490
+
491
+ The pool reserves ``pool_gb`` of disk for encoded context. If less than that is free on
492
+ the target filesystem, refuse loudly with a typed, hinted error rather than letting the
493
+ pool fill up and fail mid-run.
494
+ """
495
+ free = free_disk_bytes(pool_dir)
496
+ if free is None:
497
+ return # cannot probe -> do not block
498
+ if free >= pool_gb * BYTES_PER_GB:
499
+ return
500
+ free_gb = free / BYTES_PER_GB
501
+ fits = int(free // BYTES_PER_GB)
502
+ if fits >= POOL_GB_FLOOR:
503
+ hint = f"free up disk, or pick a smaller pool that fits: aether-context --pool {fits}"
504
+ else:
505
+ hint = (
506
+ f"free up disk space — even the {POOL_GB_FLOOR} GB floor needs {POOL_GB_FLOOR} GB "
507
+ f"free (only {free_gb:.1f} GB available at {pool_dir})"
508
+ )
509
+ raise PoolBudgetError(
510
+ f"not enough disk: a {pool_gb} GB pool needs {pool_gb} GB free at {pool_dir}, "
511
+ f"but only {free_gb:.1f} GB is available",
512
+ hint=hint,
513
+ )
514
+
515
+
516
+ def _resolve_dir(flag: str | None) -> Path:
517
+ """Resolve the pool directory: ``--dir`` if given, else ``~/.aether-context``."""
518
+ if flag:
519
+ return Path(flag)
520
+ return PoolConfig().dir
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # run — one task through the engine, then a one-line status.
525
+ # ---------------------------------------------------------------------------
526
+ def _build_session(args: argparse.Namespace) -> Session:
527
+ """Construct a :class:`Session` from the shared session-config flags (offline-safe).
528
+
529
+ ``--pool`` falls back to any persisted config's reach (so ``run`` after ``init`` honors
530
+ the chosen size) then the 5 GB floor. ``fallback_to_mock=True`` keeps a clean clone
531
+ working with no backend installed.
532
+ """
533
+ pool_dir = _resolve_dir(args.dir)
534
+ pool_gb = args.pool if args.pool is not None else PoolConfig.load(pool_dir).pool_gb
535
+ return Session(
536
+ model=args.model,
537
+ pool_gb=int(pool_gb),
538
+ pool_mode=args.pool_mode,
539
+ pool_index=args.index,
540
+ pool_dir=pool_dir,
541
+ mpo_chain=getattr(args, "mpo_chain", True),
542
+ fallback_to_mock=True,
543
+ )
544
+
545
+
546
+ def _cmd_run(args: argparse.Namespace) -> int:
547
+ """Run ``args.task`` through a fresh session, print the text then a one-line status.
548
+
549
+ The session is closed in a ``finally`` so the pool is always flushed (the encoded slices
550
+ survive for a later ``status`` / ``chat`` over the same dir). Returns 0 on success.
551
+ """
552
+ session = _build_session(args)
553
+ try:
554
+ result = session.run(args.task)
555
+ print(result.text)
556
+ print(_status_line(session.status_dict()))
557
+ return 0
558
+ finally:
559
+ session.close()
560
+
561
+
562
+ def _status_line(s: dict[str, Any]) -> str:
563
+ """A compact one-line status summary for the ``run`` tail."""
564
+ return (
565
+ f"[pool {s['pool_gb']} GB | slices {s['slices_used']}/{s['capacity']} | "
566
+ f"reach {int(s['reach_tokens']) / 1e9:.2f}B tok | hit {float(s['hit_rate']):.0%} | "
567
+ f"mode {s['pool_mode']} | index {s['index']}]"
568
+ )
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # status — open the pool read-only and print the honest status fields.
573
+ # ---------------------------------------------------------------------------
574
+ def _cmd_status(args: argparse.Namespace) -> int:
575
+ """Print the status fields for the pool at ``--dir`` (no live session, so hit rate N/A).
576
+
577
+ Loads the persisted :class:`PoolConfig`, opens the pool read-only to count its slices,
578
+ and reports reach / capacity / resident-RAM estimate. The hit rate is honestly ``N/A``
579
+ here: there is no running pager to measure, so we do not fabricate one.
580
+ """
581
+ pool_dir = _resolve_dir(args.dir)
582
+ cfg = PoolConfig.load(pool_dir)
583
+ slices, capacity = _pool_counts(cfg)
584
+ reach = reach_tokens(cfg.pool_gb)
585
+ resident_mb = cfg.pool_gb * _INDEX_MB_PER_GB
586
+ ui = _ui()
587
+ ui.heading("aether-context status")
588
+ fill = (slices / capacity) if capacity else 0.0
589
+ ui.field("pool:", f"{cfg.pool_gb} GB (reach ~= {reach / 1e9:.2f}B tokens)")
590
+ ui.field("slices:", f"{slices} / {capacity} {ui.bar(fill, slots=16)}")
591
+ ui.field("reach:", f"{reach:,} tokens")
592
+ ui.field("hit rate:", "N/A (no live session)")
593
+ ui.field("resident:", f"~{resident_mb} MB RAM (estimate)")
594
+ ui.field("pool-mode:", cfg.mode)
595
+ ui.field("index:", cfg.index)
596
+ ui.line()
597
+ return 0
598
+
599
+
600
+ def _pool_counts(cfg: PoolConfig) -> tuple[int, int]:
601
+ """``(slices_used, capacity)`` for the pool at ``cfg.dir`` (0/0 if none on disk yet).
602
+
603
+ Opens the pool read-only via :class:`Session`'s storage layer; on a fresh/absent pool
604
+ the count is 0. Closed immediately so no mmap handle lingers (Windows-safe).
605
+ """
606
+ from aether_context.context_pool import ContextPool, slice_cost_bytes
607
+
608
+ pool = ContextPool(cfg)
609
+ try:
610
+ used = len(pool)
611
+ capacity = pool.ceiling_bytes // slice_cost_bytes(cfg.dim)
612
+ return used, int(capacity)
613
+ finally:
614
+ pool.close()
615
+
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # clear — empty the pool (this dir) / remove the whole dir, with confirmation.
619
+ # ---------------------------------------------------------------------------
620
+ def _cmd_clear(args: argparse.Namespace) -> int:
621
+ """Clear the pool at ``--dir`` (all sessions) or remove the whole dir with ``--all``.
622
+
623
+ Confirmation policy (honest + safe): ``--all`` ALWAYS confirms; a non-default ``clear``
624
+ confirms when the pool is ``shared`` or a named/persistent dir. On a tty we ask; off a
625
+ tty we require ``--yes`` and refuse with a message otherwise (never block on input()).
626
+ """
627
+ pool_dir = _resolve_dir(args.dir)
628
+ if args.all:
629
+ return _clear_all(pool_dir, assume_yes=args.yes)
630
+ return _clear_slices(pool_dir, assume_yes=args.yes)
631
+
632
+
633
+ def _clear_all(pool_dir: Path, *, assume_yes: bool) -> int:
634
+ """Remove the entire pool dir (always confirmed)."""
635
+ if not _confirm(f"remove the ENTIRE pool dir {pool_dir}?", assume_yes=assume_yes):
636
+ print("clear --all aborted (no confirmation).")
637
+ return 1
638
+ if pool_dir.exists():
639
+ shutil.rmtree(pool_dir, ignore_errors=True)
640
+ print(f"removed pool dir {pool_dir}")
641
+ return 0
642
+
643
+
644
+ def _clear_slices(pool_dir: Path, *, assume_yes: bool) -> int:
645
+ """Empty the pool's slices (all sessions) at ``pool_dir``, confirming if persistent/shared."""
646
+ cfg = PoolConfig.load(pool_dir)
647
+ needs_confirm = cfg.mode == "shared" or _is_persistent_dir(pool_dir)
648
+ if needs_confirm and not _confirm(
649
+ f"clear all slices in the {cfg.mode} pool at {pool_dir}?", assume_yes=assume_yes
650
+ ):
651
+ print("clear aborted (no confirmation).")
652
+ return 1
653
+ removed = _clear_pool_slices(cfg)
654
+ print(f"cleared {removed} slice(s) from the pool at {pool_dir}")
655
+ return 0
656
+
657
+
658
+ def _clear_pool_slices(cfg: PoolConfig) -> int:
659
+ """Drop every slice in the pool at ``cfg.dir`` (global clear) and flush. Returns the count.
660
+
661
+ ``ContextPool.close`` is idempotent, so the ``finally`` flush is safe even though the
662
+ happy path also closes (it must, so the emptied sidecar is on disk before we return and
663
+ a following ``status`` reads zero slices).
664
+ """
665
+ from aether_context.context_pool import ContextPool
666
+
667
+ pool = ContextPool(cfg)
668
+ try:
669
+ return pool.clear_session(None) # None -> clear all sessions' slices
670
+ finally:
671
+ pool.close() # flush the now-empty sidecar so a later status sees 0 (idempotent)
672
+
673
+
674
+ def _is_persistent_dir(pool_dir: Path) -> bool:
675
+ """A dir is 'persistent' (worth confirming before clearing) iff it is not the default.
676
+
677
+ The default ``~/.aether-context`` is the throwaway/ephemeral home; an explicit ``--dir``
678
+ is treated as a named/persistent pool, so clearing it asks first on a tty.
679
+ """
680
+ try:
681
+ return pool_dir.resolve() != PoolConfig().dir.resolve()
682
+ except OSError:
683
+ return True
684
+
685
+
686
+ def _confirm(question: str, *, assume_yes: bool) -> bool:
687
+ """Confirm a destructive action. ``--yes`` / a tty 'y' proceeds; non-tty without --yes refuses.
688
+
689
+ Never blocks under a non-tty (CI / pipes): if stdin is not interactive and ``--yes`` was
690
+ not passed we return False with an explanatory message rather than calling ``input()``.
691
+ """
692
+ if assume_yes:
693
+ return True
694
+ if sys.stdin is None or not sys.stdin.isatty():
695
+ print(f"refusing: {question} (non-interactive; pass --yes to proceed)", file=sys.stderr)
696
+ return False
697
+ try:
698
+ answer = input(f"{question} [y/N] ").strip().lower()
699
+ except EOFError:
700
+ return False
701
+ return answer in ("y", "yes")
702
+
703
+
704
+ # ---------------------------------------------------------------------------
705
+ # chat — interactive REPL with a pure, testable slash-command dispatcher.
706
+ # ---------------------------------------------------------------------------
707
+ @dataclass
708
+ class ReplState:
709
+ """Mutable state the slash dispatcher reads/advises on (the REPL owns the side effects).
710
+
711
+ ``dispatch_slash`` is **pure**: it never touches the session or prints — it parses one
712
+ slash line against this state and returns an ``(action, message)`` pair. The REPL loop is
713
+ the only place that actually mutates the session, prints, or exits.
714
+ """
715
+
716
+ model: str = _DEFAULT_MODEL
717
+ pool_gb: int = POOL_GB_FLOOR
718
+ pool_mode: str = "separate"
719
+ index: str = "flat"
720
+ extended: bool = False
721
+
722
+
723
+ #: The actions ``dispatch_slash`` can return (the REPL maps each to a real side effect).
724
+ SLASH_ACTIONS: tuple[str, ...] = (
725
+ "continue", "quit", "clear", "new", "status", "pool", "model",
726
+ "think", "export", "help", "unknown",
727
+ )
728
+
729
+ #: The help text shown for ``/help`` (also printed at chat start).
730
+ _CHAT_HELP = (
731
+ "slash-commands: /clear (alias /cls) /new /status /pool <GB> /model <name> "
732
+ "/think /export [file] /help /quit"
733
+ )
734
+
735
+
736
+ def dispatch_slash(state: ReplState, line: str) -> tuple[str, str]:
737
+ """Parse one slash ``line`` against ``state`` -> ``(action, message)``. PURE: no side effects.
738
+
739
+ Recognized: ``/clear`` (alias ``/cls``), ``/new``, ``/status``, ``/pool <GB>``,
740
+ ``/model <name>``, ``/think``, ``/export [file]``, ``/help``, ``/quit`` (aliases
741
+ ``/exit`` / ``/q``). Anything else yields ``("unknown", ...)``. ``message`` is the
742
+ argument payload for parameterized commands (e.g. the GB for ``/pool``, the path for
743
+ ``/export``) or a human-readable note; the REPL performs the actual effect.
744
+
745
+ Robust to a leading UTF-8 BOM that some shells prepend to piped/redirected input — both
746
+ the decoded form (````) and the raw 3-byte form (``\xef\xbb\xbf``) that appears when
747
+ Windows reads piped stdin under a non-UTF-8 console encoding — so a ``/command`` is still
748
+ recognized when the line is fed in non-interactively.
749
+ """
750
+ text = line.lstrip("\xef\xbb\xbf").strip()
751
+ if not text.startswith("/"):
752
+ return ("continue", text)
753
+ parts = text[1:].split(maxsplit=1)
754
+ cmd = parts[0].lower() if parts else ""
755
+ arg = parts[1].strip() if len(parts) > 1 else ""
756
+
757
+ if cmd in ("quit", "exit", "q"):
758
+ return ("quit", "")
759
+ if cmd in ("clear", "cls"):
760
+ return ("clear", "")
761
+ if cmd == "new":
762
+ return ("new", "")
763
+ if cmd == "status":
764
+ return ("status", "")
765
+ if cmd == "help":
766
+ return ("help", _CHAT_HELP)
767
+ if cmd == "think":
768
+ return ("think", "")
769
+ if cmd == "export":
770
+ return ("export", arg)
771
+ if cmd == "pool":
772
+ return ("pool", arg)
773
+ if cmd == "model":
774
+ return ("model", arg)
775
+ return ("unknown", f"unknown command: /{cmd} ({_CHAT_HELP})")
776
+
777
+
778
+ def _cmd_chat(args: argparse.Namespace) -> int:
779
+ """Interactive REPL. Non-tty: read a single line (or none) and exit cleanly.
780
+
781
+ Each input line is routed through :func:`dispatch_slash`; non-slash lines call
782
+ ``session.ask`` and print the reply. A best-effort ``readline`` binding inserts
783
+ ``/clear`` on Ctrl+L. In ``separate`` mode the ephemeral pool is dropped on exit.
784
+ """
785
+ session = _build_session(args)
786
+ state = ReplState(
787
+ model=args.model, pool_gb=session.pool_gb, pool_mode=args.pool_mode,
788
+ index=args.index, extended=session.extended,
789
+ )
790
+ _bind_readline_clear()
791
+ interactive = sys.stdin is not None and sys.stdin.isatty()
792
+ if interactive:
793
+ print(f"aether-context chat — {_CHAT_HELP}")
794
+ try:
795
+ return _chat_loop(args, session, state, interactive=interactive)
796
+ finally:
797
+ _drop_ephemeral(args, session)
798
+
799
+
800
+ def _chat_loop(
801
+ args: argparse.Namespace, session: Session, state: ReplState, *, interactive: bool
802
+ ) -> int:
803
+ """The read/dispatch/print loop. Returns 0 on a clean exit."""
804
+ while True:
805
+ try:
806
+ line = input("> " if interactive else "")
807
+ except (EOFError, KeyboardInterrupt):
808
+ return 0
809
+ action, message = dispatch_slash(state, line)
810
+ if action == "quit":
811
+ return 0
812
+ cont = _apply_chat_action(args, session, state, action, message)
813
+ if not cont:
814
+ return 0
815
+ if not interactive:
816
+ # Non-tty chat handles exactly one line then exits cleanly (never blocks).
817
+ return 0
818
+
819
+
820
+ def _apply_chat_action(
821
+ args: argparse.Namespace,
822
+ session: Session,
823
+ state: ReplState,
824
+ action: str,
825
+ message: str,
826
+ ) -> bool:
827
+ """Perform the side effect for one dispatched ``action``. Returns False to end the loop."""
828
+ if action == "continue":
829
+ if message:
830
+ print(session.ask(message))
831
+ return True
832
+ if action == "help":
833
+ print(message)
834
+ return True
835
+ if action == "status":
836
+ for ln in _status_lines(session.status_dict()):
837
+ print(ln)
838
+ return True
839
+ if action == "clear":
840
+ removed = session.clear(scope="session")
841
+ print(f"cleared {removed} slice(s); resident window reset.")
842
+ return True
843
+ if action == "new":
844
+ session.clear(scope="resident")
845
+ print("resident window cleared; reachable pool kept.")
846
+ return True
847
+ if action == "think":
848
+ on = session.toggle_extended()
849
+ state.extended = on
850
+ print(f"extended thinking: {'on' if on else 'off'}")
851
+ return True
852
+ if action == "export":
853
+ path = session.export(message or None)
854
+ print(f"transcript exported to {path}")
855
+ return True
856
+ if action == "pool":
857
+ print(_apply_pool_change(state, message))
858
+ return True
859
+ if action == "model":
860
+ if message:
861
+ state.model = message
862
+ print(f"model set to {state.model} (applies to the next `chat`/`run`).")
863
+ return True
864
+ # unknown
865
+ print(message)
866
+ return True
867
+
868
+
869
+ def _apply_pool_change(state: ReplState, message: str) -> str:
870
+ """Validate + record a ``/pool <GB>`` change on the REPL state (advisory; not live-resized)."""
871
+ if not message.isdigit():
872
+ return f"usage: /pool <GB> (got {message!r})"
873
+ gb = int(message)
874
+ if gb < POOL_GB_FLOOR:
875
+ return f"{gb} GB is below the {POOL_GB_FLOOR} GB floor; keeping {state.pool_gb} GB."
876
+ state.pool_gb = gb
877
+ return f"pool size set to {gb} GB (applies to the next `chat`/`run`)."
878
+
879
+
880
+ def _status_lines(s: dict[str, Any]) -> list[str]:
881
+ """Multi-line status block for the REPL ``/status`` (mirrors the shell ``status`` fields)."""
882
+ reach = int(s["reach_tokens"])
883
+ return [
884
+ f" pool: {s['pool_gb']} GB (reach ~= {reach / 1e9:.2f}B tokens)",
885
+ f" slices: {s['slices_used']} / {s['capacity']}",
886
+ f" reach: {reach:,} tokens",
887
+ f" hit rate: {float(s['hit_rate']):.0%}",
888
+ f" resident RAM ~= {s['resident_ram_mb']} MB (estimate)",
889
+ f" pool-mode: {s['pool_mode']}",
890
+ f" index: {s['index']}",
891
+ f" model: {s['model']} extended: {s['extended']}",
892
+ ]
893
+
894
+
895
+ def _bind_readline_clear() -> None:
896
+ """Best-effort: bind Ctrl+L to insert ``/clear`` via readline (no-op if unavailable)."""
897
+ try:
898
+ import readline # noqa: PLC0415 - optional, best-effort on this platform
899
+ except ImportError:
900
+ return
901
+ bind = getattr(readline, "parse_and_bind", None)
902
+ if bind is None:
903
+ return
904
+ try:
905
+ bind(r'"\C-l": "/clear\n"')
906
+ except (OSError, ValueError) as exc:
907
+ # readline present but the binding syntax was rejected on this build — non-fatal.
908
+ print(f" (note: could not bind Ctrl+L: {exc})", file=sys.stderr)
909
+
910
+
911
+ def _drop_ephemeral(args: argparse.Namespace, session: Session) -> None:
912
+ """On exit, close the session; in separate/ephemeral mode also drop its slices.
913
+
914
+ Honest cleanup: ``separate`` mode is ephemeral, so the slices this chat encoded are
915
+ dropped on exit (the next chat starts clean). ``shared`` / a persistent dir keeps them.
916
+ """
917
+ if args.pool_mode == "separate":
918
+ try:
919
+ session.clear(scope="session")
920
+ except AetherContextError as exc:
921
+ print(f" (note: ephemeral clear skipped: {exc})", file=sys.stderr)
922
+ session.close()
923
+
924
+
925
+ # ---------------------------------------------------------------------------
926
+ # doctor — runs fully offline, prints exact fixes.
927
+ # ---------------------------------------------------------------------------
928
+ def _cmd_doctor(args: argparse.Namespace) -> int:
929
+ """Diagnose the three common failure modes, printing the exact fix for each.
930
+
931
+ Runs fully offline: the Ollama reachability probe has a short timeout and any network
932
+ failure is rendered as a fixable condition (with ``ollama serve`` / ``ollama pull``), never
933
+ raised. Returns 0 if everything checks out, 1 if any check found a problem.
934
+ """
935
+ host = _resolve_host(args.host)
936
+ model = args.model
937
+ pool_dir = _resolve_dir(args.dir)
938
+ ui = _ui()
939
+ ui.heading("aether-context doctor")
940
+ ui.note(f"ollama host: {host}")
941
+
942
+ ok = True
943
+ reachable = _probe_ollama(host)
944
+ ok = _report_ollama(ui, reachable, host) and ok
945
+ ok = _report_model(ui, reachable, host, model) and ok
946
+ ok = _report_disk_vs_pool(ui, pool_dir) and ok
947
+ ok = _report_ram_vs_index(ui, pool_dir) and ok
948
+
949
+ ui.line()
950
+ if ok:
951
+ ui.line(f" {ui.glyph('ok')} {ui.style('all good.', 'bold', 'green')}")
952
+ else:
953
+ ui.line(f" {ui.glyph('warn')} some checks need attention (see fixes above).")
954
+ return 0 if ok else 1
955
+
956
+
957
+ def _resolve_host(flag: str | None) -> str:
958
+ """Resolve the Ollama host: ``--host`` → ``$OLLAMA_HOST`` → default."""
959
+ if flag:
960
+ return flag.rstrip("/")
961
+ env = os.environ.get(_ENV_OLLAMA_HOST)
962
+ if env:
963
+ return env.rstrip("/")
964
+ return _DEFAULT_OLLAMA_HOST
965
+
966
+
967
+ def _probe_ollama(host: str) -> bool:
968
+ """Best-effort reachability probe of the Ollama daemon. Never raises (offline-safe)."""
969
+ try:
970
+ req = urllib.request.Request(f"{host}/api/tags", method="GET")
971
+ with urllib.request.urlopen(req, timeout=_PROBE_TIMEOUT) as resp:
972
+ return 200 <= resp.status < 500
973
+ except (urllib.error.URLError, OSError, ValueError):
974
+ return False
975
+
976
+
977
+ def _report_ollama(ui: Console, reachable: bool, host: str) -> bool:
978
+ """Print the Ollama reachability check + its fix command. Returns True iff reachable."""
979
+ if reachable:
980
+ ui.check("ok", f"ollama daemon reachable at {host}")
981
+ return True
982
+ ui.check("fail", f"ollama daemon not reachable at {host}", "ollama serve")
983
+ return False
984
+
985
+
986
+ def _report_model(ui: Console, reachable: bool, host: str, model: str | None) -> bool:
987
+ """Check whether ``model`` is pulled (only meaningful if the daemon is up).
988
+
989
+ Always prints the exact ``ollama pull <model>`` fix command when a model was named, so the
990
+ user sees the remedy even fully offline.
991
+ """
992
+ if model is None:
993
+ ui.check("skip", "no --model given; pass --model qwen2.5 to check a specific model")
994
+ return True
995
+ if not reachable:
996
+ ui.check(
997
+ "fail", f"cannot check model '{model}' (daemon down)",
998
+ f"start ollama then `ollama pull {model}`",
999
+ )
1000
+ return False
1001
+ pulled = _model_is_pulled(host, model)
1002
+ if pulled:
1003
+ ui.check("ok", f"model '{model}' is pulled")
1004
+ return True
1005
+ ui.check(
1006
+ "fail", f"model '{model}' is not pulled",
1007
+ f"ollama pull {model} (or Session(model='ollama/{model}', pull=True))",
1008
+ )
1009
+ return False
1010
+
1011
+
1012
+ def _model_is_pulled(host: str, model: str) -> bool:
1013
+ """Return True iff ``model`` appears in ``/api/tags``. Offline-safe (False on any failure)."""
1014
+ try:
1015
+ req = urllib.request.Request(f"{host}/api/tags", method="GET")
1016
+ with urllib.request.urlopen(req, timeout=_PROBE_TIMEOUT) as resp:
1017
+ body = json.loads(resp.read().decode("utf-8"))
1018
+ except (urllib.error.URLError, OSError, ValueError):
1019
+ return False
1020
+ names = {m.get("name", "") for m in (body.get("models") or [])}
1021
+ # ollama tags carry a ':tag' suffix; match the bare name or any tag of it.
1022
+ base = model.split(":", 1)[0]
1023
+ return any(n == model or n.split(":", 1)[0] == base for n in names)
1024
+
1025
+
1026
+ def _report_disk_vs_pool(ui: Console, pool_dir: Path) -> bool:
1027
+ """Check free disk against the configured pool size (the pool reserves that much disk)."""
1028
+ cfg = PoolConfig.load(pool_dir)
1029
+ free = free_disk_bytes(pool_dir)
1030
+ if free is None:
1031
+ ui.check("ok", f"{cfg.pool_gb} GB pool — free disk unknown (could not probe)")
1032
+ return True
1033
+ free_gb = free / BYTES_PER_GB
1034
+ if free >= cfg.pool_gb * BYTES_PER_GB:
1035
+ ui.check("ok", f"{cfg.pool_gb} GB pool fits ({free_gb:.1f} GB free at {pool_dir})")
1036
+ return True
1037
+ fits = max(POOL_GB_FLOOR, int(free // BYTES_PER_GB))
1038
+ ui.check(
1039
+ "warn", f"{cfg.pool_gb} GB pool vs only {free_gb:.1f} GB free at {pool_dir}",
1040
+ f"free up disk, or `aether-context --pool {fits}`",
1041
+ )
1042
+ return False
1043
+
1044
+
1045
+ def _report_ram_vs_index(ui: Console, pool_dir: Path) -> bool:
1046
+ """Estimate the index RAM for the configured pool and compare to free system RAM.
1047
+
1048
+ Reads the persisted ``PoolConfig`` (or defaults), computes the index RAM from the pool
1049
+ reach math (README table: ~145 MB at 5 GB), and warns if it would not comfortably fit in
1050
+ free RAM. Free RAM is probed best-effort; if it cannot be read we report the estimate only.
1051
+ """
1052
+ cfg = PoolConfig.load(pool_dir)
1053
+ index_bytes = _estimate_index_bytes(cfg)
1054
+ index_mb = index_bytes / (1024 * 1024)
1055
+ free_bytes = _free_ram_bytes()
1056
+ if free_bytes is None:
1057
+ ui.check("ok", f"index RAM estimate ~= {index_mb:.0f} MB (free RAM unknown; not probed)")
1058
+ return True
1059
+ free_mb = free_bytes / (1024 * 1024)
1060
+ # comfortable = index fits in well under half of free RAM.
1061
+ if index_bytes * 2 < free_bytes:
1062
+ ui.check("ok", f"index RAM ~= {index_mb:.0f} MB fits in {free_mb:.0f} MB free")
1063
+ return True
1064
+ ui.check(
1065
+ "warn", f"index RAM ~= {index_mb:.0f} MB vs only {free_mb:.0f} MB free",
1066
+ "use a smaller --pool (a paged 'tiered' index is not built yet)",
1067
+ )
1068
+ return False
1069
+
1070
+
1071
+ def _estimate_index_bytes(cfg: PoolConfig) -> int:
1072
+ """In-RAM ANN index estimate for ``cfg``, matching the README table (~145 MB at 5 GB).
1073
+
1074
+ The resident index scales with reach (more reach -> more slices -> a bigger graph), so the
1075
+ published table is linear in ``pool_gb`` at ~29 MB/GB. The full float32 vector store is far
1076
+ larger but lives on disk (mmap), so it is not what bounds RAM — the in-RAM index is.
1077
+ """
1078
+ return int(cfg.pool_gb * _INDEX_MB_PER_GB * 1024 * 1024)
1079
+
1080
+
1081
+ def _free_ram_bytes() -> int | None:
1082
+ """Best-effort free-RAM probe using only the standard library. None if unavailable.
1083
+
1084
+ Tries ``os.sysconf`` (POSIX) then a Windows ctypes call. Never raises — a probe failure
1085
+ simply yields ``None`` and the report degrades gracefully.
1086
+ """
1087
+ posix = _free_ram_posix()
1088
+ if posix is not None:
1089
+ return posix
1090
+ return _free_ram_windows()
1091
+
1092
+
1093
+ def _free_ram_posix() -> int | None:
1094
+ """POSIX free-RAM via ``os.sysconf`` (available pages * page size). None off-POSIX."""
1095
+ try:
1096
+ names = getattr(os, "sysconf_names", {})
1097
+ sysconf = getattr(os, "sysconf", None) # absent on Windows
1098
+ if sysconf is not None and "SC_AVPHYS_PAGES" in names and "SC_PAGE_SIZE" in names:
1099
+ pages = sysconf("SC_AVPHYS_PAGES")
1100
+ page_size = sysconf("SC_PAGE_SIZE")
1101
+ if pages > 0 and page_size > 0:
1102
+ return int(pages) * int(page_size)
1103
+ except (AttributeError, ValueError, OSError):
1104
+ return None
1105
+ return None
1106
+
1107
+
1108
+ def _free_ram_windows() -> int | None:
1109
+ """Windows free-RAM via ``GlobalMemoryStatusEx`` (ctypes). None on non-Windows/failure."""
1110
+ if not sys.platform.startswith("win"):
1111
+ return None
1112
+ try:
1113
+ import ctypes
1114
+
1115
+ class _MemStatus(ctypes.Structure):
1116
+ _fields_ = [
1117
+ ("dwLength", ctypes.c_ulong),
1118
+ ("dwMemoryLoad", ctypes.c_ulong),
1119
+ ("ullTotalPhys", ctypes.c_ulonglong),
1120
+ ("ullAvailPhys", ctypes.c_ulonglong),
1121
+ ("ullTotalPageFile", ctypes.c_ulonglong),
1122
+ ("ullAvailPageFile", ctypes.c_ulonglong),
1123
+ ("ullTotalVirtual", ctypes.c_ulonglong),
1124
+ ("ullAvailVirtual", ctypes.c_ulonglong),
1125
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
1126
+ ]
1127
+
1128
+ stat = _MemStatus()
1129
+ stat.dwLength = ctypes.sizeof(_MemStatus)
1130
+ if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): # type: ignore[attr-defined]
1131
+ return int(stat.ullAvailPhys)
1132
+ except (OSError, AttributeError, ValueError):
1133
+ return None
1134
+ return None
1135
+
1136
+
1137
+ # ---------------------------------------------------------------------------
1138
+ # bench — delegate to bench/drift_vs_window.py.
1139
+ # ---------------------------------------------------------------------------
1140
+ def _cmd_bench(args: argparse.Namespace) -> int:
1141
+ """Delegate to ``bench/drift_vs_window.py``. Returns its exit code.
1142
+
1143
+ The bench lives outside the importable package (it's a script under ``bench/``), so we load
1144
+ it by file path. If it cannot be located (e.g. an installed wheel that omits ``bench/``) we
1145
+ print an actionable hint rather than crashing.
1146
+ """
1147
+ module = _load_bench_module()
1148
+ if module is None:
1149
+ print("bench script not found (bench/drift_vs_window.py).", file=sys.stderr)
1150
+ print(" fix: run from a source checkout, or `python bench/drift_vs_window.py`.",
1151
+ file=sys.stderr)
1152
+ return 1
1153
+ bench_argv: list[str] = ["--model", str(args.model)]
1154
+ if args.quick:
1155
+ bench_argv.append("--quick")
1156
+ if getattr(args, "json", False):
1157
+ bench_argv.append("--json")
1158
+ return int(module.main(bench_argv))
1159
+
1160
+
1161
+ def _load_bench_module() -> ModuleType | None:
1162
+ """Locate + import ``bench/drift_vs_window.py`` by file path. None if not found."""
1163
+ here = Path(__file__).resolve()
1164
+ candidates = [
1165
+ here.parent.parent / "bench" / "drift_vs_window.py", # repo checkout: <root>/bench/
1166
+ Path.cwd() / "bench" / "drift_vs_window.py", # invoked from repo root
1167
+ ]
1168
+ for path in candidates:
1169
+ if path.is_file():
1170
+ mod_name = "aether_context_bench"
1171
+ spec = importlib.util.spec_from_file_location(mod_name, path)
1172
+ if spec is None or spec.loader is None:
1173
+ continue
1174
+ module = importlib.util.module_from_spec(spec)
1175
+ # Register before exec so the module's own dataclasses can resolve
1176
+ # ``cls.__module__`` via ``sys.modules`` during class creation.
1177
+ sys.modules[mod_name] = module
1178
+ try:
1179
+ spec.loader.exec_module(module)
1180
+ except Exception: # noqa: BLE001 - a broken bench file shouldn't crash the CLI
1181
+ sys.modules.pop(mod_name, None)
1182
+ raise
1183
+ return module
1184
+ return None
1185
+
1186
+
1187
+ __all__ = ["main", "build_parser", "dispatch_slash", "ReplState", "SLASH_ACTIONS"]
1188
+
1189
+
1190
+ if __name__ == "__main__": # pragma: no cover - exercised via `python -m aether_context.cli`
1191
+ sys.exit(main())