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,504 @@
1
+ """
2
+ FlowScript Memory — Python implementation.
3
+
4
+ Programmatic builder for FlowScript IR graphs. The Python equivalent of
5
+ flowscript-core's Memory class, built on top of flowscript-ldp's IR types
6
+ and query engine.
7
+
8
+ Design:
9
+ - IR is the internal representation (same schema as TypeScript)
10
+ - Content-hash deduplication drives frequency tracking
11
+ - Query engine refreshes when IR changes
12
+ - JSON is canonical persistence
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from flowscript_ldp.ir import (
24
+ IR,
25
+ GraphInvariants,
26
+ IRMetadata,
27
+ Node,
28
+ NodeModifier,
29
+ NodeType,
30
+ Provenance,
31
+ Relationship,
32
+ RelationType,
33
+ State,
34
+ StateFields,
35
+ StateType,
36
+ )
37
+ from flowscript_ldp.query import QueryEngine
38
+
39
+
40
+ def _hash_content(content: str, node_type: str) -> str:
41
+ """Generate SHA-256 content hash matching TypeScript hashContent."""
42
+ raw = f"{node_type}:{content}"
43
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
44
+
45
+
46
+ def _now_iso() -> str:
47
+ return datetime.now(timezone.utc).isoformat()
48
+
49
+
50
+ def _make_provenance(source: str = "memory-api") -> Provenance:
51
+ return Provenance(
52
+ source_file=source,
53
+ line_number=1,
54
+ timestamp=_now_iso(),
55
+ )
56
+
57
+
58
+ class NodeRef:
59
+ """Fluent reference handle for a node in the memory graph."""
60
+
61
+ def __init__(self, memory: Memory, node: Node) -> None:
62
+ self._memory = memory
63
+ self._node = node
64
+
65
+ @property
66
+ def id(self) -> str:
67
+ return self._node.id
68
+
69
+ @property
70
+ def node(self) -> Node:
71
+ return self._node
72
+
73
+ @property
74
+ def type(self) -> NodeType:
75
+ return self._node.type
76
+
77
+ @property
78
+ def content(self) -> str:
79
+ return self._node.content
80
+
81
+ # -- Relationship builders (return self for chaining) --
82
+
83
+ def causes(self, target: NodeRef | str) -> NodeRef:
84
+ """This node causes target."""
85
+ t = self._memory._resolve_ref(target)
86
+ self._memory._add_relationship(self.id, t.id, RelationType.CAUSES)
87
+ return self
88
+
89
+ def then(self, target: NodeRef | str) -> NodeRef:
90
+ """Temporal: this node leads to target."""
91
+ t = self._memory._resolve_ref(target)
92
+ self._memory._add_relationship(self.id, t.id, RelationType.TEMPORAL)
93
+ return self
94
+
95
+ def derives_from(self, source: NodeRef | str) -> NodeRef:
96
+ """This node derives from source."""
97
+ s = self._memory._resolve_ref(source)
98
+ self._memory._add_relationship(s.id, self.id, RelationType.DERIVES_FROM)
99
+ return self
100
+
101
+ def tension_with(self, target: NodeRef | str, axis: str) -> NodeRef:
102
+ """Tension between this and target on named axis."""
103
+ t = self._memory._resolve_ref(target)
104
+ self._memory._add_relationship(
105
+ self.id, t.id, RelationType.TENSION, axis_label=axis
106
+ )
107
+ return self
108
+
109
+ def bidirectional(self, target: NodeRef | str) -> NodeRef:
110
+ """Bidirectional relationship."""
111
+ t = self._memory._resolve_ref(target)
112
+ self._memory._add_relationship(self.id, t.id, RelationType.BIDIRECTIONAL)
113
+ return self
114
+
115
+ # -- State builders (return self for chaining) --
116
+
117
+ def decide(self, rationale: str, on: Optional[str] = None) -> NodeRef:
118
+ """Mark as decided."""
119
+ fields = StateFields(rationale=rationale, on=on or _now_iso())
120
+ self._memory._add_state(self.id, StateType.DECIDED, fields)
121
+ return self
122
+
123
+ def block(self, reason: str, since: Optional[str] = None) -> NodeRef:
124
+ """Mark as blocked."""
125
+ fields = StateFields(reason=reason, since=since or _now_iso())
126
+ self._memory._add_state(self.id, StateType.BLOCKED, fields)
127
+ return self
128
+
129
+ def park(self, why: str, until: Optional[str] = None) -> NodeRef:
130
+ """Mark as parked."""
131
+ fields = StateFields(why=why, until=until)
132
+ self._memory._add_state(self.id, StateType.PARKING, fields)
133
+ return self
134
+
135
+ def explore(self) -> NodeRef:
136
+ """Mark as exploring."""
137
+ self._memory._add_state(self.id, StateType.EXPLORING)
138
+ return self
139
+
140
+ def unblock(self) -> NodeRef:
141
+ """Remove blocked states."""
142
+ self._memory._remove_states(self.id, StateType.BLOCKED)
143
+ return self
144
+
145
+ def __repr__(self) -> str:
146
+ return f"NodeRef({self.type.value}: {self.content!r})"
147
+
148
+
149
+ class _QueryProxy:
150
+ """Lazy query engine that refreshes when IR changes."""
151
+
152
+ def __init__(self, memory: Memory) -> None:
153
+ self._memory = memory
154
+ self._engine: Optional[QueryEngine] = None
155
+ self._dirty = True
156
+
157
+ def _mark_dirty(self) -> None:
158
+ self._dirty = True
159
+
160
+ def _get_engine(self) -> QueryEngine:
161
+ if self._dirty or self._engine is None:
162
+ self._engine = QueryEngine(self._memory.to_ir())
163
+ self._dirty = False
164
+ return self._engine
165
+
166
+ def why(self, node_id: str, **kwargs: Any) -> Any:
167
+ return self._get_engine().why(node_id, **kwargs)
168
+
169
+ def what_if(self, node_id: str, **kwargs: Any) -> Any:
170
+ return self._get_engine().what_if(node_id, **kwargs)
171
+
172
+ def tensions(self, **kwargs: Any) -> Any:
173
+ return self._get_engine().tensions(**kwargs)
174
+
175
+ def blocked(self, **kwargs: Any) -> Any:
176
+ return self._get_engine().blocked(**kwargs)
177
+
178
+ def alternatives(self, question_id: str, **kwargs: Any) -> Any:
179
+ return self._get_engine().alternatives(question_id, **kwargs)
180
+
181
+
182
+ class Memory:
183
+ """
184
+ Programmatic builder for FlowScript reasoning graphs.
185
+
186
+ Mirrors the TypeScript Memory class from flowscript-core.
187
+ Built on flowscript-ldp's IR types and query engine.
188
+ """
189
+
190
+ def __init__(self, source_file: str = "memory-api") -> None:
191
+ self._nodes: dict[str, Node] = {}
192
+ self._relationships: list[Relationship] = []
193
+ self._states: list[State] = []
194
+ self._source_file = source_file
195
+ self._file_path: Optional[str] = None
196
+ self._query = _QueryProxy(self)
197
+
198
+ @property
199
+ def query(self) -> _QueryProxy:
200
+ return self._query
201
+
202
+ @property
203
+ def size(self) -> int:
204
+ return len(self._nodes)
205
+
206
+ @property
207
+ def nodes(self) -> list[NodeRef]:
208
+ return [NodeRef(self, n) for n in self._nodes.values()]
209
+
210
+ @property
211
+ def file_path(self) -> Optional[str]:
212
+ return self._file_path
213
+
214
+ # -- Static constructors --
215
+
216
+ @staticmethod
217
+ def from_ir(ir: IR) -> Memory:
218
+ """Create Memory from an existing IR graph."""
219
+ mem = Memory()
220
+ for node in ir.nodes:
221
+ mem._nodes[node.id] = node
222
+ mem._relationships = list(ir.relationships)
223
+ mem._states = list(ir.states)
224
+ mem._query._mark_dirty()
225
+ return mem
226
+
227
+ @staticmethod
228
+ def load(file_path: str) -> Memory:
229
+ """Load Memory from a JSON file."""
230
+ path = Path(file_path)
231
+ data = json.loads(path.read_text("utf-8"))
232
+ ir = IR.model_validate(data)
233
+ mem = Memory.from_ir(ir)
234
+ mem._file_path = str(path.resolve())
235
+ return mem
236
+
237
+ @staticmethod
238
+ def load_or_create(file_path: str) -> Memory:
239
+ """Load existing memory or create empty. Zero-friction entry point."""
240
+ path = Path(file_path)
241
+ if path.exists():
242
+ return Memory.load(str(path))
243
+ mem = Memory()
244
+ mem._file_path = str(path.resolve())
245
+ return mem
246
+
247
+ @staticmethod
248
+ def from_json(data: dict[str, Any] | str) -> Memory:
249
+ """Create Memory from JSON dict or string."""
250
+ if isinstance(data, str):
251
+ data = json.loads(data)
252
+ ir = IR.model_validate(data)
253
+ return Memory.from_ir(ir)
254
+
255
+ # -- Node creation --
256
+
257
+ def _add_node(self, content: str, node_type: NodeType) -> NodeRef:
258
+ node_id = _hash_content(content, node_type.value)
259
+ if node_id in self._nodes:
260
+ return NodeRef(self, self._nodes[node_id])
261
+
262
+ node = Node(
263
+ id=node_id,
264
+ type=node_type,
265
+ content=content,
266
+ provenance=_make_provenance(self._source_file),
267
+ )
268
+ self._nodes[node_id] = node
269
+ self._query._mark_dirty()
270
+ return NodeRef(self, node)
271
+
272
+ def thought(self, content: str) -> NodeRef:
273
+ return self._add_node(content, NodeType.THOUGHT)
274
+
275
+ def statement(self, content: str) -> NodeRef:
276
+ return self._add_node(content, NodeType.STATEMENT)
277
+
278
+ def question(self, content: str) -> NodeRef:
279
+ return self._add_node(content, NodeType.QUESTION)
280
+
281
+ def action(self, content: str) -> NodeRef:
282
+ return self._add_node(content, NodeType.ACTION)
283
+
284
+ def insight(self, content: str) -> NodeRef:
285
+ return self._add_node(content, NodeType.INSIGHT)
286
+
287
+ def completion(self, content: str) -> NodeRef:
288
+ return self._add_node(content, NodeType.COMPLETION)
289
+
290
+ def alternative(self, question: NodeRef | str, content: str) -> NodeRef:
291
+ """Create an alternative linked to a question node."""
292
+ q = self._resolve_ref(question)
293
+ alt = self._add_node(content, NodeType.ALTERNATIVE)
294
+ self._add_relationship(q.id, alt.id, RelationType.ALTERNATIVE)
295
+ return alt
296
+
297
+ # -- Relationship creation --
298
+
299
+ def _add_relationship(
300
+ self,
301
+ source_id: str,
302
+ target_id: str,
303
+ rel_type: RelationType,
304
+ axis_label: Optional[str] = None,
305
+ ) -> None:
306
+ raw = f"{rel_type.value}:{source_id}:{target_id}"
307
+ if axis_label:
308
+ raw += f":{axis_label}"
309
+ rel_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()
310
+
311
+ # Deduplicate
312
+ for existing in self._relationships:
313
+ if existing.id == rel_id:
314
+ return
315
+
316
+ rel = Relationship(
317
+ id=rel_id,
318
+ type=rel_type,
319
+ source=source_id,
320
+ target=target_id,
321
+ axis_label=axis_label,
322
+ provenance=_make_provenance(self._source_file),
323
+ )
324
+ self._relationships.append(rel)
325
+ self._query._mark_dirty()
326
+
327
+ def tension(
328
+ self, a: NodeRef | str, b: NodeRef | str, axis: str
329
+ ) -> None:
330
+ """Create a tension between two nodes on a named axis."""
331
+ a_ref = self._resolve_ref(a)
332
+ b_ref = self._resolve_ref(b)
333
+ self._add_relationship(a_ref.id, b_ref.id, RelationType.TENSION, axis_label=axis)
334
+
335
+ def relate(
336
+ self,
337
+ source: NodeRef | str,
338
+ target: NodeRef | str,
339
+ rel_type: RelationType,
340
+ axis_label: Optional[str] = None,
341
+ ) -> None:
342
+ """Create an arbitrary relationship."""
343
+ s = self._resolve_ref(source)
344
+ t = self._resolve_ref(target)
345
+ self._add_relationship(s.id, t.id, rel_type, axis_label=axis_label)
346
+
347
+ # -- State management --
348
+
349
+ def _add_state(
350
+ self,
351
+ node_id: str,
352
+ state_type: StateType,
353
+ fields: Optional[StateFields] = None,
354
+ ) -> None:
355
+ raw = f"{state_type.value}:{node_id}"
356
+ state_id = hashlib.sha256(raw.encode("utf-8")).hexdigest()
357
+
358
+ # Remove existing state of same type on same node
359
+ self._states = [
360
+ s for s in self._states
361
+ if not (s.node_id == node_id and s.type == state_type)
362
+ ]
363
+
364
+ state = State(
365
+ id=state_id,
366
+ type=state_type,
367
+ node_id=node_id,
368
+ fields=fields,
369
+ provenance=_make_provenance(self._source_file),
370
+ )
371
+ self._states.append(state)
372
+ self._query._mark_dirty()
373
+
374
+ def _remove_states(
375
+ self, node_id: str, state_type: Optional[StateType] = None
376
+ ) -> int:
377
+ before = len(self._states)
378
+ if state_type:
379
+ self._states = [
380
+ s for s in self._states
381
+ if not (s.node_id == node_id and s.type == state_type)
382
+ ]
383
+ else:
384
+ self._states = [s for s in self._states if s.node_id != node_id]
385
+ removed = before - len(self._states)
386
+ if removed > 0:
387
+ self._query._mark_dirty()
388
+ return removed
389
+
390
+ # -- Lookup --
391
+
392
+ def get_node(self, node_id: str) -> Optional[Node]:
393
+ return self._nodes.get(node_id)
394
+
395
+ def ref(self, node_id: str) -> NodeRef:
396
+ """Get a NodeRef by ID. Raises KeyError if not found."""
397
+ node = self._nodes.get(node_id)
398
+ if node is None:
399
+ raise KeyError(f"No node with id {node_id!r}")
400
+ return NodeRef(self, node)
401
+
402
+ def find_nodes(self, content_match: str) -> list[NodeRef]:
403
+ """Find nodes whose content contains the search string."""
404
+ lower = content_match.lower()
405
+ return [
406
+ NodeRef(self, n)
407
+ for n in self._nodes.values()
408
+ if lower in n.content.lower()
409
+ ]
410
+
411
+ # -- Serialization --
412
+
413
+ def to_ir(self) -> IR:
414
+ """Export as FlowScript IR."""
415
+ return IR(
416
+ version="1.0.0",
417
+ nodes=list(self._nodes.values()),
418
+ relationships=list(self._relationships),
419
+ states=list(self._states),
420
+ invariants=GraphInvariants(),
421
+ metadata=IRMetadata(
422
+ source_files=[self._source_file],
423
+ parsed_at=_now_iso(),
424
+ parser="flowscript-agents",
425
+ ),
426
+ )
427
+
428
+ def to_json(self) -> dict[str, Any]:
429
+ """Export as JSON-serializable dict."""
430
+ return self.to_ir().model_dump(mode="json", exclude_none=True)
431
+
432
+ def to_json_string(self, indent: int = 2) -> str:
433
+ """Export as JSON string."""
434
+ return json.dumps(self.to_json(), indent=indent)
435
+
436
+ def save(self, file_path: Optional[str] = None) -> None:
437
+ """Save memory to JSON file. Uses atomic write (temp + rename)."""
438
+ import tempfile
439
+ import os
440
+
441
+ target = file_path or self._file_path
442
+ if target is None:
443
+ raise ValueError("No file path provided and no stored path")
444
+ path = Path(target)
445
+ path.parent.mkdir(parents=True, exist_ok=True)
446
+
447
+ # Atomic write: write to temp file, then rename
448
+ fd, tmp_path = tempfile.mkstemp(
449
+ dir=str(path.parent), suffix=".tmp", prefix=".flowscript-"
450
+ )
451
+ try:
452
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
453
+ f.write(self.to_json_string())
454
+ os.replace(tmp_path, str(path))
455
+ except Exception:
456
+ # Clean up temp file on failure
457
+ try:
458
+ os.unlink(tmp_path)
459
+ except OSError:
460
+ pass
461
+ raise
462
+
463
+ self._file_path = str(path.resolve())
464
+
465
+ # -- Internal helpers --
466
+
467
+ def remove_node(self, node_id: str) -> bool:
468
+ """Remove a node and its associated relationships and states.
469
+
470
+ Returns True if the node was found and removed, False otherwise.
471
+ """
472
+ if node_id not in self._nodes:
473
+ return False
474
+ del self._nodes[node_id]
475
+ self._relationships = [
476
+ r for r in self._relationships
477
+ if r.source != node_id and r.target != node_id
478
+ ]
479
+ self._states = [s for s in self._states if s.node_id != node_id]
480
+ self._query._mark_dirty()
481
+ return True
482
+
483
+ def _resolve_ref(self, ref: NodeRef | str) -> NodeRef:
484
+ """Resolve a NodeRef or content string to a NodeRef.
485
+
486
+ Resolution order:
487
+ 1. If NodeRef, return as-is
488
+ 2. If string matching an existing node ID, return that node
489
+ 3. Otherwise, create a new thought node with the string as content
490
+ """
491
+ if isinstance(ref, NodeRef):
492
+ return ref
493
+ # Try as node ID first
494
+ if ref in self._nodes:
495
+ return NodeRef(self, self._nodes[ref])
496
+ # Create thought from content string
497
+ return self.thought(ref)
498
+
499
+ def __repr__(self) -> str:
500
+ return (
501
+ f"Memory(nodes={len(self._nodes)}, "
502
+ f"relationships={len(self._relationships)}, "
503
+ f"states={len(self._states)})"
504
+ )
@@ -0,0 +1,170 @@
1
+ """
2
+ FlowScript OpenAI Agents SDK Integration.
3
+
4
+ Implements the OpenAI Agents SDK Session protocol, making FlowScript memory
5
+ available as a session backend for OpenAI agents.
6
+
7
+ Usage:
8
+ from flowscript_agents.openai_agents import FlowScriptSession
9
+
10
+ session = FlowScriptSession("conversation_123", "./agent-memory.json")
11
+ # Use with Runner:
12
+ # result = await Runner.run(agent, "Hello", session=session)
13
+
14
+ Note: Requires openai-agents package: pip install flowscript-agents[openai-agents]
15
+ The Session protocol is for conversation history. For richer FlowScript
16
+ capabilities (compression, semantic queries, temporal tiers), access
17
+ session.memory directly or expose as agent tools.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from datetime import datetime, timezone
24
+ from typing import Any, Optional
25
+
26
+ from .memory import Memory
27
+
28
+
29
+ class FlowScriptSession:
30
+ """OpenAI Agents SDK Session backed by FlowScript reasoning memory.
31
+
32
+ Implements the 4-method Session protocol:
33
+ - get_items(limit?) → conversation history
34
+ - add_items(items) → store new conversation items
35
+ - pop_item() → remove and return last item
36
+ - clear_session() → clear all items
37
+
38
+ Access FlowScript queries via .memory property:
39
+ session.memory.query.tensions()
40
+ session.memory.query.blocked()
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ session_id: str,
46
+ file_path: str | None = None,
47
+ session_settings: Any = None,
48
+ ) -> None:
49
+ self.session_id = session_id
50
+ self.session_settings = session_settings
51
+
52
+ if file_path:
53
+ self._memory = Memory.load_or_create(file_path)
54
+ else:
55
+ self._memory = Memory()
56
+ self._file_path = file_path
57
+
58
+ # Ordered list of conversation items for this session
59
+ self._items: list[dict[str, Any]] = []
60
+ self._rebuild_items()
61
+
62
+ @property
63
+ def memory(self) -> Memory:
64
+ return self._memory
65
+
66
+ def _rebuild_items(self) -> None:
67
+ """Rebuild item list from loaded memory nodes."""
68
+ items_with_order: list[tuple[int, dict[str, Any]]] = []
69
+ for ref in self._memory.nodes:
70
+ node = ref.node
71
+ ext = node.ext or {}
72
+ if ext.get("oai_session_id") == self.session_id:
73
+ order = ext.get("oai_order", 0)
74
+ item = ext.get("oai_item", {"role": "user", "content": node.content})
75
+ items_with_order.append((order, item))
76
+
77
+ items_with_order.sort(key=lambda x: x[0])
78
+ self._items = [item for _, item in items_with_order]
79
+
80
+ async def get_items(self, limit: int | None = None) -> list[dict[str, Any]]:
81
+ """Get conversation items, optionally limited."""
82
+ if limit is not None:
83
+ return list(self._items[-limit:])
84
+ return list(self._items)
85
+
86
+ async def add_items(self, items: list[dict[str, Any]]) -> None:
87
+ """Add conversation items to the session."""
88
+ base_order = len(self._items)
89
+ for i, item in enumerate(items):
90
+ content = _extract_item_content(item)
91
+ if content:
92
+ ref = self._memory.thought(f"[{self.session_id}] {content}")
93
+ node = ref.node
94
+ node.ext = node.ext or {}
95
+ node.ext.update({
96
+ "oai_session_id": self.session_id,
97
+ "oai_order": base_order + i,
98
+ "oai_item": item,
99
+ })
100
+
101
+ self._items.append(item)
102
+
103
+ if self._file_path:
104
+ self._memory.save()
105
+
106
+ async def pop_item(self) -> dict[str, Any] | None:
107
+ """Remove and return the most recent item."""
108
+ if not self._items:
109
+ return None
110
+ item = self._items.pop()
111
+
112
+ # Remove the corresponding FlowScript node
113
+ content = _extract_item_content(item)
114
+ if content:
115
+ node_content = f"[{self.session_id}] {content}"
116
+ matches = self._memory.find_nodes(node_content)
117
+ for ref in matches:
118
+ if ref.content == node_content:
119
+ self._memory.remove_node(ref.id)
120
+ break
121
+
122
+ if self._file_path:
123
+ self._memory.save()
124
+ return item
125
+
126
+ async def clear_session(self) -> None:
127
+ """Clear all items for this session. Removes nodes from graph."""
128
+ self._items.clear()
129
+ # Remove session-specific nodes from the FlowScript graph
130
+ to_remove = []
131
+ for ref in self._memory.nodes:
132
+ ext = ref.node.ext or {}
133
+ if ext.get("oai_session_id") == self.session_id:
134
+ to_remove.append(ref.id)
135
+ for node_id in to_remove:
136
+ self._memory.remove_node(node_id)
137
+
138
+ if self._file_path:
139
+ self._memory.save()
140
+
141
+ def save(self) -> None:
142
+ """Persist to disk."""
143
+ self._memory.save()
144
+
145
+
146
+ def _extract_item_content(item: dict[str, Any]) -> str | None:
147
+ """Extract text content from an OpenAI response input item."""
148
+ # Standard message format
149
+ content = item.get("content")
150
+ if isinstance(content, str):
151
+ return content
152
+
153
+ # Content array format
154
+ if isinstance(content, list):
155
+ texts = []
156
+ for part in content:
157
+ if isinstance(part, dict) and part.get("type") == "input_text":
158
+ texts.append(part.get("text", ""))
159
+ elif isinstance(part, dict) and part.get("type") == "text":
160
+ texts.append(part.get("text", ""))
161
+ return " ".join(texts) if texts else None
162
+
163
+ # Tool output format
164
+ output = item.get("output")
165
+ if isinstance(output, str):
166
+ return output
167
+
168
+ # Fallback
169
+ role = item.get("role", "")
170
+ return f"[{role} message]" if role else None