unshadow 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_unshadow/__init__.py +5 -0
- langchain_unshadow/retriever.py +35 -0
- llama_index_unshadow/__init__.py +5 -0
- llama_index_unshadow/retriever.py +32 -0
- unshadow/__init__.py +11 -0
- unshadow/bank.py +127 -0
- unshadow/client.py +96 -0
- unshadow/documents.py +71 -0
- unshadow/http.py +100 -0
- unshadow-0.2.0.dist-info/METADATA +125 -0
- unshadow-0.2.0.dist-info/RECORD +14 -0
- unshadow-0.2.0.dist-info/WHEEL +5 -0
- unshadow-0.2.0.dist-info/licenses/LICENSE +21 -0
- unshadow-0.2.0.dist-info/top_level.txt +3 -0
|
@@ -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
|
unshadow/__init__.py
ADDED
|
@@ -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"]
|
unshadow/bank.py
ADDED
|
@@ -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)})
|
unshadow/client.py
ADDED
|
@@ -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)
|
unshadow/documents.py
ADDED
|
@@ -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
|
unshadow/http.py
ADDED
|
@@ -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,14 @@
|
|
|
1
|
+
langchain_unshadow/__init__.py,sha256=M2nHwmCXyCuPV_LZVSZW12bB-6mDlUd0toes4z6pdDQ,174
|
|
2
|
+
langchain_unshadow/retriever.py,sha256=tI79_1b4wTop6xDsDicNcNM7P1sre2n1dsD3VgTTiNk,1153
|
|
3
|
+
llama_index_unshadow/__init__.py,sha256=2vVEHSSNk9BisWwfM4-EUnLh52D-9_0Lm3j1bbwqFa8,179
|
|
4
|
+
llama_index_unshadow/retriever.py,sha256=Xaw4u4-mfPqlZ4F2q_zKIByO64Iq-8p4uYNpi9avNjE,1316
|
|
5
|
+
unshadow/__init__.py,sha256=qByuz4bf1KuJpkZzyy9J-l1LcgyPj2iIP-k_ODPyCsE,382
|
|
6
|
+
unshadow/bank.py,sha256=ZE93Eh2P4m6Xa75z037bEM65ySrG-ztmVHDxHTEeiK0,4157
|
|
7
|
+
unshadow/client.py,sha256=QrojShFsZEskbBhH09iXrkBOKp1gYHzexjL3bYAkW_w,3173
|
|
8
|
+
unshadow/documents.py,sha256=P9m7N8ttaSzMzkTawvkRlLCK4mUzRoF9wm_YYbO46Dw,2647
|
|
9
|
+
unshadow/http.py,sha256=P9SATKIDV4fFEZfYzshrwR7RCs3oJR2vDTeAjmMfOnM,3185
|
|
10
|
+
unshadow-0.2.0.dist-info/licenses/LICENSE,sha256=FxDTXMJvCxG3ZRqVsczpBrEoDmru0yog3fcZYVtimj8,1065
|
|
11
|
+
unshadow-0.2.0.dist-info/METADATA,sha256=iJh1chjYAqyAWPYqZiuCefqjUs8WoaRk9mq0Ic6I6EU,4060
|
|
12
|
+
unshadow-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
unshadow-0.2.0.dist-info/top_level.txt,sha256=q67oJFmQXHOr3f1xgpVtHkP2ZqXvgYI389Nw8lhW6qc,49
|
|
14
|
+
unshadow-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|