langgraph-dynamodb-store 0.1.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.
- langgraph_dynamodb_store-0.1.0.dist-info/METADATA +204 -0
- langgraph_dynamodb_store-0.1.0.dist-info/RECORD +9 -0
- langgraph_dynamodb_store-0.1.0.dist-info/WHEEL +4 -0
- langgraph_dynamodb_store-0.1.0.dist-info/licenses/LICENSE +21 -0
- memory_layer/__init__.py +4 -0
- memory_layer/retrieval.py +35 -0
- memory_layer/store.py +159 -0
- memory_layer/types.py +15 -0
- memory_layer/writer.py +75 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langgraph-dynamodb-store
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A DynamoDB-backed BaseStore for LangGraph — cross-thread memory for agents, no Postgres/pgvector required
|
|
5
|
+
Project-URL: Homepage, https://github.com/mauriciosneira/memory-layer
|
|
6
|
+
Project-URL: Issues, https://github.com/mauriciosneira/memory-layer/issues
|
|
7
|
+
Author: Mauricio Neira
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,dynamodb,langchain,langgraph,llm,memory
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: boto3>=1.35
|
|
20
|
+
Requires-Dist: langchain-core>=0.3
|
|
21
|
+
Requires-Dist: langgraph>=0.2
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: moto[dynamodb]>=5.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: semantic
|
|
28
|
+
Requires-Dist: langchain-openai>=0.2; extra == 'semantic'
|
|
29
|
+
Requires-Dist: numpy>=1.26; extra == 'semantic'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# memory-layer
|
|
33
|
+
|
|
34
|
+
[](https://github.com/mauriciosneira/memory-layer/actions/workflows/test.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**A DynamoDB-backed `BaseStore` for LangGraph — cross-thread memory for your agents without standing up Postgres/pgvector.**
|
|
38
|
+
|
|
39
|
+
LangGraph's checkpointer persists state *inside* a thread — when a user opens a new conversation, the graph starts from zero. The `store` protocol is LangGraph's answer to that: memory that survives *across* threads, shared by every session for the same user (or the same tenant). LangGraph ships an official store for Postgres. If your stack is already DynamoDB — which a lot of serverless/Fargate deployments are — there wasn't an official option. `memory-layer` is that option.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from memory_layer import DynamoDBStore
|
|
43
|
+
|
|
44
|
+
store = DynamoDBStore(table_name="my-app-memories")
|
|
45
|
+
graph = builder.compile(checkpointer=checkpointer, store=store)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's the whole integration. No new infra beyond one DynamoDB table you probably already know how to provision.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Why this exists
|
|
53
|
+
|
|
54
|
+
- **You're already on DynamoDB.** Adding Postgres + pgvector just for agent memory is a real infra cost — a new engine, a new backup story, a new thing to monitor — for a feature that, for most products, doesn't need vector search on day one.
|
|
55
|
+
- **LangGraph's `store` protocol is a clean seam.** It's designed so storage is swappable — your agent code shouldn't care whether memories live in Postgres, Dynamo, or Redis. This fills the Dynamo gap in that seam.
|
|
56
|
+
- **Memory doesn't have to mean embeddings.** Most products get real value from "the last N things we know about this user," fetched by recency — no vector index required. `memory-layer` starts there, and gives you a clean place to add semantic ranking later if you actually need it.
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
- **`DynamoDBStore`** — a complete `langgraph.store.base.BaseStore` implementation: `get`/`put`/`search` (and their async counterparts), real pagination via DynamoDB's `LastEvaluatedKey` (not a "hope the first page has enough" heuristic), per-item filtering, and per-type TTL (e.g. auto-expire `episodic` memories after 90 days while `semantic` ones never expire).
|
|
61
|
+
- **`SimpleRetrieval`** — fetch a user's N most recent memories and turn them into a ready-to-inject prompt block. No embeddings, no extra dependencies.
|
|
62
|
+
- **`MemoryWriter`** — an LLM-driven extraction step: hand it a conversation, it classifies and persists the facts worth remembering, via `with_structured_output` (a real schema-validated response, not a hand-rolled JSON parser hoping the model didn't wrap the array in a sentence).
|
|
63
|
+
- **Scopes, not just users.** Namespaces are plain tuples (`("user", user_id)`, `("instance", tenant_id)`) — model per-user memory, per-tenant shared context, or your own scope, however your product actually shapes ownership.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install langgraph-dynamodb-store
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Want LLM-driven extraction? `MemoryWriter` takes any LangChain `BaseChatModel` — bring the one you already use, no extra install needed. `numpy`/`langchain-openai` are only required for the semantic-retrieval extra (see [Roadmap](#roadmap)):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install "langgraph-dynamodb-store[semantic]"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## DynamoDB table
|
|
78
|
+
|
|
79
|
+
One table, one GSI. Create it however you provision infra (CDK/Terraform/console) — here's the raw shape via the AWS CLI, if you just want to try it out:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
aws dynamodb create-table \
|
|
83
|
+
--table-name my-app-memories \
|
|
84
|
+
--attribute-definitions \
|
|
85
|
+
AttributeName=owner_id,AttributeType=S \
|
|
86
|
+
AttributeName=memory_id,AttributeType=S \
|
|
87
|
+
AttributeName=created_at,AttributeType=S \
|
|
88
|
+
--key-schema \
|
|
89
|
+
AttributeName=owner_id,KeyType=HASH \
|
|
90
|
+
AttributeName=memory_id,KeyType=RANGE \
|
|
91
|
+
--global-secondary-indexes '[{
|
|
92
|
+
"IndexName": "owner_id-created_at-index",
|
|
93
|
+
"KeySchema": [
|
|
94
|
+
{"AttributeName": "owner_id", "KeyType": "HASH"},
|
|
95
|
+
{"AttributeName": "created_at", "KeyType": "RANGE"}
|
|
96
|
+
],
|
|
97
|
+
"Projection": {"ProjectionType": "ALL"}
|
|
98
|
+
}]' \
|
|
99
|
+
--billing-mode PAY_PER_REQUEST
|
|
100
|
+
|
|
101
|
+
# Optional but recommended — lets episodic memories actually expire instead of
|
|
102
|
+
# accumulating forever. memory-layer sets the `ttl` attribute; DynamoDB does the rest.
|
|
103
|
+
aws dynamodb update-time-to-live \
|
|
104
|
+
--table-name my-app-memories \
|
|
105
|
+
--time-to-live-specification "Enabled=true, AttributeName=ttl"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Set `MEMORY_TABLE=my-app-memories` or pass `table_name` explicitly — `DynamoDBStore(table_name="my-app-memories")`.
|
|
109
|
+
|
|
110
|
+
## Quickstart
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from memory_layer import DynamoDBStore
|
|
114
|
+
|
|
115
|
+
store = DynamoDBStore(table_name="my-app-memories")
|
|
116
|
+
|
|
117
|
+
namespace = ("user", "user-123")
|
|
118
|
+
|
|
119
|
+
store.put(namespace, "mem-1", {"content": "Prefers responses in Spanish", "type": "semantic"})
|
|
120
|
+
store.put(namespace, "mem-2", {"content": "Reviewed Q3 numbers on 2026-08-01", "type": "episodic"})
|
|
121
|
+
|
|
122
|
+
memories = store.search(namespace, limit=10)
|
|
123
|
+
for m in memories:
|
|
124
|
+
print(m.value["content"])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Inside a LangGraph node
|
|
128
|
+
|
|
129
|
+
LangGraph injects `store` into any node whose signature asks for it:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from langgraph.store.base import BaseStore
|
|
133
|
+
from langchain_core.runnables import RunnableConfig
|
|
134
|
+
|
|
135
|
+
from memory_layer import DynamoDBStore
|
|
136
|
+
from memory_layer.retrieval import SimpleRetrieval
|
|
137
|
+
|
|
138
|
+
store = DynamoDBStore(table_name="my-app-memories")
|
|
139
|
+
graph = builder.compile(checkpointer=checkpointer, store=store)
|
|
140
|
+
|
|
141
|
+
def supervisor_node(state: AgentState, config: RunnableConfig, store: BaseStore) -> dict:
|
|
142
|
+
user_id = state["context"]["user_id"]
|
|
143
|
+
retrieval = SimpleRetrieval(store, limit=5)
|
|
144
|
+
memories = retrieval.fetch(("user", user_id))
|
|
145
|
+
memory_block = retrieval.to_prompt_block(memories)
|
|
146
|
+
# ...inject memory_block into the system prompt
|
|
147
|
+
return {}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Writing memories back
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from memory_layer.writer import MemoryWriter
|
|
154
|
+
|
|
155
|
+
writer = MemoryWriter(llm=your_chat_model, store=store)
|
|
156
|
+
|
|
157
|
+
async def memory_writer_node(state: AgentState) -> dict:
|
|
158
|
+
await writer.extract_and_save(
|
|
159
|
+
namespace=("user", state["context"]["user_id"]),
|
|
160
|
+
messages=state["messages"],
|
|
161
|
+
session_id=state["context"]["session_id"],
|
|
162
|
+
)
|
|
163
|
+
return {}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The extraction LLM only needs `with_structured_output` support — every major provider's LangChain integration has it.
|
|
167
|
+
|
|
168
|
+
## Memory types
|
|
169
|
+
|
|
170
|
+
| Type | What it's for | Default TTL |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `semantic` | Stable preferences and facts ("prefers Spanish", "works on the Acme account") | none |
|
|
173
|
+
| `episodic` | Specific past events ("reviewed Q3 numbers on 2026-08-01") | 90 days |
|
|
174
|
+
| `procedural` | Recurring work patterns ("always starts with a channel breakdown") | none |
|
|
175
|
+
|
|
176
|
+
TTL policy per type lives in `memory_layer.store._TTL_SECONDS_BY_TYPE` — override it if 90 days isn't the right default for your product.
|
|
177
|
+
|
|
178
|
+
## Scopes
|
|
179
|
+
|
|
180
|
+
A namespace is just a tuple — `memory-layer` doesn't prescribe what it means, but the common shapes are:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
("user", cognito_sub) # private to one user
|
|
184
|
+
("instance", tenant_id) # shared across every user of one tenant
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Roadmap
|
|
188
|
+
|
|
189
|
+
- **Semantic retrieval** — embed memories + query, rank by cosine similarity, for products that outgrow "most recent N" (roughly ~20+ memories per user is where this starts to matter). Lives behind the `semantic` extra so the core library stays dependency-light.
|
|
190
|
+
- **Deduplication on write** — skip persisting a fact that's a near-duplicate of one already stored.
|
|
191
|
+
- **Bring-your-own embeddings backend** — DynamoDB-native cosine similarity to start; pluggable enough to swap in pgvector/a real vector store later if volume ever justifies it.
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
pip install -e ".[dev]"
|
|
197
|
+
pytest
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Tests run against [`moto`](https://github.com/getmoto/moto) — no real AWS account or network access required.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
memory_layer/__init__.py,sha256=axH8XyMwwTLz3fgKeZzaBN14Pn6ZLtry-_482sGj8FI,163
|
|
2
|
+
memory_layer/retrieval.py,sha256=vEl94VUmrcg6upkazyk4Ru6yBP4vSj4SVT2OOyhVpVo,1144
|
|
3
|
+
memory_layer/store.py,sha256=1xsJguNy31-z88KClBalznPGX_5secCDa2WGRDPUNT8,5744
|
|
4
|
+
memory_layer/types.py,sha256=NsYpvjnicO-bjIiWWcIRfO3sxgpPDuMPB8pf6gGLX3w,356
|
|
5
|
+
memory_layer/writer.py,sha256=Rgx4ko9WnF3Of4dtfAElJUur3gsJZq6Kp9v_f70pb3g,2603
|
|
6
|
+
langgraph_dynamodb_store-0.1.0.dist-info/METADATA,sha256=06rbdrq9nLs8Rlq12lYCkf8ZPSzXzZakmDYrsC4sgFY,9199
|
|
7
|
+
langgraph_dynamodb_store-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
langgraph_dynamodb_store-0.1.0.dist-info/licenses/LICENSE,sha256=f9c-ka2i0rKaqbPVdW-9crUoQtg68EOKK9MoHORUs3I,1071
|
|
9
|
+
langgraph_dynamodb_store-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mauricio Neira
|
|
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.
|
memory_layer/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from langgraph.store.base import BaseStore, SearchItem
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SimpleRetrieval:
|
|
7
|
+
"""Fetches N most recent memories by recency. No embeddings required."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, store: BaseStore, limit: int = 10) -> None:
|
|
10
|
+
self._store = store
|
|
11
|
+
self._limit = limit
|
|
12
|
+
|
|
13
|
+
def fetch(
|
|
14
|
+
self,
|
|
15
|
+
namespace: tuple[str, ...],
|
|
16
|
+
*,
|
|
17
|
+
memory_type: str | None = None,
|
|
18
|
+
) -> list[SearchItem]:
|
|
19
|
+
filter_ = {"type": memory_type} if memory_type else None
|
|
20
|
+
return self._store.search(namespace, filter=filter_, limit=self._limit)
|
|
21
|
+
|
|
22
|
+
async def afetch(
|
|
23
|
+
self,
|
|
24
|
+
namespace: tuple[str, ...],
|
|
25
|
+
*,
|
|
26
|
+
memory_type: str | None = None,
|
|
27
|
+
) -> list[SearchItem]:
|
|
28
|
+
filter_ = {"type": memory_type} if memory_type else None
|
|
29
|
+
return await self._store.asearch(namespace, filter=filter_, limit=self._limit)
|
|
30
|
+
|
|
31
|
+
def to_prompt_block(self, memories: list[SearchItem]) -> str:
|
|
32
|
+
if not memories:
|
|
33
|
+
return ""
|
|
34
|
+
lines = "\n".join(f"- {m.value.get('content', '')}" for m in memories)
|
|
35
|
+
return f"## User memory\n{lines}"
|
memory_layer/store.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
from boto3.dynamodb.conditions import Key
|
|
11
|
+
from langgraph.store.base import (
|
|
12
|
+
BaseStore,
|
|
13
|
+
GetOp,
|
|
14
|
+
Item,
|
|
15
|
+
ListNamespacesOp,
|
|
16
|
+
Op,
|
|
17
|
+
PutOp,
|
|
18
|
+
Result,
|
|
19
|
+
SearchItem,
|
|
20
|
+
SearchOp,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_TABLE_NAME = os.environ.get("MEMORY_TABLE", "memory-layer-local-memories")
|
|
24
|
+
_MAX_SEARCH_PAGES = 10
|
|
25
|
+
_TTL_SECONDS_BY_TYPE = {"episodic": 90 * 24 * 60 * 60} # semantic/procedural: no ttl attribute, never expire
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _owner_id(namespace: tuple[str, ...]) -> str:
|
|
29
|
+
return ":".join(namespace)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_namespace(owner_id: str) -> tuple[str, ...]:
|
|
33
|
+
return tuple(owner_id.split(":"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _row_to_item(row: dict[str, Any]) -> Item:
|
|
37
|
+
return Item(
|
|
38
|
+
namespace=_parse_namespace(row["owner_id"]),
|
|
39
|
+
key=row["memory_id"],
|
|
40
|
+
value=json.loads(row["value"]),
|
|
41
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
42
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _row_to_search_item(row: dict[str, Any]) -> SearchItem:
|
|
47
|
+
return SearchItem(
|
|
48
|
+
namespace=_parse_namespace(row["owner_id"]),
|
|
49
|
+
key=row["memory_id"],
|
|
50
|
+
value=json.loads(row["value"]),
|
|
51
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
52
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
53
|
+
score=None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _matches_filter(value: dict[str, Any], filter: dict[str, Any]) -> bool:
|
|
58
|
+
for field, condition in filter.items():
|
|
59
|
+
field_val = value.get(field)
|
|
60
|
+
if isinstance(condition, dict):
|
|
61
|
+
for op, operand in condition.items():
|
|
62
|
+
if op == "$eq" and field_val != operand:
|
|
63
|
+
return False
|
|
64
|
+
elif op == "$ne" and field_val == operand:
|
|
65
|
+
return False
|
|
66
|
+
elif op == "$gt" and not (field_val is not None and field_val > operand):
|
|
67
|
+
return False
|
|
68
|
+
elif op == "$gte" and not (field_val is not None and field_val >= operand):
|
|
69
|
+
return False
|
|
70
|
+
elif op == "$lt" and not (field_val is not None and field_val < operand):
|
|
71
|
+
return False
|
|
72
|
+
elif op == "$lte" and not (field_val is not None and field_val <= operand):
|
|
73
|
+
return False
|
|
74
|
+
elif field_val != condition:
|
|
75
|
+
return False
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DynamoDBStore(BaseStore):
|
|
80
|
+
def __init__(self, table_name: str = _TABLE_NAME) -> None:
|
|
81
|
+
self._table = boto3.resource("dynamodb").Table(table_name)
|
|
82
|
+
|
|
83
|
+
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
84
|
+
results: list[Result] = []
|
|
85
|
+
for op in ops:
|
|
86
|
+
if isinstance(op, GetOp):
|
|
87
|
+
results.append(self._handle_get(op))
|
|
88
|
+
elif isinstance(op, PutOp):
|
|
89
|
+
results.append(self._handle_put(op))
|
|
90
|
+
elif isinstance(op, SearchOp):
|
|
91
|
+
results.append(self._handle_search(op))
|
|
92
|
+
elif isinstance(op, ListNamespacesOp):
|
|
93
|
+
results.append([])
|
|
94
|
+
else:
|
|
95
|
+
raise TypeError(f"Unsupported op type: {type(op).__name__}")
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
99
|
+
return await asyncio.to_thread(self.batch, list(ops))
|
|
100
|
+
|
|
101
|
+
def _handle_get(self, op: GetOp) -> Item | None:
|
|
102
|
+
resp = self._table.get_item(
|
|
103
|
+
Key={"owner_id": _owner_id(op.namespace), "memory_id": op.key}
|
|
104
|
+
)
|
|
105
|
+
row = resp.get("Item")
|
|
106
|
+
return _row_to_item(row) if row else None
|
|
107
|
+
|
|
108
|
+
def _handle_put(self, op: PutOp) -> None:
|
|
109
|
+
pk = {"owner_id": _owner_id(op.namespace), "memory_id": op.key}
|
|
110
|
+
if op.value is None:
|
|
111
|
+
self._table.delete_item(Key=pk)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
115
|
+
update_expression = (
|
|
116
|
+
"SET #v = :v, updated_at = :now, "
|
|
117
|
+
"created_at = if_not_exists(created_at, :now)"
|
|
118
|
+
)
|
|
119
|
+
expression_values: dict[str, Any] = {":v": json.dumps(op.value), ":now": now}
|
|
120
|
+
|
|
121
|
+
ttl_seconds = _TTL_SECONDS_BY_TYPE.get(op.value.get("type"))
|
|
122
|
+
if ttl_seconds is not None:
|
|
123
|
+
update_expression += ", #ttl = :ttl"
|
|
124
|
+
expression_values[":ttl"] = int(datetime.now(timezone.utc).timestamp()) + ttl_seconds
|
|
125
|
+
|
|
126
|
+
self._table.update_item(
|
|
127
|
+
Key=pk,
|
|
128
|
+
UpdateExpression=update_expression,
|
|
129
|
+
ExpressionAttributeNames={"#v": "value", "#ttl": "ttl"} if ttl_seconds is not None else {"#v": "value"},
|
|
130
|
+
ExpressionAttributeValues=expression_values,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _handle_search(self, op: SearchOp) -> list[SearchItem]:
|
|
134
|
+
matches: list[dict[str, Any]] = []
|
|
135
|
+
last_evaluated_key: dict[str, Any] | None = None
|
|
136
|
+
pages = 0
|
|
137
|
+
|
|
138
|
+
while len(matches) < op.offset + op.limit and pages < _MAX_SEARCH_PAGES:
|
|
139
|
+
query_kwargs: dict[str, Any] = {
|
|
140
|
+
"IndexName": "owner_id-created_at-index",
|
|
141
|
+
"KeyConditionExpression": Key("owner_id").eq(_owner_id(op.namespace_prefix)),
|
|
142
|
+
"ScanIndexForward": False,
|
|
143
|
+
}
|
|
144
|
+
if last_evaluated_key is not None:
|
|
145
|
+
query_kwargs["ExclusiveStartKey"] = last_evaluated_key
|
|
146
|
+
|
|
147
|
+
resp = self._table.query(**query_kwargs)
|
|
148
|
+
rows = resp.get("Items", [])
|
|
149
|
+
pages += 1
|
|
150
|
+
|
|
151
|
+
if op.filter:
|
|
152
|
+
rows = [r for r in rows if _matches_filter(json.loads(r["value"]), op.filter)]
|
|
153
|
+
matches.extend(rows)
|
|
154
|
+
|
|
155
|
+
last_evaluated_key = resp.get("LastEvaluatedKey")
|
|
156
|
+
if last_evaluated_key is None:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
return [_row_to_search_item(r) for r in matches[op.offset : op.offset + op.limit]]
|
memory_layer/types.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Literal, TypedDict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
MemoryType = Literal["semantic", "episodic", "procedural"]
|
|
6
|
+
MemoryScope = Literal["user", "instance"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryRecord(TypedDict, total=False):
|
|
10
|
+
content: str
|
|
11
|
+
type: MemoryType
|
|
12
|
+
created_at: str
|
|
13
|
+
updated_at: str
|
|
14
|
+
session_id: str
|
|
15
|
+
score: Optional[float]
|
memory_layer/writer.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage
|
|
6
|
+
from langchain_core.language_models import BaseChatModel
|
|
7
|
+
from langgraph.store.base import BaseStore
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .types import MemoryType
|
|
11
|
+
|
|
12
|
+
_EXTRACTION_PROMPT = """You are a memory extraction assistant. Given a conversation, extract facts worth remembering about the user for future sessions.
|
|
13
|
+
|
|
14
|
+
Rules:
|
|
15
|
+
- Only extract facts that would be useful in a *future* conversation — preferences, recurring patterns, key domain context.
|
|
16
|
+
- Ignore facts that are specific to this single request and won't generalise.
|
|
17
|
+
- Each fact must be a short, standalone sentence.
|
|
18
|
+
- Classify each fact as one of: semantic (preferences/facts about the user), episodic (specific past event), procedural (recurring work pattern).
|
|
19
|
+
- If nothing is worth remembering, return no facts."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ExtractedFact(BaseModel):
|
|
23
|
+
content: str
|
|
24
|
+
type: MemoryType
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ExtractedFacts(BaseModel):
|
|
28
|
+
facts: list[_ExtractedFact]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemoryWriter:
|
|
32
|
+
def __init__(self, llm: BaseChatModel, store: BaseStore) -> None:
|
|
33
|
+
self._llm = llm
|
|
34
|
+
self._store = store
|
|
35
|
+
|
|
36
|
+
async def extract_and_save(
|
|
37
|
+
self,
|
|
38
|
+
namespace: tuple[str, ...],
|
|
39
|
+
messages: list[BaseMessage],
|
|
40
|
+
session_id: str,
|
|
41
|
+
) -> int:
|
|
42
|
+
conversation = _format_conversation(messages)
|
|
43
|
+
if not conversation:
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
extraction_messages = [
|
|
47
|
+
SystemMessage(content=_EXTRACTION_PROMPT),
|
|
48
|
+
HumanMessage(content=conversation),
|
|
49
|
+
]
|
|
50
|
+
# with_structured_output validates the shape via the provider's own tool-calling —
|
|
51
|
+
# no hand-rolled JSON parsing, no risk of a stray sentence around the array breaking it.
|
|
52
|
+
structured_llm = self._llm.with_structured_output(_ExtractedFacts)
|
|
53
|
+
result: _ExtractedFacts = await structured_llm.ainvoke(extraction_messages)
|
|
54
|
+
|
|
55
|
+
for fact in result.facts:
|
|
56
|
+
await self._store.aput(
|
|
57
|
+
namespace,
|
|
58
|
+
str(uuid4()),
|
|
59
|
+
{
|
|
60
|
+
"content": fact.content,
|
|
61
|
+
"type": fact.type,
|
|
62
|
+
"session_id": session_id,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return len(result.facts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_conversation(messages: list[BaseMessage]) -> str:
|
|
70
|
+
lines: list[str] = []
|
|
71
|
+
for msg in messages:
|
|
72
|
+
role = getattr(msg, "type", "unknown")
|
|
73
|
+
if role in ("human", "ai") and msg.content:
|
|
74
|
+
lines.append(f"{role.upper()}: {msg.content}")
|
|
75
|
+
return "\n".join(lines)
|