hindsight-langgraph 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ """Hindsight-LangGraph: Persistent memory for LangGraph and LangChain agents.
2
+
3
+ Provides Hindsight-backed tools, nodes, and a BaseStore adapter,
4
+ giving agents long-term memory across conversations.
5
+
6
+ The **tools** pattern works with both LangChain and LangGraph — only
7
+ ``langchain-core`` is required. The **nodes** and **store** patterns
8
+ require ``langgraph`` (install with ``pip install hindsight-langgraph[langgraph]``).
9
+
10
+ Basic usage with tools (LangChain or LangGraph)::
11
+
12
+ from hindsight_client import Hindsight
13
+ from hindsight_langgraph import create_hindsight_tools
14
+
15
+ client = Hindsight(base_url="http://localhost:8888")
16
+ tools = create_hindsight_tools(client=client, bank_id="user-123")
17
+
18
+ # Bind tools to your model
19
+ model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
20
+
21
+ Usage with memory nodes (requires langgraph)::
22
+
23
+ from hindsight_langgraph import create_recall_node, create_retain_node
24
+
25
+ recall = create_recall_node(client=client, bank_id="user-123")
26
+ retain = create_retain_node(client=client, bank_id="user-123")
27
+
28
+ builder.add_node("recall", recall)
29
+ builder.add_node("agent", agent_node)
30
+ builder.add_node("retain", retain)
31
+ builder.add_edge("recall", "agent")
32
+ builder.add_edge("agent", "retain")
33
+
34
+ Usage with BaseStore (requires langgraph)::
35
+
36
+ from hindsight_langgraph import HindsightStore
37
+
38
+ store = HindsightStore(client=client)
39
+ graph = builder.compile(checkpointer=checkpointer, store=store)
40
+ """
41
+
42
+ from .config import (
43
+ HindsightLangGraphConfig,
44
+ configure,
45
+ get_config,
46
+ reset_config,
47
+ )
48
+ from .errors import HindsightError
49
+ from .tools import create_hindsight_tools
50
+
51
+
52
+ def __getattr__(name: str):
53
+ """Lazy-import LangGraph-specific modules so langgraph is optional."""
54
+ if name == "create_recall_node" or name == "create_retain_node":
55
+ try:
56
+ from .nodes import create_recall_node, create_retain_node
57
+ except ImportError:
58
+ raise ImportError(
59
+ f"'{name}' requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
60
+ ) from None
61
+ return (
62
+ create_recall_node if name == "create_recall_node" else create_retain_node
63
+ )
64
+
65
+ if name == "HindsightStore":
66
+ try:
67
+ from .store import HindsightStore
68
+ except ImportError:
69
+ raise ImportError(
70
+ "HindsightStore requires langgraph. Install with: pip install hindsight-langgraph[langgraph]"
71
+ ) from None
72
+ return HindsightStore
73
+
74
+ raise AttributeError(f"module 'hindsight_langgraph' has no attribute {name!r}")
75
+
76
+
77
+ __version__ = "0.1.0"
78
+
79
+ __all__ = [
80
+ "configure",
81
+ "get_config",
82
+ "reset_config",
83
+ "HindsightLangGraphConfig",
84
+ "HindsightError",
85
+ "create_hindsight_tools",
86
+ ]
87
+
88
+ try:
89
+ import langgraph # noqa: F401
90
+
91
+ __all__ += ["create_recall_node", "create_retain_node", "HindsightStore"]
92
+ except ImportError:
93
+ pass
@@ -0,0 +1,32 @@
1
+ """Shared Hindsight client resolution logic."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from hindsight_client import Hindsight
6
+
7
+ from .config import get_config
8
+ from .errors import HindsightError
9
+
10
+
11
+ def resolve_client(
12
+ client: Optional[Hindsight],
13
+ hindsight_api_url: Optional[str],
14
+ api_key: Optional[str],
15
+ ) -> Hindsight:
16
+ """Resolve a Hindsight client from explicit args or global config."""
17
+ if client is not None:
18
+ return client
19
+
20
+ config = get_config()
21
+ url = hindsight_api_url or (config.hindsight_api_url if config else None)
22
+ key = api_key or (config.api_key if config else None)
23
+
24
+ if url is None:
25
+ raise HindsightError(
26
+ "No Hindsight API URL configured. Pass client= or hindsight_api_url=, or call configure() first."
27
+ )
28
+
29
+ kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0}
30
+ if key:
31
+ kwargs["api_key"] = key
32
+ return Hindsight(**kwargs)
@@ -0,0 +1,91 @@
1
+ """Global configuration for Hindsight-LangGraph integration."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
8
+ HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
9
+
10
+
11
+ @dataclass
12
+ class HindsightLangGraphConfig:
13
+ """Connection and default settings for the LangGraph integration.
14
+
15
+ Attributes:
16
+ hindsight_api_url: URL of the Hindsight API server.
17
+ api_key: API key for Hindsight authentication.
18
+ budget: Default recall budget level (low/mid/high).
19
+ max_tokens: Default maximum tokens for recall results.
20
+ tags: Default tags applied when storing memories.
21
+ recall_tags: Default tags to filter when searching memories.
22
+ recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
23
+ verbose: Enable verbose logging.
24
+ """
25
+
26
+ hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
27
+ api_key: Optional[str] = None
28
+ budget: str = "mid"
29
+ max_tokens: int = 4096
30
+ tags: Optional[list[str]] = None
31
+ recall_tags: Optional[list[str]] = None
32
+ recall_tags_match: str = "any"
33
+ verbose: bool = False
34
+
35
+
36
+ _global_config: Optional[HindsightLangGraphConfig] = None
37
+
38
+
39
+ def configure(
40
+ hindsight_api_url: Optional[str] = None,
41
+ api_key: Optional[str] = None,
42
+ budget: str = "mid",
43
+ max_tokens: int = 4096,
44
+ tags: Optional[list[str]] = None,
45
+ recall_tags: Optional[list[str]] = None,
46
+ recall_tags_match: str = "any",
47
+ verbose: bool = False,
48
+ ) -> HindsightLangGraphConfig:
49
+ """Configure Hindsight connection and default settings.
50
+
51
+ Args:
52
+ hindsight_api_url: Hindsight API URL (default: production).
53
+ api_key: API key. Falls back to HINDSIGHT_API_KEY env var.
54
+ budget: Default recall budget (low/mid/high).
55
+ max_tokens: Default max tokens for recall.
56
+ tags: Default tags for retain operations.
57
+ recall_tags: Default tags to filter recall/search.
58
+ recall_tags_match: Tag matching mode.
59
+ verbose: Enable verbose logging.
60
+
61
+ Returns:
62
+ The configured HindsightLangGraphConfig.
63
+ """
64
+ global _global_config
65
+
66
+ resolved_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
67
+ resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
68
+
69
+ _global_config = HindsightLangGraphConfig(
70
+ hindsight_api_url=resolved_url,
71
+ api_key=resolved_key,
72
+ budget=budget,
73
+ max_tokens=max_tokens,
74
+ tags=tags,
75
+ recall_tags=recall_tags,
76
+ recall_tags_match=recall_tags_match,
77
+ verbose=verbose,
78
+ )
79
+
80
+ return _global_config
81
+
82
+
83
+ def get_config() -> Optional[HindsightLangGraphConfig]:
84
+ """Get the current global configuration."""
85
+ return _global_config
86
+
87
+
88
+ def reset_config() -> None:
89
+ """Reset global configuration to None."""
90
+ global _global_config
91
+ _global_config = None
@@ -0,0 +1,7 @@
1
+ """Hindsight-LangGraph error types."""
2
+
3
+
4
+ class HindsightError(Exception):
5
+ """Exception raised when a Hindsight memory operation fails."""
6
+
7
+ pass
@@ -0,0 +1,259 @@
1
+ """Pre-built LangGraph nodes for Hindsight memory operations.
2
+
3
+ Provides node functions that can be added directly to a StateGraph to
4
+ inject memories at conversation start and store new memories after responses.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Optional
9
+
10
+ from hindsight_client import Hindsight
11
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
12
+ from langchain_core.runnables import RunnableConfig
13
+ from langgraph.graph import MessagesState
14
+
15
+ from ._client import resolve_client
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _extract_text_content(content: Any) -> str:
21
+ """Extract text from a message content field.
22
+
23
+ Handles both plain string content and multimodal content lists
24
+ (where each item may be a dict with "type" and "text" keys).
25
+ Returns the concatenated text parts, or an empty string if no
26
+ text content is found.
27
+ """
28
+ if isinstance(content, str):
29
+ return content
30
+ if isinstance(content, list):
31
+ parts = []
32
+ for part in content:
33
+ if isinstance(part, str):
34
+ parts.append(part)
35
+ elif isinstance(part, dict) and part.get("type") == "text":
36
+ parts.append(part.get("text", ""))
37
+ return " ".join(parts)
38
+ return str(content) if content else ""
39
+
40
+
41
+ def create_recall_node(
42
+ *,
43
+ bank_id: Optional[str] = None,
44
+ client: Optional[Hindsight] = None,
45
+ hindsight_api_url: Optional[str] = None,
46
+ api_key: Optional[str] = None,
47
+ budget: str = "mid",
48
+ max_tokens: int = 4096,
49
+ max_results: int = 10,
50
+ tags: Optional[list[str]] = None,
51
+ tags_match: str = "any",
52
+ bank_id_from_config: str = "user_id",
53
+ output_key: Optional[str] = None,
54
+ ):
55
+ """Create a node that injects relevant memories into the conversation.
56
+
57
+ This node extracts the latest user message, recalls relevant memories
58
+ from Hindsight, and returns them either as a SystemMessage in the
59
+ ``messages`` list (default) or as a plain string under a custom state
60
+ key via ``output_key``.
61
+
62
+ **Message ordering:** When using the default ``messages`` output,
63
+ ``MessagesState`` uses ``add_messages`` as its reducer, which appends.
64
+ The memory SystemMessage will appear after existing messages, not at
65
+ position 0. If your LLM provider requires system messages first, use
66
+ ``output_key`` to write the memory text to a separate state field and
67
+ inject it into your prompt in the agent node.
68
+
69
+ Example with ``output_key`` (recommended for correct ordering)::
70
+
71
+ from typing import Optional
72
+ from langgraph.graph import MessagesState
73
+
74
+ class AgentState(MessagesState):
75
+ memory_context: Optional[str] = None
76
+
77
+ recall = create_recall_node(
78
+ client=client, bank_id="user-123", output_key="memory_context"
79
+ )
80
+ # In your agent node, read state["memory_context"] and prepend
81
+ # it to the system prompt.
82
+
83
+ The bank_id can be provided directly or resolved dynamically from
84
+ the graph's RunnableConfig via the ``bank_id_from_config`` key.
85
+
86
+ Args:
87
+ bank_id: Static Hindsight memory bank ID.
88
+ client: Pre-configured Hindsight client.
89
+ hindsight_api_url: API URL (used if no client provided).
90
+ api_key: API key (used if no client provided).
91
+ budget: Recall budget level (low/mid/high).
92
+ max_tokens: Maximum tokens for recall results.
93
+ max_results: Maximum number of memories to inject.
94
+ tags: Tags to filter recall results.
95
+ tags_match: Tag matching mode.
96
+ bank_id_from_config: Config key to read bank_id from at runtime.
97
+ Looked up in ``config["configurable"][bank_id_from_config]``.
98
+ Only used when ``bank_id`` is not provided.
99
+ output_key: If set, write the memory text to this state key as a
100
+ plain string instead of appending a SystemMessage to ``messages``.
101
+ Use this with a custom state type to control where memory context
102
+ appears in your prompt.
103
+
104
+ Returns:
105
+ An async node function compatible with LangGraph StateGraph.
106
+ """
107
+ resolved_client = resolve_client(client, hindsight_api_url, api_key)
108
+
109
+ async def recall_node(
110
+ state: MessagesState, config: Optional[RunnableConfig] = None
111
+ ) -> dict[str, Any]:
112
+ resolved_bank_id = bank_id
113
+ if resolved_bank_id is None and config:
114
+ configurable = config.get("configurable", {})
115
+ resolved_bank_id = configurable.get(bank_id_from_config)
116
+
117
+ if not resolved_bank_id:
118
+ logger.warning(
119
+ "No bank_id available for recall node, skipping memory injection."
120
+ )
121
+ if output_key:
122
+ return {output_key: None}
123
+ return {"messages": []}
124
+
125
+ # Extract query from the latest human message
126
+ query = None
127
+ for msg in reversed(state["messages"]):
128
+ if isinstance(msg, HumanMessage):
129
+ query = _extract_text_content(msg.content)
130
+ break
131
+
132
+ if not query:
133
+ if output_key:
134
+ return {output_key: None}
135
+ return {"messages": []}
136
+
137
+ try:
138
+ recall_kwargs: dict[str, Any] = {
139
+ "bank_id": resolved_bank_id,
140
+ "query": query,
141
+ "budget": budget,
142
+ "max_tokens": max_tokens,
143
+ }
144
+ if tags:
145
+ recall_kwargs["tags"] = tags
146
+ recall_kwargs["tags_match"] = tags_match
147
+
148
+ response = await resolved_client.arecall(**recall_kwargs)
149
+ results = response.results[:max_results] if response.results else []
150
+
151
+ if not results:
152
+ if output_key:
153
+ return {output_key: None}
154
+ return {"messages": []}
155
+
156
+ lines = ["Relevant memories about this user:"]
157
+ for i, result in enumerate(results, 1):
158
+ lines.append(f"{i}. {result.text}")
159
+ memory_text = "\n".join(lines)
160
+
161
+ if output_key:
162
+ return {output_key: memory_text}
163
+ return {
164
+ "messages": [
165
+ SystemMessage(content=memory_text, id="hindsight_memory_context")
166
+ ]
167
+ }
168
+ except Exception as e:
169
+ logger.error(f"Recall node failed: {e}")
170
+ if output_key:
171
+ return {output_key: None}
172
+ return {"messages": []}
173
+
174
+ return recall_node
175
+
176
+
177
+ def create_retain_node(
178
+ *,
179
+ bank_id: Optional[str] = None,
180
+ client: Optional[Hindsight] = None,
181
+ hindsight_api_url: Optional[str] = None,
182
+ api_key: Optional[str] = None,
183
+ tags: Optional[list[str]] = None,
184
+ bank_id_from_config: str = "user_id",
185
+ retain_human: bool = True,
186
+ retain_ai: bool = False,
187
+ ):
188
+ """Create a node that stores conversation messages as memories.
189
+
190
+ This node extracts messages from the conversation and stores them
191
+ via Hindsight retain. It should be placed after the LLM response
192
+ node in your graph.
193
+
194
+ Args:
195
+ bank_id: Static Hindsight memory bank ID.
196
+ client: Pre-configured Hindsight client.
197
+ hindsight_api_url: API URL (used if no client provided).
198
+ api_key: API key (used if no client provided).
199
+ tags: Tags to apply to stored memories.
200
+ bank_id_from_config: Config key to read bank_id from at runtime.
201
+ retain_human: Store human messages as memories.
202
+ retain_ai: Store AI responses as memories.
203
+
204
+ Returns:
205
+ An async node function compatible with LangGraph StateGraph.
206
+ """
207
+ resolved_client = resolve_client(client, hindsight_api_url, api_key)
208
+
209
+ async def retain_node(
210
+ state: MessagesState, config: Optional[RunnableConfig] = None
211
+ ) -> dict[str, Any]:
212
+ resolved_bank_id = bank_id
213
+ if resolved_bank_id is None and config:
214
+ configurable = config.get("configurable", {})
215
+ resolved_bank_id = configurable.get(bank_id_from_config)
216
+
217
+ if not resolved_bank_id:
218
+ logger.warning(
219
+ "No bank_id available for retain node, skipping memory storage."
220
+ )
221
+ return {"messages": []}
222
+
223
+ # Only retain the latest human and/or AI message to avoid
224
+ # duplicating memories that were already stored in prior calls.
225
+ messages_to_retain = []
226
+ if retain_human:
227
+ for msg in reversed(state["messages"]):
228
+ if isinstance(msg, HumanMessage):
229
+ text = _extract_text_content(msg.content)
230
+ if text:
231
+ messages_to_retain.append(text)
232
+ break
233
+ if retain_ai:
234
+ for msg in reversed(state["messages"]):
235
+ if isinstance(msg, AIMessage):
236
+ text = _extract_text_content(msg.content)
237
+ if text:
238
+ messages_to_retain.append(text)
239
+ break
240
+
241
+ if not messages_to_retain:
242
+ return {"messages": []}
243
+
244
+ content = "\n\n".join(messages_to_retain)
245
+
246
+ try:
247
+ retain_kwargs: dict[str, Any] = {
248
+ "bank_id": resolved_bank_id,
249
+ "content": content,
250
+ }
251
+ if tags:
252
+ retain_kwargs["tags"] = tags
253
+ await resolved_client.aretain(**retain_kwargs)
254
+ except Exception as e:
255
+ logger.error(f"Retain node failed: {e}")
256
+
257
+ return {"messages": []}
258
+
259
+ return retain_node
@@ -0,0 +1,430 @@
1
+ """LangGraph BaseStore adapter backed by Hindsight.
2
+
3
+ Maps LangGraph's key-value store interface to Hindsight's memory operations.
4
+ Namespace tuples are joined to form bank IDs, and values are stored/retrieved
5
+ via retain/recall.
6
+ """
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Iterable, Optional
14
+
15
+ from hindsight_client import Hindsight
16
+ from langgraph.store.base import (
17
+ BaseStore,
18
+ GetOp,
19
+ Item,
20
+ ListNamespacesOp,
21
+ PutOp,
22
+ Result,
23
+ SearchItem,
24
+ SearchOp,
25
+ )
26
+
27
+ from ._client import resolve_client
28
+ from .errors import HindsightError
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _namespace_to_bank_id(namespace: tuple[str, ...]) -> str:
34
+ """Convert a namespace tuple to a Hindsight bank ID.
35
+
36
+ Uses "." as separator since "/" is not valid in Hindsight bank IDs
37
+ (interpreted as URL path segments).
38
+ """
39
+ return ".".join(namespace) if namespace else "default"
40
+
41
+
42
+ def _make_item(
43
+ namespace: tuple[str, ...],
44
+ key: str,
45
+ value: dict,
46
+ created_at: Optional[datetime] = None,
47
+ ) -> Item:
48
+ """Create a LangGraph Item from Hindsight data."""
49
+ now = datetime.now(timezone.utc)
50
+ return Item(
51
+ namespace=namespace,
52
+ key=key,
53
+ value=value,
54
+ created_at=created_at or now,
55
+ updated_at=now,
56
+ )
57
+
58
+
59
+ def _make_search_item(
60
+ namespace: tuple[str, ...],
61
+ key: str,
62
+ value: dict,
63
+ score: float,
64
+ created_at: Optional[datetime] = None,
65
+ ) -> SearchItem:
66
+ """Create a LangGraph SearchItem from Hindsight recall results."""
67
+ now = datetime.now(timezone.utc)
68
+ return SearchItem(
69
+ namespace=namespace,
70
+ key=key,
71
+ value=value,
72
+ score=score,
73
+ created_at=created_at or now,
74
+ updated_at=now,
75
+ )
76
+
77
+
78
+ class HindsightStore(BaseStore):
79
+ """LangGraph BaseStore implementation backed by Hindsight.
80
+
81
+ Maps LangGraph's namespace/key-value model to Hindsight memory banks:
82
+ - Namespace tuples are joined with "." to form bank IDs
83
+ - ``put()`` stores values via Hindsight retain with the key as document_id
84
+ - ``search()`` uses Hindsight recall for semantic search
85
+ - ``get()`` uses recall with the key as a targeted query, returning only
86
+ exact ``document_id`` matches. If the stored document does not surface in
87
+ the recall window, ``get()`` returns ``None`` even though the item exists.
88
+ Hindsight does not currently expose a direct document-lookup endpoint.
89
+
90
+ **Known limitations:**
91
+
92
+ - **Async-only.** All sync methods (``batch``, ``get``, ``put``, ``delete``,
93
+ ``search``, ``list_namespaces``) raise ``NotImplementedError``. Use the
94
+ async variants (``abatch``, ``aget``, ``aput``, ``adelete``, ``asearch``,
95
+ ``alist_namespaces``) instead.
96
+ - **``list_namespaces`` is session-scoped.** It only tracks namespaces that
97
+ have been written to via ``aput()`` during the current process. After a
98
+ restart, ``list_namespaces`` returns empty even though data still exists
99
+ in Hindsight. Hindsight does not currently provide a bank-listing API.
100
+ - **``delete`` is a no-op.** Calling ``adelete()`` logs a debug message but
101
+ does not remove data. Hindsight's memory model is append-oriented; fact
102
+ superseding is handled automatically during retain.
103
+ - **``get()`` relies on recall.** There is no direct key lookup — the key
104
+ is used as a recall query and only exact ``document_id`` matches are
105
+ returned. Items that do not rank in the top recall results will appear
106
+ missing.
107
+
108
+ Example::
109
+
110
+ from hindsight_client import Hindsight
111
+ from hindsight_langgraph import HindsightStore
112
+
113
+ store = HindsightStore(client=Hindsight(base_url="http://localhost:8888"))
114
+ graph = builder.compile(checkpointer=checkpointer, store=store)
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ client: Optional[Hindsight] = None,
121
+ hindsight_api_url: Optional[str] = None,
122
+ api_key: Optional[str] = None,
123
+ tags: Optional[list[str]] = None,
124
+ ):
125
+ self._client = resolve_client(client, hindsight_api_url, api_key)
126
+ self._tags = tags
127
+ # Track known namespaces for list_namespaces (session-scoped only)
128
+ self._known_namespaces: set[tuple[str, ...]] = set()
129
+ # Track banks that have been created to avoid repeated create calls
130
+ self._created_banks: set[str] = set()
131
+ # Per-bank locks for concurrency-safe bank creation
132
+ self._bank_locks: dict[str, asyncio.Lock] = {}
133
+
134
+ def batch(
135
+ self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]
136
+ ) -> list[Result]:
137
+ raise NotImplementedError("Use abatch() for async operation.")
138
+
139
+ async def abatch(
140
+ self, ops: Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp]
141
+ ) -> list[Result]:
142
+ results: list[Result] = []
143
+ for op in ops:
144
+ if isinstance(op, GetOp):
145
+ results.append(await self._handle_get(op))
146
+ elif isinstance(op, PutOp):
147
+ await self._handle_put(op)
148
+ results.append(None)
149
+ elif isinstance(op, SearchOp):
150
+ results.append(await self._handle_search(op))
151
+ elif isinstance(op, ListNamespacesOp):
152
+ results.append(await self._handle_list_namespaces(op))
153
+ else:
154
+ results.append(None)
155
+ return results
156
+
157
+ async def _handle_get(self, op: GetOp) -> Optional[Item]:
158
+ """Handle a get operation by recalling with the key as query."""
159
+ bank_id = _namespace_to_bank_id(op.namespace)
160
+ try:
161
+ await self._ensure_bank(bank_id)
162
+ response = await self._client.arecall(
163
+ bank_id=bank_id,
164
+ query=op.key,
165
+ budget="low",
166
+ max_tokens=1024,
167
+ )
168
+ if not response.results:
169
+ return None
170
+
171
+ # Only return a result if the document_id matches the requested key exactly.
172
+ # Do NOT fall back to semantic search — that would violate key-value store semantics.
173
+ for result in response.results:
174
+ doc_id = getattr(result, "document_id", None)
175
+ if doc_id == op.key:
176
+ value = _parse_value(result.text)
177
+ ts = getattr(result, "occurred_start", None)
178
+ return _make_item(op.namespace, op.key, value, created_at=ts)
179
+
180
+ return None
181
+ except Exception as e:
182
+ logger.error(f"Store get failed for {op.namespace}/{op.key}: {e}")
183
+ return None
184
+
185
+ async def _ensure_bank(self, bank_id: str) -> None:
186
+ """Create a bank if it hasn't been created yet in this session.
187
+
188
+ Uses per-bank locking to prevent concurrent creation races.
189
+ """
190
+ if bank_id in self._created_banks:
191
+ return
192
+ lock = self._bank_locks.setdefault(bank_id, asyncio.Lock())
193
+ async with lock:
194
+ # Double-check after acquiring the lock
195
+ if bank_id in self._created_banks:
196
+ return
197
+ try:
198
+ await self._client.acreate_bank(bank_id, name=bank_id)
199
+ self._created_banks.add(bank_id)
200
+ except Exception as e:
201
+ error_str = str(e).lower()
202
+ if (
203
+ "already exists" in error_str
204
+ or "conflict" in error_str
205
+ or "409" in error_str
206
+ ):
207
+ # Bank already exists — safe to cache
208
+ self._created_banks.add(bank_id)
209
+ else:
210
+ logger.error(f"Failed to create bank '{bank_id}': {e}")
211
+ raise
212
+
213
+ async def _handle_put(self, op: PutOp) -> None:
214
+ """Handle a put operation by retaining the value."""
215
+ bank_id = _namespace_to_bank_id(op.namespace)
216
+ self._known_namespaces.add(op.namespace)
217
+
218
+ if op.value is None:
219
+ # LangGraph uses value=None as delete
220
+ logger.debug(f"Delete not supported for {op.namespace}/{op.key}, skipping.")
221
+ return
222
+
223
+ try:
224
+ await self._ensure_bank(bank_id)
225
+ content = (
226
+ json.dumps(op.value) if isinstance(op.value, dict) else str(op.value)
227
+ )
228
+ retain_kwargs: dict[str, Any] = {
229
+ "bank_id": bank_id,
230
+ "content": content,
231
+ "document_id": op.key,
232
+ }
233
+ if self._tags:
234
+ retain_kwargs["tags"] = self._tags
235
+ await self._client.aretain(**retain_kwargs)
236
+ except Exception as e:
237
+ logger.error(f"Store put failed for {op.namespace}/{op.key}: {e}")
238
+ raise HindsightError(f"Store put failed: {e}") from e
239
+
240
+ async def _handle_search(self, op: SearchOp) -> list[SearchItem]:
241
+ """Handle a search operation via Hindsight recall."""
242
+ bank_id = _namespace_to_bank_id(op.namespace_prefix)
243
+ query = op.query or "*"
244
+
245
+ try:
246
+ await self._ensure_bank(bank_id)
247
+ recall_kwargs: dict[str, Any] = {
248
+ "bank_id": bank_id,
249
+ "query": query,
250
+ "budget": "mid",
251
+ "max_tokens": 4096,
252
+ }
253
+ response = await self._client.arecall(**recall_kwargs)
254
+ if not response.results:
255
+ return []
256
+
257
+ # Build all candidate items first
258
+ all_items = []
259
+ for i, result in enumerate(response.results):
260
+ value = _parse_value(result.text)
261
+ doc_id = getattr(result, "document_id", None) or _content_key(
262
+ result.text
263
+ )
264
+ score = max(
265
+ 0.0, 1.0 - (i * 0.01)
266
+ ) # Approximate score from rank position
267
+ ts = getattr(result, "occurred_start", None)
268
+ all_items.append(
269
+ _make_search_item(
270
+ op.namespace_prefix, doc_id, value, score=score, created_at=ts
271
+ )
272
+ )
273
+
274
+ # Apply filters BEFORE pagination so offset/limit operate on
275
+ # the filtered set rather than discarding matching items.
276
+ if op.filter:
277
+ all_items = [
278
+ item for item in all_items if _matches_filter(item.value, op.filter)
279
+ ]
280
+
281
+ limit = op.limit or 10
282
+ offset = op.offset or 0
283
+ return all_items[offset : offset + limit]
284
+ except Exception as e:
285
+ logger.error(f"Store search failed for {op.namespace_prefix}: {e}")
286
+ return []
287
+
288
+ async def _handle_list_namespaces(
289
+ self, op: ListNamespacesOp
290
+ ) -> list[tuple[str, ...]]:
291
+ """List known namespaces. Limited to namespaces seen via put() in this session."""
292
+ namespaces = list(self._known_namespaces)
293
+
294
+ if op.match_conditions:
295
+ filtered = []
296
+ for ns in namespaces:
297
+ match = True
298
+ for cond in op.match_conditions:
299
+ match_type = getattr(cond, "match_type", "prefix")
300
+ if match_type == "prefix":
301
+ if not _namespace_starts_with(ns, cond.path):
302
+ match = False
303
+ break
304
+ elif match_type == "suffix":
305
+ if not _namespace_ends_with(ns, cond.path):
306
+ match = False
307
+ break
308
+ if match:
309
+ filtered.append(ns)
310
+ namespaces = filtered
311
+
312
+ if op.max_depth is not None:
313
+ # Truncate namespaces to max_depth and deduplicate, per BaseStore contract.
314
+ namespaces = list(dict.fromkeys(ns[: op.max_depth] for ns in namespaces))
315
+
316
+ limit = op.limit or 100
317
+ offset = op.offset or 0
318
+ return namespaces[offset : offset + limit]
319
+
320
+ # Sync convenience methods that delegate to async
321
+
322
+ def get(self, namespace: tuple[str, ...], key: str) -> Optional[Item]:
323
+ raise NotImplementedError("Use aget() for async operation.")
324
+
325
+ async def aget(self, namespace: tuple[str, ...], key: str) -> Optional[Item]:
326
+ result = await self.abatch([GetOp(namespace=namespace, key=key)])
327
+ return result[0]
328
+
329
+ def put(
330
+ self,
331
+ namespace: tuple[str, ...],
332
+ key: str,
333
+ value: dict,
334
+ index: Optional[Any] = None,
335
+ ) -> None:
336
+ raise NotImplementedError("Use aput() for async operation.")
337
+
338
+ async def aput(
339
+ self,
340
+ namespace: tuple[str, ...],
341
+ key: str,
342
+ value: dict,
343
+ index: Optional[Any] = None,
344
+ ttl: Optional[float] = None,
345
+ ) -> None:
346
+ # ttl is accepted for BaseStore compatibility but not used;
347
+ # Hindsight does not support TTL-based expiration natively.
348
+ await self.abatch([PutOp(namespace=namespace, key=key, value=value)])
349
+
350
+ def delete(self, namespace: tuple[str, ...], key: str) -> None:
351
+ raise NotImplementedError("Use adelete() for async operation.")
352
+
353
+ async def adelete(self, namespace: tuple[str, ...], key: str) -> None:
354
+ await self.abatch([PutOp(namespace=namespace, key=key, value=None)])
355
+
356
+ def search(
357
+ self,
358
+ namespace_prefix: tuple[str, ...],
359
+ *,
360
+ query: Optional[str] = None,
361
+ filter: Optional[dict] = None,
362
+ limit: int = 10,
363
+ offset: int = 0,
364
+ ) -> list[SearchItem]:
365
+ raise NotImplementedError("Use asearch() for async operation.")
366
+
367
+ async def asearch(
368
+ self,
369
+ namespace_prefix: tuple[str, ...],
370
+ *,
371
+ query: Optional[str] = None,
372
+ filter: Optional[dict] = None,
373
+ limit: int = 10,
374
+ offset: int = 0,
375
+ ) -> list[SearchItem]:
376
+ result = await self.abatch(
377
+ [
378
+ SearchOp(
379
+ namespace_prefix=namespace_prefix,
380
+ query=query,
381
+ filter=filter,
382
+ limit=limit,
383
+ offset=offset,
384
+ )
385
+ ]
386
+ )
387
+ return result[0]
388
+
389
+ # list_namespaces / alist_namespaces are NOT overridden here.
390
+ # The base class converts prefix=/suffix= kwargs into MatchCondition
391
+ # objects and calls abatch() -> _handle_list_namespaces(). Overriding
392
+ # with a different signature (match_conditions=) would break callers.
393
+
394
+
395
+ def _parse_value(text: str) -> dict:
396
+ """Try to parse stored text as JSON, fallback to wrapping in a dict."""
397
+ try:
398
+ parsed = json.loads(text)
399
+ if isinstance(parsed, dict):
400
+ return parsed
401
+ except (json.JSONDecodeError, TypeError):
402
+ pass
403
+ return {"text": text}
404
+
405
+
406
+ def _content_key(text: str) -> str:
407
+ """Generate a stable key from content text."""
408
+ return hashlib.sha256(text.encode()).hexdigest()[:12]
409
+
410
+
411
+ def _matches_filter(value: dict, filter_dict: dict) -> bool:
412
+ """Check if a value dict matches all filter conditions."""
413
+ for key, expected in filter_dict.items():
414
+ if value.get(key) != expected:
415
+ return False
416
+ return True
417
+
418
+
419
+ def _namespace_starts_with(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
420
+ """Check if namespace starts with the given prefix."""
421
+ if len(prefix) > len(namespace):
422
+ return False
423
+ return namespace[: len(prefix)] == prefix
424
+
425
+
426
+ def _namespace_ends_with(namespace: tuple[str, ...], suffix: tuple[str, ...]) -> bool:
427
+ """Check if namespace ends with the given suffix."""
428
+ if len(suffix) > len(namespace):
429
+ return False
430
+ return namespace[len(namespace) - len(suffix) :] == suffix
@@ -0,0 +1,217 @@
1
+ """LangGraph tool definitions for Hindsight memory operations.
2
+
3
+ Provides factory functions that create LangGraph-compatible tool functions
4
+ backed by Hindsight's retain/recall/reflect APIs. These tools can be bound
5
+ to a ChatModel via `model.bind_tools()` or used in a ToolNode.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Optional
10
+
11
+ from hindsight_client import Hindsight
12
+ from langchain_core.tools import tool
13
+
14
+ from ._client import resolve_client
15
+ from .config import get_config
16
+ from .errors import HindsightError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def create_hindsight_tools(
22
+ *,
23
+ bank_id: str,
24
+ client: Optional[Hindsight] = None,
25
+ hindsight_api_url: Optional[str] = None,
26
+ api_key: Optional[str] = None,
27
+ budget: Optional[str] = None,
28
+ max_tokens: Optional[int] = None,
29
+ tags: Optional[list[str]] = None,
30
+ recall_tags: Optional[list[str]] = None,
31
+ recall_tags_match: Optional[str] = None,
32
+ # Retain options
33
+ retain_metadata: Optional[dict[str, str]] = None,
34
+ retain_document_id: Optional[str] = None,
35
+ # Recall options
36
+ recall_types: Optional[list[str]] = None,
37
+ recall_include_entities: bool = False,
38
+ # Reflect options
39
+ reflect_context: Optional[str] = None,
40
+ reflect_max_tokens: Optional[int] = None,
41
+ reflect_response_schema: Optional[dict[str, Any]] = None,
42
+ reflect_tags: Optional[list[str]] = None,
43
+ reflect_tags_match: Optional[str] = None,
44
+ include_retain: bool = True,
45
+ include_recall: bool = True,
46
+ include_reflect: bool = True,
47
+ ) -> list:
48
+ """Create Hindsight memory tools for a LangGraph agent.
49
+
50
+ Returns a list of LangChain tool instances compatible with LangGraph's
51
+ ToolNode and ChatModel.bind_tools().
52
+
53
+ Args:
54
+ bank_id: The Hindsight memory bank to operate on.
55
+ client: Pre-configured Hindsight client (preferred).
56
+ hindsight_api_url: API URL (used if no client provided).
57
+ api_key: API key (used if no client provided).
58
+ budget: Recall/reflect budget level (low/mid/high).
59
+ max_tokens: Maximum tokens for recall results.
60
+ tags: Tags applied when storing memories via retain.
61
+ recall_tags: Tags to filter when searching memories.
62
+ recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
63
+ retain_metadata: Default metadata dict for retain operations.
64
+ retain_document_id: Default document_id for retain (groups/upserts memories).
65
+ recall_types: Fact types to filter (world, experience, opinion, observation).
66
+ recall_include_entities: Include entity information in recall results.
67
+ reflect_context: Additional context for reflect operations.
68
+ reflect_max_tokens: Max tokens for reflect results (defaults to max_tokens).
69
+ reflect_response_schema: JSON schema to constrain reflect output format.
70
+ reflect_tags: Tags to filter memories used in reflect (defaults to recall_tags).
71
+ reflect_tags_match: Tag matching for reflect (defaults to recall_tags_match).
72
+ include_retain: Include the retain (store) tool.
73
+ include_recall: Include the recall (search) tool.
74
+ include_reflect: Include the reflect (synthesize) tool.
75
+
76
+ Returns:
77
+ List of LangChain tool instances.
78
+
79
+ Raises:
80
+ HindsightError: If no client or API URL can be resolved.
81
+ """
82
+ resolved_client = resolve_client(client, hindsight_api_url, api_key)
83
+
84
+ config = get_config()
85
+ effective_tags = tags if tags is not None else (config.tags if config else None)
86
+ effective_recall_tags = (
87
+ recall_tags
88
+ if recall_tags is not None
89
+ else (config.recall_tags if config else None)
90
+ )
91
+ effective_recall_tags_match = (
92
+ recall_tags_match
93
+ if recall_tags_match is not None
94
+ else (config.recall_tags_match if config else "any")
95
+ )
96
+ effective_budget = (
97
+ budget if budget is not None else (config.budget if config else "mid")
98
+ )
99
+ effective_max_tokens = (
100
+ max_tokens
101
+ if max_tokens is not None
102
+ else (config.max_tokens if config else 4096)
103
+ )
104
+
105
+ tools: list = []
106
+
107
+ if include_retain:
108
+
109
+ @tool
110
+ async def hindsight_retain(content: str) -> str:
111
+ """Store information to long-term memory for later retrieval.
112
+
113
+ Use this to save important facts, user preferences, decisions,
114
+ or any information that should be remembered across conversations.
115
+
116
+ Args:
117
+ content: The information to store in memory.
118
+ """
119
+ try:
120
+ retain_kwargs: dict[str, Any] = {"bank_id": bank_id, "content": content}
121
+ if effective_tags:
122
+ retain_kwargs["tags"] = effective_tags
123
+ if retain_metadata:
124
+ retain_kwargs["metadata"] = retain_metadata
125
+ if retain_document_id:
126
+ retain_kwargs["document_id"] = retain_document_id
127
+ await resolved_client.aretain(**retain_kwargs)
128
+ return "Memory stored successfully."
129
+ except Exception as e:
130
+ logger.error(f"Retain failed: {e}")
131
+ raise HindsightError(f"Retain failed: {e}") from e
132
+
133
+ tools.append(hindsight_retain)
134
+
135
+ if include_recall:
136
+
137
+ @tool
138
+ async def hindsight_recall(query: str) -> str:
139
+ """Search long-term memory for relevant information.
140
+
141
+ Use this to find previously stored facts, preferences, or context.
142
+ Returns a numbered list of matching memories.
143
+
144
+ Args:
145
+ query: What to search for in memory.
146
+ """
147
+ try:
148
+ recall_kwargs: dict[str, Any] = {
149
+ "bank_id": bank_id,
150
+ "query": query,
151
+ "budget": effective_budget,
152
+ "max_tokens": effective_max_tokens,
153
+ }
154
+ if effective_recall_tags:
155
+ recall_kwargs["tags"] = effective_recall_tags
156
+ recall_kwargs["tags_match"] = effective_recall_tags_match
157
+ if recall_types:
158
+ recall_kwargs["types"] = recall_types
159
+ if recall_include_entities:
160
+ recall_kwargs["include_entities"] = True
161
+ response = await resolved_client.arecall(**recall_kwargs)
162
+ if not response.results:
163
+ return "No relevant memories found."
164
+ lines = []
165
+ for i, result in enumerate(response.results, 1):
166
+ lines.append(f"{i}. {result.text}")
167
+ return "\n".join(lines)
168
+ except Exception as e:
169
+ logger.error(f"Recall failed: {e}")
170
+ raise HindsightError(f"Recall failed: {e}") from e
171
+
172
+ tools.append(hindsight_recall)
173
+
174
+ if include_reflect:
175
+
176
+ @tool
177
+ async def hindsight_reflect(query: str) -> str:
178
+ """Synthesize a thoughtful answer from long-term memories.
179
+
180
+ Use this when you need a coherent summary or reasoned response
181
+ about what you know, rather than raw memory facts.
182
+
183
+ Args:
184
+ query: The question to reflect on using stored memories.
185
+ """
186
+ try:
187
+ reflect_kwargs: dict[str, Any] = {
188
+ "bank_id": bank_id,
189
+ "query": query,
190
+ "budget": effective_budget,
191
+ }
192
+ if reflect_context:
193
+ reflect_kwargs["context"] = reflect_context
194
+ effective_reflect_max = reflect_max_tokens or effective_max_tokens
195
+ if effective_reflect_max:
196
+ reflect_kwargs["max_tokens"] = effective_reflect_max
197
+ if reflect_response_schema:
198
+ reflect_kwargs["response_schema"] = reflect_response_schema
199
+ # Reflect tags: use reflect-specific or fall back to recall tags
200
+ effective_reflect_tags = (
201
+ reflect_tags if reflect_tags is not None else effective_recall_tags
202
+ )
203
+ effective_reflect_tags_match = (
204
+ reflect_tags_match or effective_recall_tags_match
205
+ )
206
+ if effective_reflect_tags:
207
+ reflect_kwargs["tags"] = effective_reflect_tags
208
+ reflect_kwargs["tags_match"] = effective_reflect_tags_match
209
+ response = await resolved_client.areflect(**reflect_kwargs)
210
+ return response.text or "No relevant memories found."
211
+ except Exception as e:
212
+ logger.error(f"Reflect failed: {e}")
213
+ raise HindsightError(f"Reflect failed: {e}") from e
214
+
215
+ tools.append(hindsight_reflect)
216
+
217
+ return tools
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: hindsight-langgraph
3
+ Version: 0.1.1
4
+ Summary: LangGraph integration for Hindsight - persistent memory tools, nodes, and store for AI agents
5
+ Project-URL: Homepage, https://github.com/vectorize-io/hindsight
6
+ Project-URL: Documentation, https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/langgraph
7
+ Project-URL: Repository, https://github.com/vectorize-io/hindsight
8
+ Author-email: Vectorize <support@vectorize.io>
9
+ License: MIT
10
+ Keywords: agents,ai,hindsight,langchain,langgraph,memory
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
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: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: hindsight-client>=0.4.0
21
+ Requires-Dist: langchain-core>=0.3.0
22
+ Provides-Extra: all
23
+ Requires-Dist: langgraph>=0.3.0; extra == 'all'
24
+ Provides-Extra: langgraph
25
+ Requires-Dist: langgraph>=0.3.0; extra == 'langgraph'
@@ -0,0 +1,10 @@
1
+ hindsight_langgraph/__init__.py,sha256=4zYHuK9cpE0avkrCzPBL4Fqh-qH24wnOt-78RvcTj74,2934
2
+ hindsight_langgraph/_client.py,sha256=BVneZXQXfVewCxfElOJ2u1ejEKlIbaUjzxXCyC8XA4I,923
3
+ hindsight_langgraph/config.py,sha256=qVW7H006hbGQ9YFeIvN_7rufeGh_BnbGMWLNWiWBdN8,2867
4
+ hindsight_langgraph/errors.py,sha256=tn9hA-mH63fF9NipRZKbxvcTf-M2OP8WhaFccUYdthc,152
5
+ hindsight_langgraph/nodes.py,sha256=CwHBOLN63WDuQ998LA_3pSNv6mgE_-5r7ikIDyoTxEY,9485
6
+ hindsight_langgraph/store.py,sha256=clF3C2wRXs8SVI8rjw6I41WN-njdYIkudqiKN5lXJys,15717
7
+ hindsight_langgraph/tools.py,sha256=NDkZMETg-bCqCKgYWmPYBRg0mmiBlLKpwTSIz7uGxOY,8788
8
+ hindsight_langgraph-0.1.1.dist-info/METADATA,sha256=_DLuExAKWVxnY-YFYtNPdYN-KOJDklR3sq8e16PjR8A,1186
9
+ hindsight_langgraph-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ hindsight_langgraph-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any