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.
@@ -0,0 +1,4 @@
1
+ """Palimpsests — a layered local-LLM inference engine."""
2
+
3
+ __version__ = "0.2.0"
4
+ __all__ = ["__version__"]
@@ -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
@@ -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
+ ]