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/__init__.py +29 -0
- aether_context/_log.py +33 -0
- aether_context/cli.py +1191 -0
- aether_context/config.py +206 -0
- aether_context/context_pool.py +819 -0
- aether_context/encoder.py +213 -0
- aether_context/errors.py +93 -0
- aether_context/local_llm.py +846 -0
- aether_context/mpo.py +151 -0
- aether_context/py.typed +0 -0
- aether_context/quantize.py +86 -0
- aether_context/session.py +829 -0
- aether_context/slice_loader.py +501 -0
- aether_context/tokenizer.py +64 -0
- aether_context/ui.py +253 -0
- aether_context/witness.py +356 -0
- aether_context-0.3.0.dist-info/METADATA +429 -0
- aether_context-0.3.0.dist-info/RECORD +23 -0
- aether_context-0.3.0.dist-info/WHEEL +5 -0
- aether_context-0.3.0.dist-info/entry_points.txt +2 -0
- aether_context-0.3.0.dist-info/licenses/LICENSE +201 -0
- aether_context-0.3.0.dist-info/licenses/NOTICE.md +19 -0
- aether_context-0.3.0.dist-info/top_level.txt +1 -0
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())
|