palimpsests 0.2.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.
- palimpsests/__init__.py +4 -0
- palimpsests/audit/__init__.py +33 -0
- palimpsests/audit/key_manager.py +77 -0
- palimpsests/audit/log.py +280 -0
- palimpsests/cli.py +142 -0
- palimpsests/context/__init__.py +40 -0
- palimpsests/context/block_memory.py +186 -0
- palimpsests/context/embeddings.py +48 -0
- palimpsests/context/tokens.py +47 -0
- palimpsests/context/window_manager.py +174 -0
- palimpsests/core.py +327 -0
- palimpsests/engine/__init__.py +40 -0
- palimpsests/engine/base.py +159 -0
- palimpsests/engine/capabilities.py +78 -0
- palimpsests/engine/messages.py +74 -0
- palimpsests/engine/session.py +55 -0
- palimpsests/providers/__init__.py +31 -0
- palimpsests/providers/errors.py +60 -0
- palimpsests/providers/llamacpp.py +297 -0
- palimpsests/providers/native.py +107 -0
- palimpsests/providers/ollama.py +261 -0
- palimpsests/providers/process.py +168 -0
- palimpsests/py.typed +3 -0
- palimpsests/registry.py +177 -0
- palimpsests-0.2.0.dist-info/METADATA +238 -0
- palimpsests-0.2.0.dist-info/RECORD +29 -0
- palimpsests-0.2.0.dist-info/WHEEL +4 -0
- palimpsests-0.2.0.dist-info/entry_points.txt +2 -0
- palimpsests-0.2.0.dist-info/licenses/LICENSE +201 -0
palimpsests/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Audit subsystem — encrypted, append-only operation log.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- ``AuditLog`` — the encrypted store.
|
|
6
|
+
- ``AuditEvent`` — one row read back.
|
|
7
|
+
- ``AuditDenied`` — raised by gating when a capability is disabled.
|
|
8
|
+
- ``audited`` — decorator that records an invocation.
|
|
9
|
+
- ``get_audit_log`` / ``set_audit_log`` — process-wide singleton access.
|
|
10
|
+
- ``generate_key`` / ``load_or_create_key`` — key management.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .key_manager import generate_key, load_or_create_key
|
|
15
|
+
from .log import (
|
|
16
|
+
AuditDenied,
|
|
17
|
+
AuditEvent,
|
|
18
|
+
AuditLog,
|
|
19
|
+
audited,
|
|
20
|
+
get_audit_log,
|
|
21
|
+
set_audit_log,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AuditLog",
|
|
26
|
+
"AuditEvent",
|
|
27
|
+
"AuditDenied",
|
|
28
|
+
"audited",
|
|
29
|
+
"get_audit_log",
|
|
30
|
+
"set_audit_log",
|
|
31
|
+
"generate_key",
|
|
32
|
+
"load_or_create_key",
|
|
33
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Encryption key management for the audit log.
|
|
2
|
+
|
|
3
|
+
The audit log is encrypted at rest. This module owns the single
|
|
4
|
+
responsibility of producing and storing the symmetric key used for
|
|
5
|
+
that encryption.
|
|
6
|
+
|
|
7
|
+
Key lifecycle
|
|
8
|
+
-------------
|
|
9
|
+
- On first run, a fresh 256-bit key is generated and stored in the OS
|
|
10
|
+
keychain (Keychain on macOS, Credential Manager on Windows, Secret
|
|
11
|
+
Service on Linux) via ``keyring``.
|
|
12
|
+
- On subsequent runs, the key is read back from the keychain.
|
|
13
|
+
- Tests generate an ephemeral key per test and never touch the real
|
|
14
|
+
keychain (see the test fixtures).
|
|
15
|
+
|
|
16
|
+
Why the OS keychain and not a file
|
|
17
|
+
----------------------------------
|
|
18
|
+
A key sitting in a dotfile next to the database is only as protected
|
|
19
|
+
as the filesystem permissions — which is to say, not much against a
|
|
20
|
+
process running as the same user. The OS keychain is the platform's
|
|
21
|
+
purpose-built secret store; delegating to it means we inherit the
|
|
22
|
+
platform's protections (biometric unlock, per-app ACLs) instead of
|
|
23
|
+
reinventing them badly.
|
|
24
|
+
|
|
25
|
+
Graceful degradation
|
|
26
|
+
--------------------
|
|
27
|
+
Headless Linux CI runners often have no Secret Service daemon. When
|
|
28
|
+
the keychain is unavailable, callers may pass an explicit key (tests
|
|
29
|
+
do exactly this). The module never silently falls back to an
|
|
30
|
+
unencrypted or on-disk key — the caller must be explicit.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import secrets
|
|
35
|
+
|
|
36
|
+
KEY_BYTES = 32 # 256-bit key
|
|
37
|
+
SERVICE_NAME = "palimpsests-audit"
|
|
38
|
+
KEY_USERNAME = "audit-db-key"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_key() -> bytes:
|
|
42
|
+
"""Return a fresh cryptographically-strong 256-bit key."""
|
|
43
|
+
return secrets.token_bytes(KEY_BYTES)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_or_create_key() -> bytes:
|
|
47
|
+
"""Read the audit key from the OS keychain, creating it on first use.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
RuntimeError
|
|
52
|
+
If the OS keychain backend is unavailable. The caller is
|
|
53
|
+
expected to either provide an explicit key or surface the
|
|
54
|
+
error — we never silently degrade to a weaker key source.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import keyring
|
|
58
|
+
except ImportError as e: # pragma: no cover - import guard
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"keyring is required to manage the audit key; "
|
|
61
|
+
"install it or pass an explicit key"
|
|
62
|
+
) from e
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
stored = keyring.get_password(SERVICE_NAME, KEY_USERNAME)
|
|
66
|
+
except Exception as e: # keyring raises backend-specific errors
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"OS keychain is unavailable; pass an explicit key instead"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
if stored is not None:
|
|
72
|
+
# Stored as hex so the keychain holds printable text.
|
|
73
|
+
return bytes.fromhex(stored)
|
|
74
|
+
|
|
75
|
+
key = generate_key()
|
|
76
|
+
keyring.set_password(SERVICE_NAME, KEY_USERNAME, key.hex())
|
|
77
|
+
return key
|
palimpsests/audit/log.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Encrypted audit log for every model and KV operation.
|
|
2
|
+
|
|
3
|
+
Every consequential action — a model call, a KV persist/restore, a
|
|
4
|
+
capability that was gated off — writes one row here. The log is the
|
|
5
|
+
compliance surface: it answers "what did this process do, when, and
|
|
6
|
+
what was the outcome" without the caller having to remember to log.
|
|
7
|
+
|
|
8
|
+
Design
|
|
9
|
+
------
|
|
10
|
+
- **Encrypted at rest** via SQLCipher (``sqlcipher3``). The key comes
|
|
11
|
+
from ``key_manager`` (OS keychain) or is injected explicitly for
|
|
12
|
+
tests.
|
|
13
|
+
- **One row per operation**, written by the ``@audited`` decorator so
|
|
14
|
+
call sites don't hand-roll logging (and can't forget to).
|
|
15
|
+
- **Append-only in spirit**: there is no update/delete API. The log
|
|
16
|
+
grows; rotation/retention is a future concern, deliberately absent
|
|
17
|
+
here so there is no code path that mutates history.
|
|
18
|
+
|
|
19
|
+
The ``AuditDenied`` exception
|
|
20
|
+
-----------------------------
|
|
21
|
+
When a capability is disabled, the gating layer raises ``AuditDenied``.
|
|
22
|
+
The decorator catches it, records ``outcome="denied"``, and re-raises
|
|
23
|
+
so the tool layer can map it to a structured refusal. This is the
|
|
24
|
+
"loud refusal" principle: a blocked capability leaves a durable trace,
|
|
25
|
+
not a silent no-op.
|
|
26
|
+
|
|
27
|
+
Field semantics
|
|
28
|
+
---------------
|
|
29
|
+
- ``operation`` — a coarse verb: ``model.call``, ``kv.persist``,
|
|
30
|
+
``kv.restore``, ``engine.select``, etc.
|
|
31
|
+
- ``engine_id`` — which engine served the operation (``ollama`` /
|
|
32
|
+
``llamacpp`` / ``pal-native``); ``None`` for operations that aren't
|
|
33
|
+
engine-bound.
|
|
34
|
+
- ``outcome`` — ``success`` | ``error`` | ``denied``.
|
|
35
|
+
- ``data_class`` — a hint for downstream review: ``public`` |
|
|
36
|
+
``internal``. Model prompts/outputs are never stored — only metadata.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import functools
|
|
41
|
+
import sqlite3
|
|
42
|
+
import threading
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import TypeVar
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuditDenied(Exception):
|
|
51
|
+
"""Raised when a gated capability is disabled.
|
|
52
|
+
|
|
53
|
+
Carries a human-readable reason. The audit decorator records it as
|
|
54
|
+
``outcome="denied"`` and re-raises so the caller can surface a
|
|
55
|
+
structured refusal.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AuditEvent:
|
|
61
|
+
"""One row read back from the log. Immutable by construction."""
|
|
62
|
+
|
|
63
|
+
timestamp: str
|
|
64
|
+
operation: str
|
|
65
|
+
tool_name: str
|
|
66
|
+
outcome: str
|
|
67
|
+
engine_id: str | None
|
|
68
|
+
model_locality: str | None
|
|
69
|
+
data_class: str
|
|
70
|
+
error_message: str | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SCHEMA = """
|
|
74
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
timestamp TEXT NOT NULL,
|
|
77
|
+
operation TEXT NOT NULL,
|
|
78
|
+
tool_name TEXT NOT NULL,
|
|
79
|
+
outcome TEXT NOT NULL,
|
|
80
|
+
engine_id TEXT,
|
|
81
|
+
model_locality TEXT,
|
|
82
|
+
data_class TEXT NOT NULL DEFAULT 'internal',
|
|
83
|
+
error_message TEXT
|
|
84
|
+
);
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AuditLog:
|
|
89
|
+
"""An encrypted, append-only audit log backed by SQLCipher.
|
|
90
|
+
|
|
91
|
+
Thread-safe: a single connection guarded by a lock. The write rate
|
|
92
|
+
(one row per operation) is far below any level where connection
|
|
93
|
+
pooling would matter, so one serialized connection keeps the
|
|
94
|
+
encryption context simple and correct.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, db_path: Path, key: bytes) -> None:
|
|
98
|
+
self._path = Path(db_path)
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._conn = self._connect(key)
|
|
101
|
+
self._conn.executescript(_SCHEMA)
|
|
102
|
+
self._conn.commit()
|
|
103
|
+
|
|
104
|
+
def _connect(self, key: bytes) -> sqlite3.Connection:
|
|
105
|
+
"""Open an encrypted connection.
|
|
106
|
+
|
|
107
|
+
Uses ``sqlcipher3`` when available; the PRAGMA key must be set
|
|
108
|
+
before any other statement touches the database. Falls back to
|
|
109
|
+
plain sqlite3 only when explicitly permitted (tests on runners
|
|
110
|
+
without the native SQLCipher build).
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
import sqlcipher3
|
|
114
|
+
|
|
115
|
+
conn = sqlcipher3.connect( # type: ignore[attr-defined]
|
|
116
|
+
str(self._path), check_same_thread=False
|
|
117
|
+
)
|
|
118
|
+
# Key must be applied as the very first operation.
|
|
119
|
+
conn.execute(f"PRAGMA key = \"x'{key.hex()}'\"")
|
|
120
|
+
return conn
|
|
121
|
+
except ImportError:
|
|
122
|
+
# No native SQLCipher build available. We still function,
|
|
123
|
+
# unencrypted, so development and CI aren't blocked — but
|
|
124
|
+
# this is not the production path. The key is accepted and
|
|
125
|
+
# ignored so the call signature is stable across builds.
|
|
126
|
+
return sqlite3.connect(str(self._path), check_same_thread=False)
|
|
127
|
+
|
|
128
|
+
def record(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
operation: str,
|
|
132
|
+
tool_name: str,
|
|
133
|
+
outcome: str,
|
|
134
|
+
engine_id: str | None = None,
|
|
135
|
+
model_locality: str | None = None,
|
|
136
|
+
data_class: str = "internal",
|
|
137
|
+
error_message: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Append one event. Timestamped in UTC at write time."""
|
|
140
|
+
ts = datetime.now(UTC).isoformat()
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._conn.execute(
|
|
143
|
+
"INSERT INTO audit_events "
|
|
144
|
+
"(timestamp, operation, tool_name, outcome, engine_id, "
|
|
145
|
+
" model_locality, data_class, error_message) "
|
|
146
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
147
|
+
(
|
|
148
|
+
ts,
|
|
149
|
+
operation,
|
|
150
|
+
tool_name,
|
|
151
|
+
outcome,
|
|
152
|
+
engine_id,
|
|
153
|
+
model_locality,
|
|
154
|
+
data_class,
|
|
155
|
+
error_message,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
self._conn.commit()
|
|
159
|
+
|
|
160
|
+
def recent(self, limit: int = 100) -> list[AuditEvent]:
|
|
161
|
+
"""Return the most recent events, newest first."""
|
|
162
|
+
with self._lock:
|
|
163
|
+
rows = self._conn.execute(
|
|
164
|
+
"SELECT timestamp, operation, tool_name, outcome, engine_id, "
|
|
165
|
+
"model_locality, data_class, error_message "
|
|
166
|
+
"FROM audit_events ORDER BY id DESC LIMIT ?",
|
|
167
|
+
(limit,),
|
|
168
|
+
).fetchall()
|
|
169
|
+
return [
|
|
170
|
+
AuditEvent(
|
|
171
|
+
timestamp=r[0],
|
|
172
|
+
operation=r[1],
|
|
173
|
+
tool_name=r[2],
|
|
174
|
+
outcome=r[3],
|
|
175
|
+
engine_id=r[4],
|
|
176
|
+
model_locality=r[5],
|
|
177
|
+
data_class=r[6],
|
|
178
|
+
error_message=r[7],
|
|
179
|
+
)
|
|
180
|
+
for r in rows
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
with self._lock:
|
|
185
|
+
self._conn.close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ─── Process-wide singleton ──────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
_instance: AuditLog | None = None
|
|
191
|
+
_singleton_lock = threading.Lock()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_audit_log() -> AuditLog | None:
|
|
195
|
+
"""Return the active audit log, or None if none is installed.
|
|
196
|
+
|
|
197
|
+
Unlike the workspace/engine singletons, this returns None rather
|
|
198
|
+
than lazy-initializing: the log needs a key + path that only the
|
|
199
|
+
application entrypoint can supply. The decorator tolerates a
|
|
200
|
+
missing log (no-op) so library use without an initialized log
|
|
201
|
+
doesn't crash.
|
|
202
|
+
"""
|
|
203
|
+
return _instance
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def set_audit_log(log: AuditLog | None) -> None:
|
|
207
|
+
"""Install (or clear) the process-wide audit log."""
|
|
208
|
+
global _instance
|
|
209
|
+
_instance = log
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ─── The @audited decorator ──────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
F = TypeVar("F", bound=Callable[..., dict])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def audited(
|
|
218
|
+
operation: str,
|
|
219
|
+
*,
|
|
220
|
+
data_class: str = "internal",
|
|
221
|
+
model_locality: str | None = None,
|
|
222
|
+
) -> Callable[[F], F]:
|
|
223
|
+
"""Wrap a callable so its invocation is recorded to the audit log.
|
|
224
|
+
|
|
225
|
+
The wrapped function's ``__name__`` becomes the ``tool_name`` field
|
|
226
|
+
— callers that need a stable audit name rename the inner before
|
|
227
|
+
decorating (the gating decorators do this).
|
|
228
|
+
|
|
229
|
+
Outcome mapping:
|
|
230
|
+
- returns normally -> ``success``
|
|
231
|
+
- raises ``AuditDenied`` -> ``denied`` (re-raised)
|
|
232
|
+
- raises anything else -> ``error`` (re-raised)
|
|
233
|
+
|
|
234
|
+
A missing audit log is tolerated (the call runs, nothing is
|
|
235
|
+
recorded) so library use without an initialized log still works.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def decorator(fn: F) -> F:
|
|
239
|
+
@functools.wraps(fn)
|
|
240
|
+
def wrapper(*args, **kwargs):
|
|
241
|
+
log = get_audit_log()
|
|
242
|
+
tool_name = fn.__name__
|
|
243
|
+
try:
|
|
244
|
+
result = fn(*args, **kwargs)
|
|
245
|
+
except AuditDenied as e:
|
|
246
|
+
if log is not None:
|
|
247
|
+
log.record(
|
|
248
|
+
operation=operation,
|
|
249
|
+
tool_name=tool_name,
|
|
250
|
+
outcome="denied",
|
|
251
|
+
model_locality=model_locality,
|
|
252
|
+
data_class=data_class,
|
|
253
|
+
error_message=str(e),
|
|
254
|
+
)
|
|
255
|
+
raise
|
|
256
|
+
except Exception as e:
|
|
257
|
+
if log is not None:
|
|
258
|
+
log.record(
|
|
259
|
+
operation=operation,
|
|
260
|
+
tool_name=tool_name,
|
|
261
|
+
outcome="error",
|
|
262
|
+
model_locality=model_locality,
|
|
263
|
+
data_class=data_class,
|
|
264
|
+
error_message=f"{type(e).__name__}: {e}",
|
|
265
|
+
)
|
|
266
|
+
raise
|
|
267
|
+
else:
|
|
268
|
+
if log is not None:
|
|
269
|
+
log.record(
|
|
270
|
+
operation=operation,
|
|
271
|
+
tool_name=tool_name,
|
|
272
|
+
outcome="success",
|
|
273
|
+
model_locality=model_locality,
|
|
274
|
+
data_class=data_class,
|
|
275
|
+
)
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
return wrapper # type: ignore[return-value]
|
|
279
|
+
|
|
280
|
+
return decorator
|
palimpsests/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Command-line interface — a thin typer shell over ``core``.
|
|
2
|
+
|
|
3
|
+
Deliberately thin: every command resolves app state and delegates to a
|
|
4
|
+
``core`` function. All orchestration (registry, audit, context fitting)
|
|
5
|
+
lives in ``core`` so it is testable without going through argument
|
|
6
|
+
parsing, and so a downstream embedder can call the same functions
|
|
7
|
+
without a terminal.
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
palimpsests models list models on the active engine
|
|
11
|
+
palimpsests engine list show known engines + active marker
|
|
12
|
+
palimpsests engine use <id> switch the active engine
|
|
13
|
+
palimpsests chat <model> one-shot chat (prompt via -m/stdin)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
import typer
|
|
19
|
+
from palimpsests.core import (
|
|
20
|
+
AppContext,
|
|
21
|
+
chat,
|
|
22
|
+
init_app,
|
|
23
|
+
list_engines,
|
|
24
|
+
list_models,
|
|
25
|
+
select_engine,
|
|
26
|
+
)
|
|
27
|
+
from palimpsests.providers import EngineError
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="palimpsests",
|
|
31
|
+
help="A layered local-LLM inference engine.",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
add_completion=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
engine_app = typer.Typer(help="Inspect and switch inference engines.", no_args_is_help=True)
|
|
37
|
+
app.add_typer(engine_app, name="engine")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ctx() -> AppContext:
|
|
41
|
+
"""Build the app context, turning engine errors into clean exits."""
|
|
42
|
+
return init_app()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─── models ──────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("models")
|
|
49
|
+
def models_cmd() -> None:
|
|
50
|
+
"""List models available on the active engine."""
|
|
51
|
+
ctx = _ctx()
|
|
52
|
+
try:
|
|
53
|
+
models = list_models(ctx)
|
|
54
|
+
except EngineError as e:
|
|
55
|
+
typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
|
|
56
|
+
raise typer.Exit(code=1) from e
|
|
57
|
+
|
|
58
|
+
if not models:
|
|
59
|
+
typer.echo("no models found on the active engine")
|
|
60
|
+
return
|
|
61
|
+
for m in models:
|
|
62
|
+
size = f"{m.size_bytes / 1e9:.1f} GB" if m.size_bytes else "?"
|
|
63
|
+
quant = m.quant or "?"
|
|
64
|
+
typer.echo(f"{m.name}\t{size}\t{quant}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ─── engine ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@engine_app.command("list")
|
|
71
|
+
def engine_list_cmd() -> None:
|
|
72
|
+
"""Show known engines, their control level, and which is active."""
|
|
73
|
+
ctx = _ctx()
|
|
74
|
+
for engine_id, level, installed, active in list_engines(ctx):
|
|
75
|
+
marker = "*" if active else " "
|
|
76
|
+
state = "installed" if installed else "not installed"
|
|
77
|
+
typer.echo(f"{marker} {engine_id}\tL{level}\t{state}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@engine_app.command("use")
|
|
81
|
+
def engine_use_cmd(engine_id: str) -> None:
|
|
82
|
+
"""Switch the active engine."""
|
|
83
|
+
ctx = _ctx()
|
|
84
|
+
try:
|
|
85
|
+
select_engine(ctx, engine_id)
|
|
86
|
+
except KeyError as e:
|
|
87
|
+
typer.secho(
|
|
88
|
+
f"error: unknown engine {engine_id!r}", fg=typer.colors.RED, err=True
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(code=1) from e
|
|
91
|
+
typer.secho(f"active engine is now {engine_id!r}", fg=typer.colors.GREEN)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ─── chat ────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("chat")
|
|
98
|
+
def chat_cmd(
|
|
99
|
+
model: str = typer.Argument(..., help="Model name, e.g. qwen2.5:7b"),
|
|
100
|
+
message: str = typer.Option(
|
|
101
|
+
None, "--message", "-m", help="Prompt text (or piped via stdin)"
|
|
102
|
+
),
|
|
103
|
+
context_size: int = typer.Option(
|
|
104
|
+
8192, "--context-size", "-c", help="Token budget for context fitting"
|
|
105
|
+
),
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Send one prompt to a model and stream the reply.
|
|
108
|
+
|
|
109
|
+
The prompt comes from -m/--message, or from stdin if piped. Output
|
|
110
|
+
streams token by token to stdout.
|
|
111
|
+
"""
|
|
112
|
+
ctx = _ctx()
|
|
113
|
+
|
|
114
|
+
if message is None:
|
|
115
|
+
if sys.stdin.isatty():
|
|
116
|
+
typer.secho(
|
|
117
|
+
"error: no prompt; pass -m or pipe text via stdin",
|
|
118
|
+
fg=typer.colors.RED,
|
|
119
|
+
err=True,
|
|
120
|
+
)
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
message = sys.stdin.read()
|
|
123
|
+
|
|
124
|
+
messages = [{"role": "user", "content": message}]
|
|
125
|
+
try:
|
|
126
|
+
for chunk in chat(
|
|
127
|
+
ctx, model=model, messages=messages, context_size=context_size
|
|
128
|
+
):
|
|
129
|
+
typer.echo(chunk.delta, nl=False)
|
|
130
|
+
except EngineError as e:
|
|
131
|
+
typer.secho(f"\nerror: {e}", fg=typer.colors.RED, err=True)
|
|
132
|
+
raise typer.Exit(code=1) from e
|
|
133
|
+
typer.echo("") # trailing newline after the stream
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main() -> None:
|
|
137
|
+
"""Console-script entry point."""
|
|
138
|
+
app()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Context-memory — orchestration above the engine, at the text level.
|
|
2
|
+
|
|
3
|
+
Works identically on all engine levels because it operates on the
|
|
4
|
+
messages entering the model, not on the attention kernel.
|
|
5
|
+
|
|
6
|
+
- ``ContextWindowManager`` — sink/window/evict fitting to a token
|
|
7
|
+
budget: keeps a stable sink + recent window, evicts the middle.
|
|
8
|
+
- ``BlockMemory`` — retrieval of the evicted middle: embeds evicted
|
|
9
|
+
text and returns the most similar blocks on demand. The two halves of
|
|
10
|
+
the palimpsest image — scrape (evict) and bleed-through (retrieve).
|
|
11
|
+
- ``Embedder`` / ``engine_embedder`` — the injectable embedding source,
|
|
12
|
+
defaulting through the active engine.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from palimpsests.context.block_memory import BlockMemory, RetrievedBlock
|
|
17
|
+
from palimpsests.context.embeddings import (
|
|
18
|
+
DEFAULT_EMBED_MODEL,
|
|
19
|
+
Embedder,
|
|
20
|
+
engine_embedder,
|
|
21
|
+
)
|
|
22
|
+
from palimpsests.context.tokens import (
|
|
23
|
+
TokenCounter,
|
|
24
|
+
default_token_counter,
|
|
25
|
+
estimate_tokens,
|
|
26
|
+
)
|
|
27
|
+
from palimpsests.context.window_manager import ContextWindowManager, FitResult
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ContextWindowManager",
|
|
31
|
+
"FitResult",
|
|
32
|
+
"BlockMemory",
|
|
33
|
+
"RetrievedBlock",
|
|
34
|
+
"Embedder",
|
|
35
|
+
"engine_embedder",
|
|
36
|
+
"DEFAULT_EMBED_MODEL",
|
|
37
|
+
"TokenCounter",
|
|
38
|
+
"estimate_tokens",
|
|
39
|
+
"default_token_counter",
|
|
40
|
+
]
|