lunaris-integrations 0.8.0__tar.gz

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,125 @@
1
+ # --- Rust ---
2
+ /target
3
+ target/
4
+ **/*.rs.bk
5
+ Cargo.lock.bak
6
+ *.pdb
7
+
8
+ # --- Python (PyO3 builds, evals, harnesses) ---
9
+ __pycache__/
10
+ *.py[cod]
11
+ *$py.class
12
+ *.so
13
+ .venv/
14
+ venv/
15
+ .python-version
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .mypy_cache/
19
+ uv.lock
20
+ .tox/
21
+ htmlcov/
22
+ .coverage
23
+ *.egg-info/
24
+ build/
25
+ dist/
26
+ wheels/
27
+
28
+ # --- Node / TypeScript (NAPI bindings) ---
29
+ node_modules/
30
+ .pnp
31
+ .pnp.js
32
+ *.tsbuildinfo
33
+ .next/
34
+ .turbo/
35
+ .npm/
36
+ .yarn/
37
+ dist-ts/
38
+ *.node
39
+
40
+ # --- Editors / OS ---
41
+ .vscode/
42
+ .idea/
43
+ *.swp
44
+ *.swo
45
+ .DS_Store
46
+ Thumbs.db
47
+
48
+ # --- Tooling caches ---
49
+ .serena/
50
+ .ruff_cache/
51
+ .cargo/
52
+ .rustup/
53
+ # .claude/ is local tooling (settings.local.json, worktrees/, the
54
+ # plugin-materialized `add` skill) EXCEPT the project-authored `/dream`
55
+ # skill (engram-soul-loop task 9), which is a real shipped deliverable and
56
+ # must be committed with the branch, not left as a local-only artifact.
57
+ .claude/*
58
+ !.claude/skills/
59
+ .claude/skills/*
60
+ !.claude/skills/dream/
61
+
62
+ # --- Local env / secrets ---
63
+ .env
64
+ .env.local
65
+ .env.*.local
66
+ secrets/
67
+ credentials.json
68
+ # Provider API keys. The LME harness reads its key from MINIMAX_API_KEY or
69
+ # from LUNARIS_BENCH_KEY_FILE (a path outside the repo); this is the backstop
70
+ # for the habit of dropping a `*.key` next to the runner. No tracked file has
71
+ # ever used this extension.
72
+ *.key
73
+
74
+ # --- Eval artefacts (large corpora, downloaded models) ---
75
+ evals/data/
76
+ evals/results/
77
+ models/
78
+ *.gguf
79
+ *.safetensors
80
+ *.onnx
81
+
82
+ # --- LongMemEval harness output (scripts/bench/lme) ---
83
+ # The runners default LME_RESULTS_DIR to target/lme, already covered by
84
+ # `/target` above. These rules make the harness directory itself un-dirtyable
85
+ # if an operator repoints LME_RESULTS_DIR at it: the SCRIPTS are tracked, the
86
+ # run artifacts, caches and Moon scratch dirs never are. The LongMemEval
87
+ # dataset is external and is never committed either (it downloads into
88
+ # LUNARIS_EVAL_CACHE_DIR, default ~/.cache/lunaris/eval-hub).
89
+ scripts/bench/lme/**/q*.json
90
+ scripts/bench/lme/**/*.log
91
+ scripts/bench/lme/**/config.fp
92
+ scripts/bench/lme/**/config.env
93
+ scripts/bench/lme/**/extract-cache/
94
+ scripts/bench/lme/**/graphon/
95
+ scripts/bench/lme/**/graphoff/
96
+ scripts/bench/lme/**/fill/
97
+ scripts/bench/lme/**/moon*/
98
+
99
+ # --- Local Postgres / Moon dev state ---
100
+ pgdata/
101
+ moon-data/
102
+ *.wal
103
+ *.snapshot
104
+ tmp/
105
+ # Moon writes these into its --dir cwd; if a bench is run with `--dir .`
106
+ # accidentally they end up in the repo root. Always-ignore so they don't
107
+ # get staged or committed.
108
+ appendonlydir/
109
+ shard-*/
110
+ replication.state
111
+ *.aof
112
+ *.rdb
113
+
114
+ # --- Live measurement scratch reports ---
115
+ LIVE-MEASUREMENT-REPORT.md
116
+ milestones/v0.1.1-bench/cache/
117
+
118
+ # mdBook build output
119
+ /docs/book/book/
120
+
121
+ # embedded-moon data dir (--features embedded-moon)
122
+ # Matches the dir written by the production binary (repo root) AND by
123
+ # cargo tests with --features embedded-moon (CWD = crates/lunaris-mcp/).
124
+ # No leading slash — intentional, matches at any directory depth.
125
+ .lunaris-moon/
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.5
2
+ Name: lunaris-integrations
3
+ Version: 0.8.0
4
+ Summary: Memory adapters for agent frameworks (LangGraph, CrewAI, Letta) on top of Lunaris.
5
+ Project-URL: Homepage, https://github.com/pilotspace/lunaris
6
+ Project-URL: Repository, https://github.com/pilotspace/lunaris
7
+ Author: Lunaris
8
+ License: Apache-2.0
9
+ Keywords: agents,crewai,langgraph,letta,lunaris,memory
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: crewai
13
+ Requires-Dist: crewai>=0.80; extra == 'crewai'
14
+ Provides-Extra: langgraph
15
+ Requires-Dist: langgraph>=0.2; extra == 'langgraph'
16
+ Provides-Extra: letta
17
+ Requires-Dist: letta>=0.16; extra == 'letta'
18
+ Provides-Extra: sdk
19
+ Requires-Dist: lunaris; extra == 'sdk'
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=8; extra == 'test'
22
+ Requires-Dist: respx>=0.21; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # lunaris-integrations
26
+
27
+ Memory adapters that drop Lunaris into your existing agent framework. One thin,
28
+ transport-agnostic layer — pick a transport once, reuse it across every adapter.
29
+
30
+ ```bash
31
+ pip install -e "path/to/lunaris/integrations[langgraph]" # or [crewai] / [letta]
32
+ ```
33
+
34
+ `lunaris-integrations` is not on PyPI **yet**. `integrations-publish.yml`
35
+ builds and uploads it on a `v*` tag, so from the next release on the line is:
36
+
37
+ ```bash
38
+ pip install "lunaris-integrations[langgraph]"
39
+ ```
40
+
41
+ Until that tag lands, install from a checkout with the `-e` form above — the
42
+ one the [examples](../examples/) and CI both use, so it is the path that is
43
+ actually exercised.
44
+
45
+ ## Why a separate package
46
+
47
+ `lunaris_integrations` is a **pure-Python** package, separate from the native
48
+ `lunaris` core wheel. Importing the client + scope layer never loads the
49
+ compiled cdylib, and the framework deps (langgraph / crewai / letta) are
50
+ **optional extras** — never hard dependencies of Lunaris core.
51
+
52
+ ## The shared client
53
+
54
+ Every adapter is built over one `LunarisClient`, scope-bound at construction:
55
+
56
+ | Transport | Use when |
57
+ |----------------------|-----------------------------------------------------------------|
58
+ | `HttpLunarisClient` | You run `lunaris-server`; talks the MemoryProtocol HTTP verbs. |
59
+ | `SdkLunarisClient` | You embed the in-process `lunaris` wheel (`pip install lunaris`).|
60
+ | `StubLunarisClient` | Tests — records calls, returns canned hits (no backend). |
61
+
62
+ The HTTP transport binds scope to the JWT server-side; the partition scope
63
+ **never travels on the wire**. Namespaces map to scopes through the Lunaris
64
+ alphabet (`[A-Za-z0-9_\-.]{1,128}`, `:` rejected) so no key can byte-alias
65
+ another scope's partition.
66
+
67
+ ## Adapters
68
+
69
+ | Framework | Class | Maps |
70
+ |-----------|-----------------------------|---------------------------------------|
71
+ | LangGraph | `langgraph.LunarisStore` | `aput`/`aget`/`asearch` → ingest/recall |
72
+ | CrewAI | `crewai.LunarisCrewAIStorage` | `save`/`search`/`reset` → ingest/recall/forget |
73
+ | Letta | `letta.LunarisArchivalConnector` | `insert`/`search` → ingest/recall (connector shim + recipe) |
74
+
75
+ > **Letta** ships as a client-backed connector shim + a [recipe](../examples/letta-lunaris/README.md):
76
+ > its archival store is server-side, so there is no clean drop-in base to
77
+ > subclass yet. The insert/search mapping is identical to the other adapters.
78
+
79
+ ## Examples
80
+
81
+ Runnable per-framework examples live in [`../examples/`](../examples/):
82
+ `langgraph-lunaris/`, `crewai-lunaris/`, `letta-lunaris/`.
83
+
84
+ ## Tests
85
+
86
+ ```bash
87
+ pip install -e ".[langgraph,crewai,letta,test]" # from integrations/
88
+ pytest tests/
89
+ ```
90
+
91
+ The unit layer runs against `StubLunarisClient` — no backend, model, or wheel.
92
+ Live HTTP/SDK + a real framework end-to-end is exercised by the examples.
@@ -0,0 +1,68 @@
1
+ # lunaris-integrations
2
+
3
+ Memory adapters that drop Lunaris into your existing agent framework. One thin,
4
+ transport-agnostic layer — pick a transport once, reuse it across every adapter.
5
+
6
+ ```bash
7
+ pip install -e "path/to/lunaris/integrations[langgraph]" # or [crewai] / [letta]
8
+ ```
9
+
10
+ `lunaris-integrations` is not on PyPI **yet**. `integrations-publish.yml`
11
+ builds and uploads it on a `v*` tag, so from the next release on the line is:
12
+
13
+ ```bash
14
+ pip install "lunaris-integrations[langgraph]"
15
+ ```
16
+
17
+ Until that tag lands, install from a checkout with the `-e` form above — the
18
+ one the [examples](../examples/) and CI both use, so it is the path that is
19
+ actually exercised.
20
+
21
+ ## Why a separate package
22
+
23
+ `lunaris_integrations` is a **pure-Python** package, separate from the native
24
+ `lunaris` core wheel. Importing the client + scope layer never loads the
25
+ compiled cdylib, and the framework deps (langgraph / crewai / letta) are
26
+ **optional extras** — never hard dependencies of Lunaris core.
27
+
28
+ ## The shared client
29
+
30
+ Every adapter is built over one `LunarisClient`, scope-bound at construction:
31
+
32
+ | Transport | Use when |
33
+ |----------------------|-----------------------------------------------------------------|
34
+ | `HttpLunarisClient` | You run `lunaris-server`; talks the MemoryProtocol HTTP verbs. |
35
+ | `SdkLunarisClient` | You embed the in-process `lunaris` wheel (`pip install lunaris`).|
36
+ | `StubLunarisClient` | Tests — records calls, returns canned hits (no backend). |
37
+
38
+ The HTTP transport binds scope to the JWT server-side; the partition scope
39
+ **never travels on the wire**. Namespaces map to scopes through the Lunaris
40
+ alphabet (`[A-Za-z0-9_\-.]{1,128}`, `:` rejected) so no key can byte-alias
41
+ another scope's partition.
42
+
43
+ ## Adapters
44
+
45
+ | Framework | Class | Maps |
46
+ |-----------|-----------------------------|---------------------------------------|
47
+ | LangGraph | `langgraph.LunarisStore` | `aput`/`aget`/`asearch` → ingest/recall |
48
+ | CrewAI | `crewai.LunarisCrewAIStorage` | `save`/`search`/`reset` → ingest/recall/forget |
49
+ | Letta | `letta.LunarisArchivalConnector` | `insert`/`search` → ingest/recall (connector shim + recipe) |
50
+
51
+ > **Letta** ships as a client-backed connector shim + a [recipe](../examples/letta-lunaris/README.md):
52
+ > its archival store is server-side, so there is no clean drop-in base to
53
+ > subclass yet. The insert/search mapping is identical to the other adapters.
54
+
55
+ ## Examples
56
+
57
+ Runnable per-framework examples live in [`../examples/`](../examples/):
58
+ `langgraph-lunaris/`, `crewai-lunaris/`, `letta-lunaris/`.
59
+
60
+ ## Tests
61
+
62
+ ```bash
63
+ pip install -e ".[langgraph,crewai,letta,test]" # from integrations/
64
+ pytest tests/
65
+ ```
66
+
67
+ The unit layer runs against `StubLunarisClient` — no backend, model, or wheel.
68
+ Live HTTP/SDK + a real framework end-to-end is exercised by the examples.
@@ -0,0 +1,40 @@
1
+ """lunaris_integrations — memory adapters for agent frameworks.
2
+
3
+ A thin, transport-agnostic layer on top of Lunaris. Every adapter
4
+ (LangGraph / CrewAI / Letta) is built over the shared `LunarisClient`
5
+ (HTTP MemoryProtocol or in-process lunaris-py SDK), so the adapters stay small
6
+ and the framework deps are OPTIONAL extras — never a hard dep of `lunaris` core.
7
+
8
+ pip install lunaris-integrations[langgraph] # or [crewai] / [letta]
9
+
10
+ The framework adapter modules (`.langgraph`, `.crewai`, `.letta`) import their
11
+ framework at module load and are version-guarded; import them only when the
12
+ corresponding extra is installed.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .client import (
17
+ ConfigError,
18
+ Hit,
19
+ HttpLunarisClient,
20
+ InvalidScope,
21
+ LunarisClient,
22
+ SdkLunarisClient,
23
+ StubLunarisClient,
24
+ UnsupportedFrameworkVersion,
25
+ namespace_to_scope,
26
+ require_base_methods,
27
+ )
28
+
29
+ __all__ = [
30
+ "Hit",
31
+ "LunarisClient",
32
+ "HttpLunarisClient",
33
+ "SdkLunarisClient",
34
+ "StubLunarisClient",
35
+ "ConfigError",
36
+ "InvalidScope",
37
+ "UnsupportedFrameworkVersion",
38
+ "namespace_to_scope",
39
+ "require_base_methods",
40
+ ]
@@ -0,0 +1,287 @@
1
+ """Shared `LunarisClient` layer for the framework adapters.
2
+
3
+ This is the ONE seam every adapter (LangGraph / CrewAI / Letta) talks to, so
4
+ each adapter stays ~tens of LOC and transport-agnostic. Two production
5
+ transports + a test double, all satisfying the same `LunarisClient` Protocol:
6
+
7
+ - `HttpLunarisClient` — talks the MemoryProtocol HTTP verbs on lunaris-server
8
+ (`POST /v1/ingest`, `POST /v1/recall`, `POST /v1/forget`) with a Bearer JWT.
9
+ The partition scope is bound to the JWT on the server side, so it NEVER
10
+ travels on the wire (honors the server's scope discipline).
11
+ - `SdkLunarisClient` — wraps the in-process lunaris-py handle. `lunaris` is
12
+ imported LAZILY so this module (and the whole unit-test layer) loads without
13
+ the compiled cdylib / a backend.
14
+ - `StubLunarisClient` — records calls + returns canned hits; the unit layer
15
+ runs against this with no backend, model, or wheel.
16
+
17
+ Frozen contract: `.add/tasks/sdk-integrations-dx/TASK.md` §3.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+ from typing import Iterable, Protocol, runtime_checkable
24
+
25
+ # RFC 0001 Scope alphabet — must match `lunaris_core::scope::Scope::new`
26
+ # (`[A-Za-z0-9_\-.]{1,128}`). `:` is rejected so an adapter can never mint a
27
+ # scope string that byte-aliases another partition in `lunaris:{scope}:...`.
28
+ _SCOPE_RE = re.compile(r"^[A-Za-z0-9_\-.]{1,128}$")
29
+
30
+
31
+ @dataclass
32
+ class Hit:
33
+ """One recalled item — the adapter-neutral shape every transport returns."""
34
+
35
+ id: str
36
+ content: str
37
+ score: float
38
+ source: str | None = None
39
+
40
+
41
+ # ── Exceptions ────────────────────────────────────────────────────────────────
42
+ class ConfigError(Exception):
43
+ """A transport was constructed without the config it needs to ever work
44
+ (e.g. an HTTP client with no base URL / an SDK client with no handle).
45
+ Raised AT CONSTRUCTION so no request is attempted at first use."""
46
+
47
+
48
+ class InvalidScope(Exception):
49
+ """A namespace mapped to a scope outside the Lunaris alphabet."""
50
+
51
+ def __init__(self, value: object) -> None:
52
+ self.value = value
53
+ super().__init__(f"invalid_scope: {value!r}")
54
+
55
+
56
+ class UnsupportedFrameworkVersion(Exception):
57
+ """The installed framework's base class/shape does not match the pinned
58
+ shape the adapter was written against — fail loud, never mis-map."""
59
+
60
+ def __init__(self, framework: str, found: object, expected: str) -> None:
61
+ self.framework = framework
62
+ self.found = found
63
+ self.expected = expected
64
+ super().__init__(
65
+ f"unsupported {framework} version: found {found!r}, expected {expected}"
66
+ )
67
+
68
+
69
+ # ── Scope mapping ───────────────────────────────────────────────────────────
70
+ def namespace_to_scope(ns: tuple[str, ...] | str) -> str:
71
+ """Map a framework namespace to a validated Lunaris scope string.
72
+
73
+ A tuple namespace (`("u", "mem")`) joins with `.` → `"u.mem"`. The result
74
+ is validated against the Scope alphabet; `:` / bad char / empty / >128
75
+ chars raise `InvalidScope` BEFORE any client call.
76
+ """
77
+ if isinstance(ns, str):
78
+ scope = ns
79
+ else:
80
+ parts = tuple(ns)
81
+ if not parts:
82
+ raise InvalidScope(ns)
83
+ scope = ".".join(str(p) for p in parts)
84
+ if not _SCOPE_RE.match(scope):
85
+ raise InvalidScope(scope)
86
+ return scope
87
+
88
+
89
+ def require_base_methods(
90
+ base_cls: type,
91
+ required: Iterable[str],
92
+ *,
93
+ framework: str,
94
+ found: object,
95
+ expected: str,
96
+ ) -> None:
97
+ """Version-guard: raise `UnsupportedFrameworkVersion` if `base_cls` is
98
+ missing any required method. Called by each adapter at import/instantiation
99
+ so framework drift fails loud instead of silently mis-mapping."""
100
+ missing = sorted(m for m in required if not hasattr(base_cls, m))
101
+ if missing:
102
+ raise UnsupportedFrameworkVersion(framework, found, expected)
103
+
104
+
105
+ # ── The client Protocol ─────────────────────────────────────────────────────
106
+ @runtime_checkable
107
+ class LunarisClient(Protocol):
108
+ """The single interface every adapter depends on. Scope-bound at
109
+ construction; the adapter supplies content + query, never a transport."""
110
+
111
+ scope: str
112
+
113
+ async def ingest(
114
+ self, source: str, content: str, metadata: dict | None = None
115
+ ) -> str: ...
116
+
117
+ async def recall(self, query: str, k: int = 10) -> list[Hit]: ...
118
+
119
+ async def forget_scope(self) -> None: ...
120
+
121
+
122
+ # ── HTTP transport ──────────────────────────────────────────────────────────
123
+ class HttpLunarisClient:
124
+ """MemoryProtocol-over-HTTP client. Scope is JWT-bound server-side and is
125
+ NEVER placed on the wire."""
126
+
127
+ def __init__(
128
+ self, base_url: str, token: str, scope: str, *, timeout: float = 10.0
129
+ ) -> None:
130
+ if not base_url:
131
+ raise ConfigError("HttpLunarisClient requires a base_url")
132
+ if not token:
133
+ raise ConfigError("HttpLunarisClient requires a Bearer token")
134
+ # Validate the scope at the boundary (no `:` byte-aliasing).
135
+ self.scope = namespace_to_scope(scope)
136
+ self._base_url = base_url.rstrip("/")
137
+ # httpx is a CORE dep of lunaris_integrations (not an optional extra).
138
+ import httpx
139
+
140
+ self._client = httpx.AsyncClient(
141
+ base_url=self._base_url,
142
+ headers={"Authorization": f"Bearer {token}"},
143
+ timeout=timeout,
144
+ )
145
+
146
+ async def ingest(
147
+ self, source: str, content: str, metadata: dict | None = None
148
+ ) -> str:
149
+ # `IngestBody.metadata` is a JSON object with `#[serde(default)]` — send
150
+ # `{}` (NEVER null) so `deny_unknown_fields` + the Map typing accept it.
151
+ resp = await self._client.post(
152
+ "/v1/ingest",
153
+ json={"source": source, "content": content, "metadata": metadata or {}},
154
+ )
155
+ resp.raise_for_status()
156
+ return _lsn_to_str(resp.json()["lsn"])
157
+
158
+ async def recall(self, query: str, k: int = 10) -> list[Hit]:
159
+ resp = await self._client.post("/v1/recall", json={"query": query, "k": k})
160
+ resp.raise_for_status()
161
+ # `/v1/recall` returns a BARE JSON array of hits (`Json(Vec<Hit>)`), not
162
+ # an object with a "hits" key.
163
+ return [_hit_from_dict(h) for h in resp.json()]
164
+
165
+ async def forget_scope(self) -> None:
166
+ # Soft-purge the whole JWT-bound scope: `ForgetTarget::Scope` with an
167
+ # empty-prefix `BySource` match (matches every source in the partition).
168
+ # `hard=false` so the D-21 dry_run→confirm 2-step is NOT required; the
169
+ # partition scope itself stays JWT-bound server-side.
170
+ resp = await self._client.post(
171
+ "/v1/forget",
172
+ json={
173
+ "target": {"Scope": {"BySource": ""}},
174
+ "hard": False,
175
+ "dry_run": False,
176
+ },
177
+ )
178
+ resp.raise_for_status()
179
+
180
+ async def aclose(self) -> None:
181
+ await self._client.aclose()
182
+
183
+
184
+ # ── In-process SDK transport ────────────────────────────────────────────────
185
+ class SdkLunarisClient:
186
+ """Wraps the in-process lunaris-py handle (`open(url)`); `lunaris` is
187
+ imported lazily so this module loads without the compiled wheel. The live
188
+ path is exercised by the example + HUMAN-UAT (needs the wheel + a backend
189
+ per the py/ts SDK test caveat)."""
190
+
191
+ def __init__(self, handle: object, scope: str) -> None:
192
+ if handle is None:
193
+ raise ConfigError("SdkLunarisClient requires an open lunaris handle")
194
+ self.scope = namespace_to_scope(scope)
195
+ self._handle = handle
196
+
197
+ def _scoped(self):
198
+ import lunaris # lazy — only the SDK transport needs the cdylib
199
+
200
+ return self._handle.scoped(lunaris.Scope(self.scope))
201
+
202
+ async def ingest(
203
+ self, source: str, content: str, metadata: dict | None = None
204
+ ) -> str:
205
+ import lunaris
206
+
207
+ builder = lunaris.EpisodeBuilder(source, content)
208
+ if metadata:
209
+ builder = builder.metadata(metadata)
210
+ lsn = await self._handle.scoped(lunaris.Scope(self.scope)).ingest(builder)
211
+ return str(lsn)
212
+
213
+ async def recall(self, query: str, k: int = 10) -> list[Hit]:
214
+ # `scoped.recall` returns pythonized hit dicts (scope-threaded). k is
215
+ # applied as a client-side cap; the DSL `.top(k)` path is available via
216
+ # the example for operators who need server-side top-k.
217
+ hits = await self._scoped().recall(query)
218
+ return [_hit_from_dict(h) for h in list(hits)[:k]]
219
+
220
+ async def forget_scope(self) -> None:
221
+ # The in-process lunaris-py binding does not expose scope-forget on
222
+ # ScopedLunaris (only ingest/recall/dsl). Use the HTTP transport for
223
+ # scope-clear / CrewAI reset(), or call the server's /v1/forget.
224
+ raise NotImplementedError(
225
+ "scope-forget is not exposed by the in-process lunaris-py binding; "
226
+ "use HttpLunarisClient for forget_scope / CrewAI reset()"
227
+ )
228
+
229
+
230
+ # ── Test double ───────────────────────────────────────────────────────────-─
231
+ class StubLunarisClient:
232
+ """Records every call + returns canned hits. The unit layer runs against
233
+ this — no backend, model, or wheel."""
234
+
235
+ def __init__(self, scope: str, hits: list[Hit] | None = None) -> None:
236
+ self.scope = scope
237
+ self._hits = hits or []
238
+ self.ingest_calls: list[tuple[str, str, dict | None]] = []
239
+ self.recall_calls: list[tuple[str, int]] = []
240
+ self.forget_calls = 0
241
+
242
+ async def ingest(
243
+ self, source: str, content: str, metadata: dict | None = None
244
+ ) -> str:
245
+ self.ingest_calls.append((source, content, metadata))
246
+ return f"stub-lsn-{len(self.ingest_calls)}"
247
+
248
+ async def recall(self, query: str, k: int = 10) -> list[Hit]:
249
+ self.recall_calls.append((query, k))
250
+ return list(self._hits[:k])
251
+
252
+ async def forget_scope(self) -> None:
253
+ self.forget_calls += 1
254
+
255
+
256
+ def _hit_from_dict(d: dict) -> Hit:
257
+ """Map a `lunaris_retrieve::Hit` JSON object to the adapter-neutral `Hit`.
258
+
259
+ The server/SDK hit carries its body in `text` and its id as a byte array
260
+ (16-byte ULID `Vec<u8>`); `content` is also accepted for forward-compat.
261
+ """
262
+ return Hit(
263
+ id=_decode_id(d.get("id", "")),
264
+ content=str(d.get("content", d.get("text", ""))),
265
+ score=float(d.get("score", 0.0)),
266
+ source=(d.get("source") or None),
267
+ )
268
+
269
+
270
+ def _decode_id(raw: object) -> str:
271
+ """`Hit.id` serializes as a byte array (`Vec<u8>`, 16-byte ULID). Hex-encode
272
+ it to a stable string id; pass a plain string through unchanged."""
273
+ if isinstance(raw, (list, tuple)):
274
+ try:
275
+ return bytes(int(b) & 0xFF for b in raw).hex()
276
+ except (TypeError, ValueError):
277
+ return ""
278
+ return str(raw)
279
+
280
+
281
+ def _lsn_to_str(raw: object) -> str:
282
+ """`IngestResponse.lsn` serializes as `{"wall_ms":u64,"counter":u32}`.
283
+ Format it as the canonical `"{wall_ms}:{counter}"` (matches `Lsn` Display);
284
+ accept a plain string/number too."""
285
+ if isinstance(raw, dict) and "wall_ms" in raw:
286
+ return f"{raw['wall_ms']}:{raw.get('counter', 0)}"
287
+ return str(raw)