flowscript-agents 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,280 @@
1
+ """
2
+ FlowScript LangGraph Integration.
3
+
4
+ Implements LangGraph's BaseStore interface, making FlowScript memory
5
+ available as a drop-in store for LangGraph agents and LangMem.
6
+
7
+ Usage:
8
+ from flowscript_agents.langgraph import FlowScriptStore
9
+
10
+ store = FlowScriptStore("./agent-memory.json")
11
+ # Use as LangGraph store — nodes stored as items, queries available via .memory
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections import defaultdict
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Iterable
20
+
21
+ from langgraph.store.base import (
22
+ BaseStore,
23
+ GetOp,
24
+ Item,
25
+ ListNamespacesOp,
26
+ MatchCondition,
27
+ Op,
28
+ PutOp,
29
+ Result,
30
+ SearchItem,
31
+ SearchOp,
32
+ )
33
+
34
+ from .memory import Memory
35
+
36
+
37
+ class FlowScriptStore(BaseStore):
38
+ """LangGraph BaseStore backed by FlowScript reasoning memory.
39
+
40
+ Each item stored maps to a FlowScript node. The store provides standard
41
+ LangGraph get/put/search/delete operations, plus access to FlowScript's
42
+ semantic queries (why, tensions, blocked, alternatives, whatIf) via
43
+ the .memory property.
44
+
45
+ Namespaces map to FlowScript node metadata:
46
+ - Items are stored as nodes with namespace encoded in provenance
47
+ - Search uses FlowScript's content matching
48
+ - Full FlowScript query engine available via .memory.query
49
+ """
50
+
51
+ def __init__(self, file_path: str | None = None) -> None:
52
+ super().__init__()
53
+ if file_path:
54
+ self._memory = Memory.load_or_create(file_path)
55
+ else:
56
+ self._memory = Memory()
57
+ # In-memory index: namespace+key → node_id + value
58
+ self._items: dict[tuple[tuple[str, ...], str], _StoredItem] = {}
59
+ # Rebuild index from loaded memory
60
+ self._rebuild_index()
61
+
62
+ @property
63
+ def memory(self) -> Memory:
64
+ """Access the underlying FlowScript Memory for semantic queries.
65
+
66
+ Example:
67
+ tensions = store.memory.query.tensions()
68
+ blocked = store.memory.query.blocked()
69
+ """
70
+ return self._memory
71
+
72
+ def _rebuild_index(self) -> None:
73
+ """Rebuild the namespace/key index from loaded memory nodes."""
74
+ for ref in self._memory.nodes:
75
+ node = ref.node
76
+ if node.ext and "langgraph_ns" in node.ext:
77
+ ns = tuple(node.ext["langgraph_ns"])
78
+ key = node.ext.get("langgraph_key", node.id)
79
+ value = node.ext.get("langgraph_value", {"content": node.content})
80
+ created = node.provenance.timestamp
81
+ self._items[(ns, key)] = _StoredItem(
82
+ node_id=node.id,
83
+ namespace=ns,
84
+ key=key,
85
+ value=value,
86
+ created_at=_parse_dt(created),
87
+ updated_at=_parse_dt(created),
88
+ )
89
+
90
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
91
+ results: list[Result] = []
92
+ for op in ops:
93
+ if isinstance(op, GetOp):
94
+ results.append(self._handle_get(op))
95
+ elif isinstance(op, PutOp):
96
+ self._handle_put(op)
97
+ results.append(None)
98
+ elif isinstance(op, SearchOp):
99
+ results.append(self._handle_search(op))
100
+ elif isinstance(op, ListNamespacesOp):
101
+ results.append(self._handle_list_namespaces(op))
102
+ else:
103
+ raise ValueError(f"Unknown op type: {type(op)}")
104
+ return results
105
+
106
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
107
+ # Synchronous implementation is sufficient for file-based store
108
+ return self.batch(ops)
109
+
110
+ def _handle_get(self, op: GetOp) -> Item | None:
111
+ stored = self._items.get((op.namespace, op.key))
112
+ if stored is None:
113
+ return None
114
+ return Item(
115
+ namespace=stored.namespace,
116
+ key=stored.key,
117
+ value=stored.value,
118
+ created_at=stored.created_at,
119
+ updated_at=stored.updated_at,
120
+ )
121
+
122
+ def _handle_put(self, op: PutOp) -> None:
123
+ ns = op.namespace
124
+ key = op.key
125
+
126
+ if op.value is None:
127
+ # Delete: remove from index AND from FlowScript graph
128
+ stored = self._items.pop((ns, key), None)
129
+ if stored:
130
+ self._memory.remove_node(stored.node_id)
131
+ return
132
+
133
+ # Upsert: create or update
134
+ existing = self._items.get((ns, key))
135
+ now = datetime.now(timezone.utc)
136
+
137
+ # Determine content for the FlowScript node
138
+ content = op.value.get("content") or op.value.get("memory") or json.dumps(op.value)
139
+
140
+ if existing:
141
+ # Remove old node, create new one with updated content
142
+ self._memory.remove_node(existing.node_id)
143
+ ref = self._memory.thought(content)
144
+ node = ref.node
145
+ node.ext = node.ext or {}
146
+ node.ext["langgraph_ns"] = list(ns)
147
+ node.ext["langgraph_key"] = key
148
+ node.ext["langgraph_value"] = op.value
149
+ existing.node_id = ref.id
150
+ existing.value = op.value
151
+ existing.updated_at = now
152
+ else:
153
+ # Create new node
154
+ ref = self._memory.thought(content)
155
+ node = ref.node
156
+ node.ext = node.ext or {}
157
+ node.ext["langgraph_ns"] = list(ns)
158
+ node.ext["langgraph_key"] = key
159
+ node.ext["langgraph_value"] = op.value
160
+
161
+ self._items[(ns, key)] = _StoredItem(
162
+ node_id=ref.id,
163
+ namespace=ns,
164
+ key=key,
165
+ value=op.value,
166
+ created_at=now,
167
+ updated_at=now,
168
+ )
169
+
170
+ def _handle_search(self, op: SearchOp) -> list[SearchItem]:
171
+ results: list[SearchItem] = []
172
+ prefix = op.namespace_prefix
173
+
174
+ for (ns, key), stored in self._items.items():
175
+ # Check namespace prefix match
176
+ if len(ns) < len(prefix) or ns[: len(prefix)] != prefix:
177
+ continue
178
+
179
+ # Apply filter
180
+ if op.filter:
181
+ if not _matches_filter(stored.value, op.filter):
182
+ continue
183
+
184
+ # Score by query match (simple content matching)
185
+ score = 0.0
186
+ if op.query:
187
+ content_str = json.dumps(stored.value).lower()
188
+ query_lower = op.query.lower()
189
+ if query_lower in content_str:
190
+ # Simple relevance: ratio of query length to content length
191
+ score = len(query_lower) / max(len(content_str), 1)
192
+ else:
193
+ # No match — skip if query was specified
194
+ continue
195
+
196
+ results.append(
197
+ SearchItem(
198
+ namespace=stored.namespace,
199
+ key=stored.key,
200
+ value=stored.value,
201
+ created_at=stored.created_at,
202
+ updated_at=stored.updated_at,
203
+ score=score,
204
+ )
205
+ )
206
+
207
+ # Sort by score descending, then by recency descending
208
+ results.sort(key=lambda x: (-(x.score or 0), -x.updated_at.timestamp()))
209
+
210
+ # Apply offset and limit
211
+ return results[op.offset : op.offset + op.limit]
212
+
213
+ def _handle_list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
214
+ namespaces: set[tuple[str, ...]] = set()
215
+
216
+ for (ns, _key) in self._items:
217
+ # Apply max_depth truncation
218
+ effective_ns = ns
219
+ if op.max_depth is not None:
220
+ effective_ns = ns[: op.max_depth]
221
+
222
+ # Apply match conditions
223
+ matches = True
224
+ for cond in op.match_conditions:
225
+ if cond.match_type == "prefix":
226
+ if len(effective_ns) < len(cond.path) or effective_ns[: len(cond.path)] != tuple(cond.path):
227
+ matches = False
228
+ break
229
+ elif cond.match_type == "suffix":
230
+ if len(effective_ns) < len(cond.path) or effective_ns[-len(cond.path) :] != tuple(cond.path):
231
+ matches = False
232
+ break
233
+
234
+ if matches:
235
+ namespaces.add(effective_ns)
236
+
237
+ sorted_ns = sorted(namespaces)
238
+ return sorted_ns[op.offset : op.offset + op.limit]
239
+
240
+ def save(self) -> None:
241
+ """Persist the store to disk."""
242
+ self._memory.save()
243
+
244
+
245
+ class _StoredItem:
246
+ """Internal storage for items with their metadata."""
247
+
248
+ __slots__ = ("node_id", "namespace", "key", "value", "created_at", "updated_at")
249
+
250
+ def __init__(
251
+ self,
252
+ node_id: str,
253
+ namespace: tuple[str, ...],
254
+ key: str,
255
+ value: dict[str, Any],
256
+ created_at: datetime,
257
+ updated_at: datetime,
258
+ ) -> None:
259
+ self.node_id = node_id
260
+ self.namespace = namespace
261
+ self.key = key
262
+ self.value = value
263
+ self.created_at = created_at
264
+ self.updated_at = updated_at
265
+
266
+
267
+ def _matches_filter(value: dict[str, Any], filter_dict: dict[str, Any]) -> bool:
268
+ """Check if a value dict matches all filter criteria."""
269
+ for k, v in filter_dict.items():
270
+ if k not in value or value[k] != v:
271
+ return False
272
+ return True
273
+
274
+
275
+ def _parse_dt(s: str) -> datetime:
276
+ """Parse ISO-8601 datetime string."""
277
+ try:
278
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
279
+ except (ValueError, AttributeError):
280
+ return datetime.now(timezone.utc)