unshadow 0.2.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.
- unshadow-0.2.0/LICENSE +21 -0
- unshadow-0.2.0/PKG-INFO +125 -0
- unshadow-0.2.0/README.md +97 -0
- unshadow-0.2.0/pyproject.toml +37 -0
- unshadow-0.2.0/setup.cfg +4 -0
- unshadow-0.2.0/src/langchain_unshadow/__init__.py +5 -0
- unshadow-0.2.0/src/langchain_unshadow/retriever.py +35 -0
- unshadow-0.2.0/src/llama_index_unshadow/__init__.py +5 -0
- unshadow-0.2.0/src/llama_index_unshadow/retriever.py +32 -0
- unshadow-0.2.0/src/unshadow/__init__.py +11 -0
- unshadow-0.2.0/src/unshadow/bank.py +127 -0
- unshadow-0.2.0/src/unshadow/client.py +96 -0
- unshadow-0.2.0/src/unshadow/documents.py +71 -0
- unshadow-0.2.0/src/unshadow/http.py +100 -0
- unshadow-0.2.0/src/unshadow.egg-info/PKG-INFO +125 -0
- unshadow-0.2.0/src/unshadow.egg-info/SOURCES.txt +21 -0
- unshadow-0.2.0/src/unshadow.egg-info/dependency_links.txt +1 -0
- unshadow-0.2.0/src/unshadow.egg-info/requires.txt +6 -0
- unshadow-0.2.0/src/unshadow.egg-info/top_level.txt +3 -0
- unshadow-0.2.0/tests/test_client.py +83 -0
- unshadow-0.2.0/tests/test_engine.py +92 -0
- unshadow-0.2.0/tests/test_llama.py +49 -0
- unshadow-0.2.0/tests/test_retriever.py +49 -0
unshadow-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unshadow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
unshadow-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unshadow
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for Unshadow Engine and agent banks.
|
|
5
|
+
Author-email: Unshadow <support@unshadow.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://unshadow.dev
|
|
8
|
+
Project-URL: Documentation, https://unshadow.dev/docs/api/sdk
|
|
9
|
+
Project-URL: Repository, https://github.com/unshadow-ai/unshadow
|
|
10
|
+
Project-URL: Issues, https://github.com/unshadow-ai/unshadow/issues
|
|
11
|
+
Keywords: unshadow,memory,langchain,llamaindex,rag
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: langchain
|
|
24
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
25
|
+
Provides-Extra: llamaindex
|
|
26
|
+
Requires-Dist: llama-index-core>=0.11; extra == "llamaindex"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Unshadow (Python)
|
|
30
|
+
|
|
31
|
+
Stdlib client for **Unshadow Engine**, plus an agent-bank client. PyPI name: `unshadow`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install unshadow
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From this repo, before the PyPI upload:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install ./packages/unshadow
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
LangChain is optional:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "./packages/unshadow[langchain]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Engine
|
|
50
|
+
|
|
51
|
+
`Unshadow` matches the TypeScript SDK: `context`, `search`, `extract`, `forget`, `profile`.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from unshadow import Unshadow
|
|
55
|
+
|
|
56
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
57
|
+
packed = unshadow.inject_context("What am I working on?", token_budget=1500)
|
|
58
|
+
hits = unshadow.search("pnpm", limit=8, category="preference")
|
|
59
|
+
unshadow.extract(
|
|
60
|
+
"User prefers pnpm",
|
|
61
|
+
source_type="ai_conversation",
|
|
62
|
+
idempotency_key="turn-1",
|
|
63
|
+
)
|
|
64
|
+
unshadow.forget(["11111111-1111-1111-1111-111111111111"])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`extract(..., idempotency_key=)` sends `Idempotency-Key`. Reads retry once on 429/5xx. `extract` retries only when that key is set. A new fact can supersede, contradict, or coexist with an older one. Search rows include `supersedes` and `superseded_by`. `UnshadowRetriever` copies `id`, `category`, `source_type`, and those lists into document metadata. Filter with `unshadow.search("editor", category="preference")`.
|
|
68
|
+
|
|
69
|
+
### LangChain
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langchain_unshadow import UnshadowRetriever
|
|
73
|
+
from unshadow import Unshadow
|
|
74
|
+
|
|
75
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
76
|
+
retriever = UnshadowRetriever(client=unshadow, k=8, prefer_context=True)
|
|
77
|
+
docs = retriever.invoke("What did we decide about pnpm?")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`prefer_context` uses `POST /context` and splits `[mem:uuid]` lines. This is not `ConversationBufferMemory`.
|
|
81
|
+
|
|
82
|
+
### LlamaIndex
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install "./packages/unshadow[llamaindex]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from llama_index_unshadow import UnshadowRetriever
|
|
90
|
+
from unshadow import Unshadow
|
|
91
|
+
|
|
92
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
93
|
+
retriever = UnshadowRetriever(unshadow, k=8, prefer_context=True)
|
|
94
|
+
nodes = retriever.retrieve("What did we decide about pnpm?")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Agent bank
|
|
98
|
+
|
|
99
|
+
`UnshadowBank` is retain / recall / authorize. Those calls still use `/lattice/*`.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from unshadow import UnshadowBank
|
|
103
|
+
|
|
104
|
+
bank = UnshadowBank(api_key="unshadow_…")
|
|
105
|
+
bank.remember("Ship on Fridays is forbidden.", conversation_key="agent-main")
|
|
106
|
+
packed = bank.context("release policy")
|
|
107
|
+
receipt = bank.authorize(["memory-id"], query="deploy production")
|
|
108
|
+
bank.explain(packed["operation_id"])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Name | HTTP |
|
|
112
|
+
|------|------|
|
|
113
|
+
| `remember` | `POST /lattice/retain` (delta, 32k cap, 409 retry) |
|
|
114
|
+
| `explain` | `POST /lattice/explain` |
|
|
115
|
+
| `authorize` | `POST /lattice/recall` with `purpose: tool_arg` |
|
|
116
|
+
|
|
117
|
+
`retain` is an alias of `remember`. Hermes still uses `packages/lattice-hermes`.
|
|
118
|
+
|
|
119
|
+
The bank client does not ingest files. Capture stores pages and repo notes. Link that project into the agent bank. `remember(..., source_url=...)` cites a URL.
|
|
120
|
+
|
|
121
|
+
## Tests
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m unittest discover -s packages/unshadow/tests
|
|
125
|
+
```
|
unshadow-0.2.0/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Unshadow (Python)
|
|
2
|
+
|
|
3
|
+
Stdlib client for **Unshadow Engine**, plus an agent-bank client. PyPI name: `unshadow`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install unshadow
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
From this repo, before the PyPI upload:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install ./packages/unshadow
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
LangChain is optional:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "./packages/unshadow[langchain]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Engine
|
|
22
|
+
|
|
23
|
+
`Unshadow` matches the TypeScript SDK: `context`, `search`, `extract`, `forget`, `profile`.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from unshadow import Unshadow
|
|
27
|
+
|
|
28
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
29
|
+
packed = unshadow.inject_context("What am I working on?", token_budget=1500)
|
|
30
|
+
hits = unshadow.search("pnpm", limit=8, category="preference")
|
|
31
|
+
unshadow.extract(
|
|
32
|
+
"User prefers pnpm",
|
|
33
|
+
source_type="ai_conversation",
|
|
34
|
+
idempotency_key="turn-1",
|
|
35
|
+
)
|
|
36
|
+
unshadow.forget(["11111111-1111-1111-1111-111111111111"])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`extract(..., idempotency_key=)` sends `Idempotency-Key`. Reads retry once on 429/5xx. `extract` retries only when that key is set. A new fact can supersede, contradict, or coexist with an older one. Search rows include `supersedes` and `superseded_by`. `UnshadowRetriever` copies `id`, `category`, `source_type`, and those lists into document metadata. Filter with `unshadow.search("editor", category="preference")`.
|
|
40
|
+
|
|
41
|
+
### LangChain
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from langchain_unshadow import UnshadowRetriever
|
|
45
|
+
from unshadow import Unshadow
|
|
46
|
+
|
|
47
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
48
|
+
retriever = UnshadowRetriever(client=unshadow, k=8, prefer_context=True)
|
|
49
|
+
docs = retriever.invoke("What did we decide about pnpm?")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`prefer_context` uses `POST /context` and splits `[mem:uuid]` lines. This is not `ConversationBufferMemory`.
|
|
53
|
+
|
|
54
|
+
### LlamaIndex
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install "./packages/unshadow[llamaindex]"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from llama_index_unshadow import UnshadowRetriever
|
|
62
|
+
from unshadow import Unshadow
|
|
63
|
+
|
|
64
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
65
|
+
retriever = UnshadowRetriever(unshadow, k=8, prefer_context=True)
|
|
66
|
+
nodes = retriever.retrieve("What did we decide about pnpm?")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Agent bank
|
|
70
|
+
|
|
71
|
+
`UnshadowBank` is retain / recall / authorize. Those calls still use `/lattice/*`.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from unshadow import UnshadowBank
|
|
75
|
+
|
|
76
|
+
bank = UnshadowBank(api_key="unshadow_…")
|
|
77
|
+
bank.remember("Ship on Fridays is forbidden.", conversation_key="agent-main")
|
|
78
|
+
packed = bank.context("release policy")
|
|
79
|
+
receipt = bank.authorize(["memory-id"], query="deploy production")
|
|
80
|
+
bank.explain(packed["operation_id"])
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Name | HTTP |
|
|
84
|
+
|------|------|
|
|
85
|
+
| `remember` | `POST /lattice/retain` (delta, 32k cap, 409 retry) |
|
|
86
|
+
| `explain` | `POST /lattice/explain` |
|
|
87
|
+
| `authorize` | `POST /lattice/recall` with `purpose: tool_arg` |
|
|
88
|
+
|
|
89
|
+
`retain` is an alias of `remember`. Hermes still uses `packages/lattice-hermes`.
|
|
90
|
+
|
|
91
|
+
The bank client does not ingest files. Capture stores pages and repo notes. Link that project into the agent bank. `remember(..., source_url=...)` cites a URL.
|
|
92
|
+
|
|
93
|
+
## Tests
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python -m unittest discover -s packages/unshadow/tests
|
|
97
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unshadow"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python client for Unshadow Engine and agent banks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Unshadow", email = "support@unshadow.dev" }]
|
|
14
|
+
keywords = ["unshadow", "memory", "langchain", "llamaindex", "rag"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://unshadow.dev"
|
|
28
|
+
Documentation = "https://unshadow.dev/docs/api/sdk"
|
|
29
|
+
Repository = "https://github.com/unshadow-ai/unshadow"
|
|
30
|
+
Issues = "https://github.com/unshadow-ai/unshadow/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
langchain = ["langchain-core>=0.3"]
|
|
34
|
+
llamaindex = ["llama-index-core>=0.11"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
unshadow-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""LangChain retriever over the Unshadow Engine client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
6
|
+
from langchain_core.documents import Document
|
|
7
|
+
from langchain_core.retrievers import BaseRetriever
|
|
8
|
+
|
|
9
|
+
from unshadow.documents import documents_from_context, documents_from_search
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnshadowRetriever(BaseRetriever):
|
|
13
|
+
"""Search Unshadow Engine. ``prefer_context`` uses ``POST /context``."""
|
|
14
|
+
|
|
15
|
+
client: Any
|
|
16
|
+
k: int = 8
|
|
17
|
+
prefer_context: bool = False
|
|
18
|
+
|
|
19
|
+
def _get_relevant_documents(
|
|
20
|
+
self,
|
|
21
|
+
query: str,
|
|
22
|
+
*,
|
|
23
|
+
run_manager: CallbackManagerForRetrieverRun,
|
|
24
|
+
) -> list[Document]:
|
|
25
|
+
del run_manager
|
|
26
|
+
if self.prefer_context:
|
|
27
|
+
body = self.client.context(query, search_limit=self.k)
|
|
28
|
+
rows = documents_from_context(str(body.get("context") or ""), body.get("sources"))
|
|
29
|
+
else:
|
|
30
|
+
body = self.client.search(query, limit=self.k)
|
|
31
|
+
rows = documents_from_search(body)
|
|
32
|
+
return [
|
|
33
|
+
Document(page_content=row["page_content"], metadata=row["metadata"])
|
|
34
|
+
for row in rows[: self.k]
|
|
35
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""LlamaIndex retriever over the Unshadow Engine client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from llama_index.core.retrievers import BaseRetriever
|
|
6
|
+
from llama_index.core.schema import NodeWithScore, QueryBundle, TextNode
|
|
7
|
+
|
|
8
|
+
from unshadow.documents import documents_from_context, documents_from_search
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnshadowRetriever(BaseRetriever):
|
|
12
|
+
"""Search Unshadow Engine. ``prefer_context`` uses ``POST /context``."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: Any, k: int = 8, prefer_context: bool = False, **kwargs: Any) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
self._k = k
|
|
17
|
+
self._prefer_context = prefer_context
|
|
18
|
+
super().__init__(**kwargs)
|
|
19
|
+
|
|
20
|
+
def _retrieve(self, query_bundle: QueryBundle) -> list[NodeWithScore]:
|
|
21
|
+
query = query_bundle.query_str
|
|
22
|
+
if self._prefer_context:
|
|
23
|
+
body = self._client.context(query, search_limit=self._k)
|
|
24
|
+
rows = documents_from_context(str(body.get("context") or ""), body.get("sources"))
|
|
25
|
+
else:
|
|
26
|
+
body = self._client.search(query, limit=self._k)
|
|
27
|
+
rows = documents_from_search(body)
|
|
28
|
+
nodes: list[NodeWithScore] = []
|
|
29
|
+
for row in rows[: self._k]:
|
|
30
|
+
node = TextNode(text=row["page_content"], metadata=row["metadata"])
|
|
31
|
+
nodes.append(NodeWithScore(node=node, score=1.0))
|
|
32
|
+
return nodes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public Unshadow Python clients.
|
|
2
|
+
|
|
3
|
+
``Unshadow`` is Engine HTTP (``/context``, ``/search``, ``/extract``, ``/forget-memories``, ``/profile``).
|
|
4
|
+
``UnshadowBank`` is the agent bank (``/lattice/*``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from unshadow.bank import UnshadowBank
|
|
8
|
+
from unshadow.client import Unshadow
|
|
9
|
+
from unshadow.http import UnshadowHttpError
|
|
10
|
+
|
|
11
|
+
__all__ = ["Unshadow", "UnshadowBank", "UnshadowHttpError"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Agent-bank client. HTTP paths stay ``/lattice/*``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from unshadow.http import UnshadowHttp, UnshadowHttpError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RETAIN_MAX_CHARS = 32_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnshadowBank(UnshadowHttp):
|
|
14
|
+
def remember(
|
|
15
|
+
self,
|
|
16
|
+
text: str,
|
|
17
|
+
*,
|
|
18
|
+
conversation_key: str = "default",
|
|
19
|
+
prev_hash: str | None = None,
|
|
20
|
+
source_type: str = "manual_entry",
|
|
21
|
+
source_url: str | None = None,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Delta retain. Do not pass file contents; cite ``source_url`` and link Capture."""
|
|
24
|
+
clipped = text if len(text) <= RETAIN_MAX_CHARS else text[: RETAIN_MAX_CHARS - 1]
|
|
25
|
+
body: dict[str, Any] = {
|
|
26
|
+
"text": clipped,
|
|
27
|
+
"conversation_key": conversation_key,
|
|
28
|
+
"source_type": source_type,
|
|
29
|
+
}
|
|
30
|
+
if prev_hash:
|
|
31
|
+
body["prev_hash"] = prev_hash
|
|
32
|
+
if source_url:
|
|
33
|
+
body["source_url"] = source_url
|
|
34
|
+
try:
|
|
35
|
+
return self.post("/lattice/retain", body)
|
|
36
|
+
except UnshadowHttpError as exc:
|
|
37
|
+
if exc.status != 409:
|
|
38
|
+
raise
|
|
39
|
+
err = exc.body.get("error") if isinstance(exc.body, Mapping) else None
|
|
40
|
+
expected = err.get("expected_hash") if isinstance(err, Mapping) else None
|
|
41
|
+
if not expected or expected == prev_hash:
|
|
42
|
+
raise
|
|
43
|
+
body["prev_hash"] = expected
|
|
44
|
+
return self.post("/lattice/retain", body)
|
|
45
|
+
|
|
46
|
+
def retain(
|
|
47
|
+
self,
|
|
48
|
+
text: str,
|
|
49
|
+
*,
|
|
50
|
+
conversation_key: str = "default",
|
|
51
|
+
prev_hash: str | None = None,
|
|
52
|
+
source_type: str = "manual_entry",
|
|
53
|
+
source_url: str | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
return self.remember(
|
|
56
|
+
text,
|
|
57
|
+
conversation_key=conversation_key,
|
|
58
|
+
prev_hash=prev_hash,
|
|
59
|
+
source_type=source_type,
|
|
60
|
+
source_url=source_url,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def recall(
|
|
64
|
+
self,
|
|
65
|
+
query: str = "",
|
|
66
|
+
*,
|
|
67
|
+
purpose: str = "answer",
|
|
68
|
+
memory_ids: Sequence[str] | None = None,
|
|
69
|
+
limit: int | None = None,
|
|
70
|
+
trust: str | None = None,
|
|
71
|
+
token_budget: int | None = None,
|
|
72
|
+
format: str | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
body: dict[str, Any] = {"query": query, "purpose": purpose}
|
|
75
|
+
if memory_ids is not None:
|
|
76
|
+
body["memory_ids"] = list(memory_ids)
|
|
77
|
+
if limit is not None:
|
|
78
|
+
body["limit"] = limit
|
|
79
|
+
if trust is not None:
|
|
80
|
+
body["trust"] = trust
|
|
81
|
+
if token_budget is not None:
|
|
82
|
+
body["token_budget"] = token_budget
|
|
83
|
+
if format is not None:
|
|
84
|
+
body["format"] = format
|
|
85
|
+
return self.post("/lattice/recall", body)
|
|
86
|
+
|
|
87
|
+
def context(
|
|
88
|
+
self,
|
|
89
|
+
query: str,
|
|
90
|
+
*,
|
|
91
|
+
trust: str = "advisory",
|
|
92
|
+
token_budget: int = 1200,
|
|
93
|
+
purpose: str = "answer",
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
return self.post(
|
|
96
|
+
"/lattice/context",
|
|
97
|
+
{
|
|
98
|
+
"query": query,
|
|
99
|
+
"trust": trust,
|
|
100
|
+
"token_budget": token_budget,
|
|
101
|
+
"purpose": purpose,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def authorize(
|
|
106
|
+
self,
|
|
107
|
+
memory_ids: Sequence[str],
|
|
108
|
+
*,
|
|
109
|
+
query: str = "",
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Fail-closed tool grounding. ``purpose`` is always ``tool_arg``."""
|
|
112
|
+
return self.recall(query, purpose="tool_arg", memory_ids=list(memory_ids))
|
|
113
|
+
|
|
114
|
+
def explain(self, operation_id: str) -> dict[str, Any]:
|
|
115
|
+
return self.post("/lattice/explain", {"operation_id": operation_id})
|
|
116
|
+
|
|
117
|
+
def reflect(self, query: str, **kwargs: Any) -> dict[str, Any]:
|
|
118
|
+
return self.post("/lattice/reflect", {"query": query, **kwargs})
|
|
119
|
+
|
|
120
|
+
def feedback(self, **kwargs: Any) -> dict[str, Any]:
|
|
121
|
+
return self.post("/lattice/feedback", kwargs)
|
|
122
|
+
|
|
123
|
+
def correct(self, memory_ids: Sequence[str]) -> dict[str, Any]:
|
|
124
|
+
return self.post("/lattice/correct", {"memory_ids": list(memory_ids)})
|
|
125
|
+
|
|
126
|
+
def forget(self, memory_ids: Sequence[str]) -> dict[str, Any]:
|
|
127
|
+
return self.post("/lattice/forget", {"memory_ids": list(memory_ids)})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Unshadow Engine client. Same HTTP as the TypeScript SDK and hosted MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Sequence
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from unshadow.http import UnshadowHttp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Unshadow(UnshadowHttp):
|
|
12
|
+
def context(
|
|
13
|
+
self,
|
|
14
|
+
query: str | None = None,
|
|
15
|
+
*,
|
|
16
|
+
token_budget: int | None = None,
|
|
17
|
+
search_mode: str | None = None,
|
|
18
|
+
search_limit: int | None = None,
|
|
19
|
+
since: str | None = None,
|
|
20
|
+
until: str | None = None,
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
body: dict[str, Any] = {}
|
|
23
|
+
if query is not None:
|
|
24
|
+
body["query"] = query
|
|
25
|
+
if token_budget is not None:
|
|
26
|
+
body["token_budget"] = token_budget
|
|
27
|
+
if search_mode is not None:
|
|
28
|
+
body["search_mode"] = search_mode
|
|
29
|
+
if search_limit is not None:
|
|
30
|
+
body["search_limit"] = search_limit
|
|
31
|
+
if since is not None:
|
|
32
|
+
body["since"] = since
|
|
33
|
+
if until is not None:
|
|
34
|
+
body["until"] = until
|
|
35
|
+
return self.post("/context", body, retry=True)
|
|
36
|
+
|
|
37
|
+
def inject_context(self, query: str, **kwargs: Any) -> dict[str, Any]:
|
|
38
|
+
"""Alias of ``context`` for prompt injection."""
|
|
39
|
+
return self.context(query, **kwargs)
|
|
40
|
+
|
|
41
|
+
def search(
|
|
42
|
+
self,
|
|
43
|
+
query: str,
|
|
44
|
+
*,
|
|
45
|
+
limit: int | None = None,
|
|
46
|
+
mode: str | None = None,
|
|
47
|
+
category: str | None = None,
|
|
48
|
+
pool: int | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
params: dict[str, str] = {"query": query}
|
|
51
|
+
if limit is not None:
|
|
52
|
+
params["limit"] = str(limit)
|
|
53
|
+
if mode is not None:
|
|
54
|
+
params["mode"] = mode
|
|
55
|
+
if category is not None:
|
|
56
|
+
params["category"] = category
|
|
57
|
+
if pool is not None:
|
|
58
|
+
params["pool"] = str(pool)
|
|
59
|
+
return self.get(f"/search?{urlencode(params)}", retry=True)
|
|
60
|
+
|
|
61
|
+
def extract(
|
|
62
|
+
self,
|
|
63
|
+
text: str,
|
|
64
|
+
*,
|
|
65
|
+
source_type: str | None = None,
|
|
66
|
+
source_url: str | None = None,
|
|
67
|
+
conversation_id: str | None = None,
|
|
68
|
+
sandbox_id: str | None = None,
|
|
69
|
+
idempotency_key: str | None = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
body: dict[str, Any] = {"text": text}
|
|
72
|
+
if source_type is not None:
|
|
73
|
+
body["source_type"] = source_type
|
|
74
|
+
if source_url is not None:
|
|
75
|
+
body["source_url"] = source_url
|
|
76
|
+
if conversation_id is not None:
|
|
77
|
+
body["conversation_id"] = conversation_id
|
|
78
|
+
if sandbox_id is not None:
|
|
79
|
+
body["sandbox_id"] = sandbox_id
|
|
80
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
81
|
+
return self.post("/extract", body, headers=headers, retry=bool(idempotency_key))
|
|
82
|
+
|
|
83
|
+
def profile(self) -> dict[str, Any]:
|
|
84
|
+
return self.get("/profile", retry=True)
|
|
85
|
+
|
|
86
|
+
def forget(
|
|
87
|
+
self,
|
|
88
|
+
memory_ids: Sequence[str] | None = None,
|
|
89
|
+
*,
|
|
90
|
+
forget_all: bool = False,
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
if forget_all:
|
|
93
|
+
body: dict[str, Any] = {"forget_all": True}
|
|
94
|
+
else:
|
|
95
|
+
body = {"memory_ids": list(memory_ids or [])}
|
|
96
|
+
return self.post("/forget-memories", body)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Turn Engine search and context payloads into retriever documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_MEM = re.compile(r"\[mem:([0-9a-fA-F-]{8,})\]")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _fact(row: Any) -> tuple[str, dict[str, Any]] | None:
|
|
13
|
+
if not isinstance(row, Mapping):
|
|
14
|
+
return None
|
|
15
|
+
text = row.get("fact") or row.get("content") or row.get("statement") or row.get("text") or ""
|
|
16
|
+
if not isinstance(text, str) or not text.strip():
|
|
17
|
+
return None
|
|
18
|
+
metadata: dict[str, Any] = {}
|
|
19
|
+
for key in ("id", "memory_id", "category", "source_url", "source_type", "created_at", "supersedes", "superseded_by"):
|
|
20
|
+
if row.get(key) is not None:
|
|
21
|
+
metadata["id" if key == "memory_id" else key] = row[key]
|
|
22
|
+
return text, metadata
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def documents_from_search(body: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
26
|
+
memories = body.get("memories") if isinstance(body.get("memories"), list) else []
|
|
27
|
+
docs = []
|
|
28
|
+
for row in memories:
|
|
29
|
+
parsed = _fact(row)
|
|
30
|
+
if parsed is None:
|
|
31
|
+
continue
|
|
32
|
+
page, metadata = parsed
|
|
33
|
+
docs.append({"page_content": page, "metadata": metadata})
|
|
34
|
+
if docs:
|
|
35
|
+
return docs
|
|
36
|
+
facts = body.get("facts") if isinstance(body.get("facts"), list) else []
|
|
37
|
+
return [
|
|
38
|
+
{"page_content": fact, "metadata": {}}
|
|
39
|
+
for fact in facts
|
|
40
|
+
if isinstance(fact, str) and fact.strip()
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def documents_from_context(context: str, sources: list[Mapping[str, Any]] | None = None) -> list[dict[str, Any]]:
|
|
45
|
+
rows = sources if isinstance(sources, list) else []
|
|
46
|
+
by_id: dict[str, Mapping[str, Any]] = {}
|
|
47
|
+
for row in rows:
|
|
48
|
+
if not isinstance(row, Mapping):
|
|
49
|
+
continue
|
|
50
|
+
memory_id = row.get("memory_id") or row.get("id")
|
|
51
|
+
if isinstance(memory_id, str) and memory_id:
|
|
52
|
+
by_id[memory_id] = row
|
|
53
|
+
docs: list[dict[str, Any]] = []
|
|
54
|
+
for chunk in re.split(r"\n{2,}|\n", context):
|
|
55
|
+
piece = chunk.strip()
|
|
56
|
+
if not piece:
|
|
57
|
+
continue
|
|
58
|
+
ids = _MEM.findall(piece)
|
|
59
|
+
page = _MEM.sub("", piece).strip()
|
|
60
|
+
if not page:
|
|
61
|
+
continue
|
|
62
|
+
metadata: dict[str, Any] = {"id": ids[0]} if ids else {}
|
|
63
|
+
source = by_id.get(ids[0]) if ids else None
|
|
64
|
+
if source:
|
|
65
|
+
for key in ("category", "source_type", "source_url", "supersedes", "superseded_by"):
|
|
66
|
+
if source.get(key) is not None:
|
|
67
|
+
metadata[key] = source[key]
|
|
68
|
+
docs.append({"page_content": page, "metadata": metadata})
|
|
69
|
+
if not docs and context.strip():
|
|
70
|
+
return [{"page_content": context.strip(), "metadata": {}}]
|
|
71
|
+
return docs
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Shared HTTP for the Unshadow Python clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_API_URL = "https://api.unshadow.dev/v1"
|
|
13
|
+
USER_AGENT = "unshadow-python/0.2.0"
|
|
14
|
+
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UnshadowHttpError(Exception):
|
|
18
|
+
def __init__(self, status: int, body: Mapping[str, Any] | str) -> None:
|
|
19
|
+
self.status = status
|
|
20
|
+
self.body = body
|
|
21
|
+
super().__init__(f"Unshadow HTTP {status}: {body}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_body(raw: bytes) -> dict[str, Any]:
|
|
25
|
+
if not raw:
|
|
26
|
+
return {}
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(raw.decode("utf-8"))
|
|
29
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
30
|
+
return {"raw": raw.decode("utf-8", errors="replace")}
|
|
31
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnshadowHttp:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = DEFAULT_API_URL,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.api_key = api_key.strip()
|
|
43
|
+
self.base_url = (base_url or DEFAULT_API_URL).rstrip("/")
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
|
|
46
|
+
def request(
|
|
47
|
+
self,
|
|
48
|
+
method: str,
|
|
49
|
+
path: str,
|
|
50
|
+
payload: Mapping[str, Any] | None = None,
|
|
51
|
+
*,
|
|
52
|
+
headers: Mapping[str, str] | None = None,
|
|
53
|
+
retry: bool = False,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
try:
|
|
56
|
+
return self._once(method, path, payload, headers)
|
|
57
|
+
except UnshadowHttpError as exc:
|
|
58
|
+
if not retry or exc.status not in RETRY_STATUSES:
|
|
59
|
+
raise
|
|
60
|
+
time.sleep(0.2)
|
|
61
|
+
return self._once(method, path, payload, headers)
|
|
62
|
+
|
|
63
|
+
def _once(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
payload: Mapping[str, Any] | None,
|
|
68
|
+
headers: Mapping[str, str] | None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
url = f"{self.base_url}{path if path.startswith('/') else '/' + path}"
|
|
71
|
+
data = None if payload is None else json.dumps(dict(payload)).encode("utf-8")
|
|
72
|
+
req_headers = {
|
|
73
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
74
|
+
"Accept": "application/json",
|
|
75
|
+
"User-Agent": USER_AGENT,
|
|
76
|
+
}
|
|
77
|
+
if data is not None:
|
|
78
|
+
req_headers["Content-Type"] = "application/json"
|
|
79
|
+
if headers:
|
|
80
|
+
req_headers.update(dict(headers))
|
|
81
|
+
req = urllib.request.Request(url, data=data, method=method.upper(), headers=req_headers)
|
|
82
|
+
try:
|
|
83
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
84
|
+
return _parse_body(resp.read())
|
|
85
|
+
except urllib.error.HTTPError as exc:
|
|
86
|
+
body = _parse_body(exc.read() if exc.fp else b"")
|
|
87
|
+
raise UnshadowHttpError(int(exc.code), body) from exc
|
|
88
|
+
|
|
89
|
+
def post(
|
|
90
|
+
self,
|
|
91
|
+
path: str,
|
|
92
|
+
payload: Mapping[str, Any],
|
|
93
|
+
*,
|
|
94
|
+
headers: Mapping[str, str] | None = None,
|
|
95
|
+
retry: bool = False,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
return self.request("POST", path, payload, headers=headers, retry=retry)
|
|
98
|
+
|
|
99
|
+
def get(self, path: str, *, retry: bool = False) -> dict[str, Any]:
|
|
100
|
+
return self.request("GET", path, retry=retry)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unshadow
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for Unshadow Engine and agent banks.
|
|
5
|
+
Author-email: Unshadow <support@unshadow.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://unshadow.dev
|
|
8
|
+
Project-URL: Documentation, https://unshadow.dev/docs/api/sdk
|
|
9
|
+
Project-URL: Repository, https://github.com/unshadow-ai/unshadow
|
|
10
|
+
Project-URL: Issues, https://github.com/unshadow-ai/unshadow/issues
|
|
11
|
+
Keywords: unshadow,memory,langchain,llamaindex,rag
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: langchain
|
|
24
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
25
|
+
Provides-Extra: llamaindex
|
|
26
|
+
Requires-Dist: llama-index-core>=0.11; extra == "llamaindex"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Unshadow (Python)
|
|
30
|
+
|
|
31
|
+
Stdlib client for **Unshadow Engine**, plus an agent-bank client. PyPI name: `unshadow`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install unshadow
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From this repo, before the PyPI upload:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install ./packages/unshadow
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
LangChain is optional:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "./packages/unshadow[langchain]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Engine
|
|
50
|
+
|
|
51
|
+
`Unshadow` matches the TypeScript SDK: `context`, `search`, `extract`, `forget`, `profile`.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from unshadow import Unshadow
|
|
55
|
+
|
|
56
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
57
|
+
packed = unshadow.inject_context("What am I working on?", token_budget=1500)
|
|
58
|
+
hits = unshadow.search("pnpm", limit=8, category="preference")
|
|
59
|
+
unshadow.extract(
|
|
60
|
+
"User prefers pnpm",
|
|
61
|
+
source_type="ai_conversation",
|
|
62
|
+
idempotency_key="turn-1",
|
|
63
|
+
)
|
|
64
|
+
unshadow.forget(["11111111-1111-1111-1111-111111111111"])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`extract(..., idempotency_key=)` sends `Idempotency-Key`. Reads retry once on 429/5xx. `extract` retries only when that key is set. A new fact can supersede, contradict, or coexist with an older one. Search rows include `supersedes` and `superseded_by`. `UnshadowRetriever` copies `id`, `category`, `source_type`, and those lists into document metadata. Filter with `unshadow.search("editor", category="preference")`.
|
|
68
|
+
|
|
69
|
+
### LangChain
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from langchain_unshadow import UnshadowRetriever
|
|
73
|
+
from unshadow import Unshadow
|
|
74
|
+
|
|
75
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
76
|
+
retriever = UnshadowRetriever(client=unshadow, k=8, prefer_context=True)
|
|
77
|
+
docs = retriever.invoke("What did we decide about pnpm?")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`prefer_context` uses `POST /context` and splits `[mem:uuid]` lines. This is not `ConversationBufferMemory`.
|
|
81
|
+
|
|
82
|
+
### LlamaIndex
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install "./packages/unshadow[llamaindex]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from llama_index_unshadow import UnshadowRetriever
|
|
90
|
+
from unshadow import Unshadow
|
|
91
|
+
|
|
92
|
+
unshadow = Unshadow(api_key="unshadow_…")
|
|
93
|
+
retriever = UnshadowRetriever(unshadow, k=8, prefer_context=True)
|
|
94
|
+
nodes = retriever.retrieve("What did we decide about pnpm?")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Agent bank
|
|
98
|
+
|
|
99
|
+
`UnshadowBank` is retain / recall / authorize. Those calls still use `/lattice/*`.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from unshadow import UnshadowBank
|
|
103
|
+
|
|
104
|
+
bank = UnshadowBank(api_key="unshadow_…")
|
|
105
|
+
bank.remember("Ship on Fridays is forbidden.", conversation_key="agent-main")
|
|
106
|
+
packed = bank.context("release policy")
|
|
107
|
+
receipt = bank.authorize(["memory-id"], query="deploy production")
|
|
108
|
+
bank.explain(packed["operation_id"])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Name | HTTP |
|
|
112
|
+
|------|------|
|
|
113
|
+
| `remember` | `POST /lattice/retain` (delta, 32k cap, 409 retry) |
|
|
114
|
+
| `explain` | `POST /lattice/explain` |
|
|
115
|
+
| `authorize` | `POST /lattice/recall` with `purpose: tool_arg` |
|
|
116
|
+
|
|
117
|
+
`retain` is an alias of `remember`. Hermes still uses `packages/lattice-hermes`.
|
|
118
|
+
|
|
119
|
+
The bank client does not ingest files. Capture stores pages and repo notes. Link that project into the agent bank. `remember(..., source_url=...)` cites a URL.
|
|
120
|
+
|
|
121
|
+
## Tests
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
python -m unittest discover -s packages/unshadow/tests
|
|
125
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/langchain_unshadow/__init__.py
|
|
5
|
+
src/langchain_unshadow/retriever.py
|
|
6
|
+
src/llama_index_unshadow/__init__.py
|
|
7
|
+
src/llama_index_unshadow/retriever.py
|
|
8
|
+
src/unshadow/__init__.py
|
|
9
|
+
src/unshadow/bank.py
|
|
10
|
+
src/unshadow/client.py
|
|
11
|
+
src/unshadow/documents.py
|
|
12
|
+
src/unshadow/http.py
|
|
13
|
+
src/unshadow.egg-info/PKG-INFO
|
|
14
|
+
src/unshadow.egg-info/SOURCES.txt
|
|
15
|
+
src/unshadow.egg-info/dependency_links.txt
|
|
16
|
+
src/unshadow.egg-info/requires.txt
|
|
17
|
+
src/unshadow.egg-info/top_level.txt
|
|
18
|
+
tests/test_client.py
|
|
19
|
+
tests/test_engine.py
|
|
20
|
+
tests/test_llama.py
|
|
21
|
+
tests/test_retriever.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Public Unshadow Python client tests (no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1] / "src"
|
|
11
|
+
if str(ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
|
|
14
|
+
from unshadow.bank import UnshadowBank
|
|
15
|
+
from unshadow.http import UnshadowHttpError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RememberExplainAuthorizeTests(unittest.TestCase):
|
|
19
|
+
def test_remember_posts_retain_with_source_url(self) -> None:
|
|
20
|
+
client = UnshadowBank("unshadow_test", base_url="https://api.example/v1")
|
|
21
|
+
with patch.object(client, "post", return_value={"checkpoint": {"hash": "h"}}) as post:
|
|
22
|
+
client.remember(
|
|
23
|
+
"Design decision lives in the spec",
|
|
24
|
+
conversation_key="agent",
|
|
25
|
+
source_url="https://capture.example/doc",
|
|
26
|
+
source_type="link",
|
|
27
|
+
)
|
|
28
|
+
post.assert_called_once_with(
|
|
29
|
+
"/lattice/retain",
|
|
30
|
+
{
|
|
31
|
+
"text": "Design decision lives in the spec",
|
|
32
|
+
"conversation_key": "agent",
|
|
33
|
+
"source_type": "link",
|
|
34
|
+
"source_url": "https://capture.example/doc",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def test_remember_retries_on_checkpoint_mismatch(self) -> None:
|
|
39
|
+
client = UnshadowBank("unshadow_test")
|
|
40
|
+
conflict = UnshadowHttpError(409, {"error": {"expected_hash": "abc", "code": "conflict"}})
|
|
41
|
+
with patch.object(
|
|
42
|
+
client,
|
|
43
|
+
"post",
|
|
44
|
+
side_effect=[conflict, {"checkpoint": {"hash": "abc"}}],
|
|
45
|
+
) as post:
|
|
46
|
+
out = client.remember("delta", conversation_key="s1", prev_hash="stale")
|
|
47
|
+
self.assertEqual(out["checkpoint"]["hash"], "abc")
|
|
48
|
+
self.assertEqual(post.call_count, 2)
|
|
49
|
+
self.assertEqual(post.call_args_list[1].args[1]["prev_hash"], "abc")
|
|
50
|
+
|
|
51
|
+
def test_authorize_forces_tool_arg(self) -> None:
|
|
52
|
+
client = UnshadowBank("unshadow_test")
|
|
53
|
+
with patch.object(
|
|
54
|
+
client,
|
|
55
|
+
"post",
|
|
56
|
+
return_value={"grounding_ok": False, "omitted": [{"reason": "stale"}]},
|
|
57
|
+
) as post:
|
|
58
|
+
out = client.authorize(["m1"], query="run deploy")
|
|
59
|
+
self.assertFalse(out["grounding_ok"])
|
|
60
|
+
post.assert_called_once_with(
|
|
61
|
+
"/lattice/recall",
|
|
62
|
+
{
|
|
63
|
+
"query": "run deploy",
|
|
64
|
+
"purpose": "tool_arg",
|
|
65
|
+
"memory_ids": ["m1"],
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def test_explain_posts_operation_id(self) -> None:
|
|
70
|
+
client = UnshadowBank("unshadow_test")
|
|
71
|
+
with patch.object(client, "post", return_value={"events": []}) as post:
|
|
72
|
+
client.explain("op-1")
|
|
73
|
+
post.assert_called_once_with("/lattice/explain", {"operation_id": "op-1"})
|
|
74
|
+
|
|
75
|
+
def test_retain_is_remember_alias(self) -> None:
|
|
76
|
+
client = UnshadowBank("k")
|
|
77
|
+
with patch.object(client, "remember", return_value={"ok": True}) as remember:
|
|
78
|
+
client.retain("x", conversation_key="c")
|
|
79
|
+
remember.assert_called_once()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
unittest.main()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Engine client tests (no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1] / "src"
|
|
11
|
+
if str(ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
|
|
14
|
+
from unshadow.client import Unshadow
|
|
15
|
+
from unshadow.documents import documents_from_context, documents_from_search
|
|
16
|
+
from unshadow.http import UnshadowHttpError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EngineTests(unittest.TestCase):
|
|
20
|
+
def test_search_category_and_context(self) -> None:
|
|
21
|
+
client = Unshadow("unshadow_test", base_url="https://api.example/v1")
|
|
22
|
+
with patch.object(client, "request", return_value={"memories": []}) as request:
|
|
23
|
+
client.search("pnpm", limit=8, category="preference")
|
|
24
|
+
client.context("task", token_budget=1500)
|
|
25
|
+
self.assertEqual(request.call_args_list[0].args[0], "GET")
|
|
26
|
+
self.assertIn("category=preference", request.call_args_list[0].args[1])
|
|
27
|
+
self.assertTrue(request.call_args_list[0].kwargs["retry"])
|
|
28
|
+
self.assertEqual(request.call_args_list[1].args[:2], ("POST", "/context"))
|
|
29
|
+
|
|
30
|
+
def test_extract_sends_idempotency_header_not_body(self) -> None:
|
|
31
|
+
client = Unshadow("k", base_url="https://api.example/v1")
|
|
32
|
+
with patch.object(client, "request", return_value={"stored": 1}) as request:
|
|
33
|
+
client.extract("User prefers pnpm", source_type="ai_conversation", idempotency_key="turn-1")
|
|
34
|
+
args, kwargs = request.call_args
|
|
35
|
+
self.assertEqual(args[:2], ("POST", "/extract"))
|
|
36
|
+
self.assertNotIn("idempotency_key", args[2])
|
|
37
|
+
self.assertEqual(kwargs["headers"]["Idempotency-Key"], "turn-1")
|
|
38
|
+
self.assertTrue(kwargs["retry"])
|
|
39
|
+
|
|
40
|
+
def test_forget_posts_memory_ids(self) -> None:
|
|
41
|
+
client = Unshadow("k")
|
|
42
|
+
with patch.object(client, "post", return_value={"deleted": 1}) as post:
|
|
43
|
+
client.forget(["m1"])
|
|
44
|
+
post.assert_called_once_with("/forget-memories", {"memory_ids": ["m1"]})
|
|
45
|
+
|
|
46
|
+
def test_search_retries_once(self) -> None:
|
|
47
|
+
client = Unshadow("k", base_url="https://api.example/v1")
|
|
48
|
+
with patch.object(
|
|
49
|
+
client,
|
|
50
|
+
"_once",
|
|
51
|
+
side_effect=[UnshadowHttpError(429, "slow"), {"memories": []}],
|
|
52
|
+
) as once:
|
|
53
|
+
out = client.get("/search?query=a", retry=True)
|
|
54
|
+
self.assertEqual(out, {"memories": []})
|
|
55
|
+
self.assertEqual(once.call_count, 2)
|
|
56
|
+
|
|
57
|
+
def test_documents_from_search_and_context(self) -> None:
|
|
58
|
+
docs = documents_from_search(
|
|
59
|
+
{
|
|
60
|
+
"memories": [
|
|
61
|
+
{
|
|
62
|
+
"id": "m1",
|
|
63
|
+
"fact": "User prefers pnpm",
|
|
64
|
+
"category": "preference",
|
|
65
|
+
"source_type": "ai_conversation",
|
|
66
|
+
"supersedes": ["old"],
|
|
67
|
+
"superseded_by": [],
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(docs[0]["page_content"], "User prefers pnpm")
|
|
73
|
+
self.assertEqual(docs[0]["metadata"]["id"], "m1")
|
|
74
|
+
self.assertEqual(docs[0]["metadata"]["category"], "preference")
|
|
75
|
+
self.assertEqual(docs[0]["metadata"]["source_type"], "ai_conversation")
|
|
76
|
+
self.assertEqual(docs[0]["metadata"]["supersedes"], ["old"])
|
|
77
|
+
cited = documents_from_context(
|
|
78
|
+
"User prefers pnpm [mem:11111111-1111-1111-1111-111111111111]",
|
|
79
|
+
[
|
|
80
|
+
{
|
|
81
|
+
"memory_id": "11111111-1111-1111-1111-111111111111",
|
|
82
|
+
"category": "preference",
|
|
83
|
+
"superseded_by": [],
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
self.assertEqual(cited[0]["metadata"]["id"], "11111111-1111-1111-1111-111111111111")
|
|
88
|
+
self.assertEqual(cited[0]["metadata"]["category"], "preference")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
unittest.main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""LlamaIndex retriever tests. Skipped when llama-index-core is not installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1] / "src"
|
|
11
|
+
if str(ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from llama_index.core.schema import QueryBundle
|
|
16
|
+
from llama_index_unshadow import UnshadowRetriever
|
|
17
|
+
except ImportError:
|
|
18
|
+
QueryBundle = None # type: ignore[misc, assignment]
|
|
19
|
+
UnshadowRetriever = None # type: ignore[misc, assignment]
|
|
20
|
+
|
|
21
|
+
from unshadow.client import Unshadow
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@unittest.skipIf(UnshadowRetriever is None, "llama-index-core is not installed")
|
|
25
|
+
class LlamaRetrieverTests(unittest.TestCase):
|
|
26
|
+
def test_retriever_searches_engine(self) -> None:
|
|
27
|
+
client = Unshadow("k", base_url="https://api.example/v1")
|
|
28
|
+
with patch.object(
|
|
29
|
+
client,
|
|
30
|
+
"search",
|
|
31
|
+
return_value={
|
|
32
|
+
"memories": [
|
|
33
|
+
{
|
|
34
|
+
"id": "m1",
|
|
35
|
+
"fact": "User prefers pnpm",
|
|
36
|
+
"category": "preference",
|
|
37
|
+
"source_type": "ai_conversation",
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
) as search:
|
|
42
|
+
nodes = UnshadowRetriever(client=client, k=4).retrieve(QueryBundle(query_str="package manager"))
|
|
43
|
+
search.assert_called_once_with("package manager", limit=4)
|
|
44
|
+
self.assertEqual(nodes[0].node.get_content(), "User prefers pnpm")
|
|
45
|
+
self.assertEqual(nodes[0].node.metadata["category"], "preference")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
unittest.main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""LangChain retriever tests. Skipped when langchain-core is not installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1] / "src"
|
|
11
|
+
if str(ROOT) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(ROOT))
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from langchain_unshadow import UnshadowRetriever
|
|
16
|
+
except ImportError:
|
|
17
|
+
UnshadowRetriever = None # type: ignore[misc, assignment]
|
|
18
|
+
|
|
19
|
+
from unshadow.client import Unshadow
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@unittest.skipIf(UnshadowRetriever is None, "langchain-core is not installed")
|
|
23
|
+
class RetrieverTests(unittest.TestCase):
|
|
24
|
+
def test_retriever_searches_engine(self) -> None:
|
|
25
|
+
client = Unshadow("k", base_url="https://api.example/v1")
|
|
26
|
+
with patch.object(
|
|
27
|
+
client,
|
|
28
|
+
"search",
|
|
29
|
+
return_value={"memories": [{"id": "m1", "fact": "User prefers pnpm", "category": "preference"}]},
|
|
30
|
+
) as search:
|
|
31
|
+
docs = UnshadowRetriever(client=client, k=4).invoke("package manager")
|
|
32
|
+
search.assert_called_once_with("package manager", limit=4)
|
|
33
|
+
self.assertEqual(docs[0].page_content, "User prefers pnpm")
|
|
34
|
+
self.assertEqual(docs[0].metadata["category"], "preference")
|
|
35
|
+
|
|
36
|
+
def test_prefer_context(self) -> None:
|
|
37
|
+
client = Unshadow("k")
|
|
38
|
+
with patch.object(
|
|
39
|
+
client,
|
|
40
|
+
"context",
|
|
41
|
+
return_value={"context": "pnpm [mem:11111111-1111-1111-1111-111111111111]"},
|
|
42
|
+
):
|
|
43
|
+
docs = UnshadowRetriever(client=client, prefer_context=True).invoke("tooling")
|
|
44
|
+
self.assertEqual(docs[0].page_content, "pnpm")
|
|
45
|
+
self.assertEqual(docs[0].metadata["id"], "11111111-1111-1111-1111-111111111111")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
unittest.main()
|