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.
@@ -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
+ [![Tests](https://github.com/mauriciosneira/memory-layer/actions/workflows/test.yml/badge.svg)](https://github.com/mauriciosneira/memory-layer/actions/workflows/test.yml)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.
@@ -0,0 +1,4 @@
1
+ from .store import DynamoDBStore
2
+ from .types import MemoryRecord, MemoryType, MemoryScope
3
+
4
+ __all__ = ["DynamoDBStore", "MemoryRecord", "MemoryType", "MemoryScope"]
@@ -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)