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.
- lunaris_integrations-0.8.0/.gitignore +125 -0
- lunaris_integrations-0.8.0/PKG-INFO +92 -0
- lunaris_integrations-0.8.0/README.md +68 -0
- lunaris_integrations-0.8.0/lunaris_integrations/__init__.py +40 -0
- lunaris_integrations-0.8.0/lunaris_integrations/client.py +287 -0
- lunaris_integrations-0.8.0/lunaris_integrations/crewai.py +95 -0
- lunaris_integrations-0.8.0/lunaris_integrations/langgraph.py +167 -0
- lunaris_integrations-0.8.0/lunaris_integrations/letta.py +73 -0
- lunaris_integrations-0.8.0/pyproject.toml +33 -0
- lunaris_integrations-0.8.0/tests/conftest.py +33 -0
- lunaris_integrations-0.8.0/tests/test_client.py +146 -0
- lunaris_integrations-0.8.0/tests/test_crewai.py +41 -0
- lunaris_integrations-0.8.0/tests/test_docs_match_shipped.py +41 -0
- lunaris_integrations-0.8.0/tests/test_langgraph.py +74 -0
- lunaris_integrations-0.8.0/tests/test_letta.py +36 -0
- lunaris_integrations-0.8.0/tests/test_scope.py +49 -0
|
@@ -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)
|