flowscript-agents 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/PKG-INFO +1 -1
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/__init__.py +4 -1
- flowscript_agents-0.4.0/flowscript_agents/continuity.py +545 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/mcp.py +371 -28
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/tool-integrity.json +6 -3
- flowscript_agents-0.4.0/guides/recommended_claude_md.md +56 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/pyproject.toml +1 -1
- flowscript_agents-0.4.0/tests/test_continuity.py +491 -0
- flowscript_agents-0.4.0/tests/test_integration_continuity.py +328 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_mcp.py +129 -4
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/.github/workflows/test.yml +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/.gitignore +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/AUDIT_TRAIL_DESIGN.md +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/README.md +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/adapters.md +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/api-reference.md +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/audit-trail.md +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/brand/logo-512.png +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/brand/social-preview.png +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/flowscript-demo.png +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/lifecycle.md +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/examples/CLAUDE.md.example +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/examples/langgraph_live_test.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/examples/temporal_e2e_test.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/audit.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/camel_ai.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/client.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/cloud.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/crewai.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/__init__.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/_utils.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/consolidate.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/extract.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/index.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/providers.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/search.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/explain.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/fixpoint.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/google_adk.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/haystack.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/langgraph.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/llamaindex.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/memory.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/openai_agents.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/pydantic_ai.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/query.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/smolagents.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/types.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/unified.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/scripts/validate_dedup_threshold.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/conftest.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_audit.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_camel_ai.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_client.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_cloud.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_cloud_fixpoint.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_consolidation.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_crewai.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_embeddings.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_explain.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_fixpoint.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_google_adk.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_haystack.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_langgraph.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_llamaindex.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_memory.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_openai_agents.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_pydantic_ai.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_smolagents.py +0 -0
- {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_temporal.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flowscript-agents
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Complete agent memory: reasoning queries + vector search + auto-extraction. Decision intelligence for LangGraph, CrewAI, Google ADK, OpenAI Agents SDK, Pydantic AI, smolagents, LlamaIndex, Haystack, and CAMEL-AI.
|
|
5
5
|
Project-URL: Homepage, https://flowscript.org
|
|
6
6
|
Project-URL: Repository, https://github.com/phillipclapham/flowscript-agents
|
|
@@ -29,6 +29,7 @@ Usage:
|
|
|
29
29
|
from .audit import AuditConfig, AuditQueryResult, AuditVerifyResult
|
|
30
30
|
from .client import FlowScriptAnthropic, FlowScriptOpenAI
|
|
31
31
|
from .cloud import CloudClient, CloudFlushResult, CloudWitness
|
|
32
|
+
from .continuity import ContinuityManager, ContinuityResult
|
|
32
33
|
from .memory import (
|
|
33
34
|
Memory,
|
|
34
35
|
MemoryOptions,
|
|
@@ -46,7 +47,7 @@ from .memory import (
|
|
|
46
47
|
from .unified import UnifiedMemory
|
|
47
48
|
from .explain import explain, explain_counterfactual
|
|
48
49
|
|
|
49
|
-
__version__ = "0.
|
|
50
|
+
__version__ = "0.4.0"
|
|
50
51
|
__all__ = [
|
|
51
52
|
"explain",
|
|
52
53
|
"explain_counterfactual",
|
|
@@ -58,6 +59,8 @@ __all__ = [
|
|
|
58
59
|
"CloudClient",
|
|
59
60
|
"CloudFlushResult",
|
|
60
61
|
"CloudWitness",
|
|
62
|
+
"ContinuityManager",
|
|
63
|
+
"ContinuityResult",
|
|
61
64
|
"Memory",
|
|
62
65
|
"MemoryOptions",
|
|
63
66
|
"NodeRef",
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ContinuityManager — LLM-driven session boundary compression for Layer 1.
|
|
3
|
+
|
|
4
|
+
Produces a compressed continuity file from session memory, implementing
|
|
5
|
+
temporal graduation (1x→2x→3x→principle) and decision lifecycle management.
|
|
6
|
+
This is compression-as-cognition: the act of compressing forces abstraction,
|
|
7
|
+
making the system LEARN, not just STORE.
|
|
8
|
+
|
|
9
|
+
The continuity file is a 4-section markdown document:
|
|
10
|
+
- State: Current focus, replaced each session
|
|
11
|
+
- Patterns: Temporal graduation with FlowScript markers
|
|
12
|
+
- Decisions: Committed decisions with lifecycle (active→clustered→archived)
|
|
13
|
+
- Context: Compressed narrative, rewritten each session
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from flowscript_agents.continuity import ContinuityManager
|
|
17
|
+
|
|
18
|
+
mgr = ContinuityManager(llm=my_llm, max_chars=20000)
|
|
19
|
+
continuity_text = mgr.produce(session_nodes, existing_continuity)
|
|
20
|
+
mgr.save(continuity_text, "./agent.json") # saves as ./agent.continuity.md
|
|
21
|
+
loaded = mgr.load("./agent.json") # reads the sidecar
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Callable, Optional
|
|
32
|
+
|
|
33
|
+
from .embeddings.extract import ExtractFn
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _log(msg: str) -> None:
|
|
37
|
+
"""Log to stderr."""
|
|
38
|
+
sys.stderr.write(f"[flowscript-continuity] {msg}\n")
|
|
39
|
+
sys.stderr.flush()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Result types
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ContinuityResult:
|
|
49
|
+
"""Result of producing a continuity file."""
|
|
50
|
+
|
|
51
|
+
text: str
|
|
52
|
+
char_count: int
|
|
53
|
+
section_sizes: dict[str, int] # section name → char count
|
|
54
|
+
truncated: bool # whether LLM output exceeded max_chars
|
|
55
|
+
session_nodes_count: int # how many nodes were in this session
|
|
56
|
+
patterns_extracted: int # estimated from output (best-effort)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# =============================================================================
|
|
60
|
+
# Wrap prompt
|
|
61
|
+
# =============================================================================
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_wrap_prompt(
|
|
65
|
+
session_summary: str,
|
|
66
|
+
existing_continuity: str | None,
|
|
67
|
+
project_name: str,
|
|
68
|
+
max_chars: int,
|
|
69
|
+
today: str | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Build the LLM prompt for continuity compression.
|
|
72
|
+
|
|
73
|
+
This prompt is the most important piece of code in the system.
|
|
74
|
+
It teaches the LLM temporal graduation, pattern extraction,
|
|
75
|
+
and decision lifecycle — all without requiring FlowScript knowledge.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
today: Override for current date string (for testing). Defaults to today's date.
|
|
79
|
+
"""
|
|
80
|
+
if today is None:
|
|
81
|
+
import datetime
|
|
82
|
+
today = datetime.date.today().isoformat()
|
|
83
|
+
|
|
84
|
+
existing_section = ""
|
|
85
|
+
if existing_continuity and existing_continuity.strip():
|
|
86
|
+
existing_section = f"""
|
|
87
|
+
## Existing Continuity File
|
|
88
|
+
(This is the current continuity file from previous sessions. Update it with the new session data.)
|
|
89
|
+
|
|
90
|
+
<existing_continuity>
|
|
91
|
+
{existing_continuity}
|
|
92
|
+
</existing_continuity>
|
|
93
|
+
"""
|
|
94
|
+
else:
|
|
95
|
+
existing_section = """
|
|
96
|
+
## Existing Continuity File
|
|
97
|
+
(No existing continuity — this is the first session. Create a fresh continuity file.)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
return f"""You are a memory compression engine for an AI agent's reasoning memory.
|
|
101
|
+
Your job is to produce a compressed continuity file that captures the PRINCIPLES and
|
|
102
|
+
PATTERNS from this session, not just a summary. The act of compression is itself a
|
|
103
|
+
form of thinking — extract what matters, discard what doesn't.
|
|
104
|
+
|
|
105
|
+
## Session Data
|
|
106
|
+
(These are the typed reasoning nodes from this session's work.)
|
|
107
|
+
|
|
108
|
+
<session_data>
|
|
109
|
+
{session_summary}
|
|
110
|
+
</session_data>
|
|
111
|
+
{existing_section}
|
|
112
|
+
## Output Format
|
|
113
|
+
|
|
114
|
+
Produce a markdown file with EXACTLY these 4 section headers (use these headers VERBATIM):
|
|
115
|
+
`## State`, `## Patterns`, `## Decisions`, `## Context`
|
|
116
|
+
Stay within {max_chars} characters total.
|
|
117
|
+
|
|
118
|
+
### ## State
|
|
119
|
+
Replace completely with this session's current focus, active work, and status.
|
|
120
|
+
What is the agent working on RIGHT NOW? What's blocked? What's next?
|
|
121
|
+
Write in plain prose, 2-5 lines.
|
|
122
|
+
|
|
123
|
+
### ## Patterns
|
|
124
|
+
This is where learning happens. Use these markers for density:
|
|
125
|
+
- `? question` — open question needing decision
|
|
126
|
+
- `thought: insight` — observation or principle worth preserving
|
|
127
|
+
- `✓ item` — completed/resolved
|
|
128
|
+
- `A -> B` — A causes or leads to B
|
|
129
|
+
- `A ><[axis] B` — tension between A and B on the named axis
|
|
130
|
+
- `[decided(rationale: "why", on: "date")] choice` — committed decision
|
|
131
|
+
- `[blocked(reason: "what", since: "date")] item` — waiting on dependency
|
|
132
|
+
|
|
133
|
+
**Temporal graduation (CRITICAL — this is what makes the system learn):**
|
|
134
|
+
- Mark each pattern with `| Nx (date)` where N = validation count, date = last validated
|
|
135
|
+
- New observation from THIS session not in existing patterns → add at `| 1x ({today})`
|
|
136
|
+
- Observation that VALIDATES an existing 1x pattern → increment to `| 2x ({today})`
|
|
137
|
+
- Observation that VALIDATES an existing 2x pattern → graduate to `| 3x ({today})`
|
|
138
|
+
- Patterns at 3x: extract the PRINCIPLE underneath, not the surface observations.
|
|
139
|
+
Multiple related observations → single meta-pattern. This is compression-as-cognition.
|
|
140
|
+
- Patterns with dates older than 7 days and no new validation → remove (they're stale)
|
|
141
|
+
|
|
142
|
+
Group related patterns in FlowScript blocks: `{{topic: ... }}`
|
|
143
|
+
|
|
144
|
+
**Example Patterns section:**
|
|
145
|
+
```
|
|
146
|
+
{{database_architecture:
|
|
147
|
+
thought: ACID compliance outweighs raw speed for financial data | 2x (2026-03-30)
|
|
148
|
+
thought: connection pooling is the real performance bottleneck | 1x (2026-03-30)
|
|
149
|
+
? horizontal scaling strategy ><[single-writer vs multi-writer] | 1x (2026-03-29)
|
|
150
|
+
}}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### ## Decisions
|
|
154
|
+
Committed decisions with rationale. Use `[decided()]` markers (which include dates).
|
|
155
|
+
|
|
156
|
+
**Decision lifecycle (prevents unbounded growth):**
|
|
157
|
+
- New decisions from this session → add with rationale and date
|
|
158
|
+
- Existing decisions still referenced by active State or Patterns → keep
|
|
159
|
+
- Cluster of 3+ related decisions pointing same direction → extract principle
|
|
160
|
+
to Patterns section, archive the individual decisions
|
|
161
|
+
- Decisions referencing nothing in active State/Patterns AND with dates older than 30 days → remove
|
|
162
|
+
- Reversed decisions → mark as reversed with rationale, remove next session
|
|
163
|
+
|
|
164
|
+
### ## Context
|
|
165
|
+
Compressed narrative of recent work history. Plain prose, NOT markers.
|
|
166
|
+
Rewrite incorporating this session — compress previous narrative into momentum.
|
|
167
|
+
Shape of the work, not transcript. 5-15 lines.
|
|
168
|
+
|
|
169
|
+
## Quality Rules
|
|
170
|
+
- PRINCIPLES over facts. "We keep hitting X because Y" > "X happened again"
|
|
171
|
+
- JUDGMENT over mechanical rules. You decide what matters.
|
|
172
|
+
- DENSITY over length. One insightful line > three vague ones.
|
|
173
|
+
- When uncertain whether to keep something, ask: "Would the agent make a worse
|
|
174
|
+
decision next session without this?" If no, cut it.
|
|
175
|
+
- Do NOT narrate FlowScript in prose ("we noted a thought about..."). Use markers directly.
|
|
176
|
+
- Do NOT include the session data verbatim. Compress it.
|
|
177
|
+
|
|
178
|
+
## Output
|
|
179
|
+
Return ONLY the markdown continuity file. No explanation, no preamble, no code fences.
|
|
180
|
+
Start directly with `# {project_name} — Memory (v1)`.
|
|
181
|
+
Do NOT add extra headers like `## Summary` or `## Overview`.
|
|
182
|
+
Do NOT wrap the output in markdown code fences.
|
|
183
|
+
Do NOT number the sections ("## Section 1: State" is WRONG, "## State" is correct)."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# =============================================================================
|
|
187
|
+
# Session data formatting
|
|
188
|
+
# =============================================================================
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _format_session_nodes(
|
|
192
|
+
nodes: list[Any],
|
|
193
|
+
relationships: list[Any],
|
|
194
|
+
states: list[Any],
|
|
195
|
+
temporal_map: dict[str, Any] | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Format session memory nodes into a readable summary for the LLM.
|
|
198
|
+
|
|
199
|
+
Takes raw IR objects from the Memory class and produces a structured
|
|
200
|
+
text representation the wrap prompt can work with.
|
|
201
|
+
"""
|
|
202
|
+
lines: list[str] = []
|
|
203
|
+
|
|
204
|
+
if not nodes:
|
|
205
|
+
return "(No nodes in this session)"
|
|
206
|
+
|
|
207
|
+
# Group nodes by type for clearer presentation
|
|
208
|
+
by_type: dict[str, list[Any]] = {}
|
|
209
|
+
for node in nodes:
|
|
210
|
+
type_name = node.type.value if hasattr(node.type, "value") else str(node.type)
|
|
211
|
+
by_type.setdefault(type_name, []).append(node)
|
|
212
|
+
|
|
213
|
+
for type_name, type_nodes in sorted(by_type.items()):
|
|
214
|
+
lines.append(f"\n### {type_name.title()}s ({len(type_nodes)})")
|
|
215
|
+
for node in type_nodes:
|
|
216
|
+
tier_info = ""
|
|
217
|
+
if temporal_map and node.id in temporal_map:
|
|
218
|
+
t = temporal_map[node.id]
|
|
219
|
+
tier = t.tier if hasattr(t, "tier") else t.get("tier", "current")
|
|
220
|
+
freq = t.frequency if hasattr(t, "frequency") else t.get("frequency", 1)
|
|
221
|
+
tier_info = f" [{tier}|{freq}x]"
|
|
222
|
+
lines.append(f"- ({node.id[:8]}){tier_info} {node.content}")
|
|
223
|
+
|
|
224
|
+
# Relationships
|
|
225
|
+
if relationships:
|
|
226
|
+
lines.append(f"\n### Relationships ({len(relationships)})")
|
|
227
|
+
node_map = {n.id: n.content[:60] for n in nodes}
|
|
228
|
+
for rel in relationships:
|
|
229
|
+
rel_type = rel.type.value if hasattr(rel.type, "value") else str(rel.type)
|
|
230
|
+
src = node_map.get(rel.source, rel.source[:8])
|
|
231
|
+
tgt = node_map.get(rel.target, rel.target[:8])
|
|
232
|
+
axis = f" [{rel.axis_label}]" if getattr(rel, "axis_label", None) else ""
|
|
233
|
+
lines.append(f"- {src} --{rel_type}{axis}--> {tgt}")
|
|
234
|
+
|
|
235
|
+
# States
|
|
236
|
+
if states:
|
|
237
|
+
lines.append(f"\n### States ({len(states)})")
|
|
238
|
+
node_map = {n.id: n.content[:60] for n in nodes}
|
|
239
|
+
for state in states:
|
|
240
|
+
state_type = state.type.value if hasattr(state.type, "value") else str(state.type)
|
|
241
|
+
node_content = node_map.get(state.node_id, state.node_id[:8])
|
|
242
|
+
fields_str = ""
|
|
243
|
+
if state.fields:
|
|
244
|
+
field_parts = []
|
|
245
|
+
for attr in ("rationale", "reason", "since", "on", "why", "until"):
|
|
246
|
+
val = getattr(state.fields, attr, None)
|
|
247
|
+
if val:
|
|
248
|
+
field_parts.append(f"{attr}: {val}")
|
|
249
|
+
if field_parts:
|
|
250
|
+
fields_str = f" ({', '.join(field_parts)})"
|
|
251
|
+
lines.append(f"- [{state_type}]{fields_str} {node_content}")
|
|
252
|
+
|
|
253
|
+
return "\n".join(lines)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# =============================================================================
|
|
257
|
+
# ContinuityManager
|
|
258
|
+
# =============================================================================
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ContinuityManager:
|
|
262
|
+
"""Manages the Layer 1 continuity file — LLM-driven session compression.
|
|
263
|
+
|
|
264
|
+
The continuity file is a lossy, principled compression of the agent's
|
|
265
|
+
reasoning history. It implements temporal graduation (observations →
|
|
266
|
+
validated patterns → proven principles) and decision lifecycle management
|
|
267
|
+
(active → clustered → archived).
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
llm: LLM function for compression. Same signature as AutoExtract:
|
|
271
|
+
(prompt: str) -> str.
|
|
272
|
+
max_chars: Maximum size of the continuity file in characters.
|
|
273
|
+
Default 20000 (~5k tokens). Configurable via
|
|
274
|
+
FLOWSCRIPT_CONTINUITY_MAX_CHARS env var.
|
|
275
|
+
project_name: Name for the continuity file header.
|
|
276
|
+
Default "Agent" or FLOWSCRIPT_PROJECT_NAME env var.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
llm: ExtractFn,
|
|
282
|
+
max_chars: int | None = None,
|
|
283
|
+
project_name: str | None = None,
|
|
284
|
+
) -> None:
|
|
285
|
+
self._llm = llm
|
|
286
|
+
self._max_chars = max_chars or int(
|
|
287
|
+
os.environ.get("FLOWSCRIPT_CONTINUITY_MAX_CHARS", "20000")
|
|
288
|
+
)
|
|
289
|
+
self._project_name = project_name or os.environ.get(
|
|
290
|
+
"FLOWSCRIPT_PROJECT_NAME", "Agent"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def max_chars(self) -> int:
|
|
295
|
+
return self._max_chars
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def project_name(self) -> str:
|
|
299
|
+
return self._project_name
|
|
300
|
+
|
|
301
|
+
# -- Core API --
|
|
302
|
+
|
|
303
|
+
def produce(
|
|
304
|
+
self,
|
|
305
|
+
memory: Any,
|
|
306
|
+
existing_continuity: str | None = None,
|
|
307
|
+
) -> ContinuityResult:
|
|
308
|
+
"""Produce a compressed continuity file from session memory.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
memory: A Memory instance containing the session's nodes.
|
|
312
|
+
existing_continuity: The current continuity file text (if any).
|
|
313
|
+
Pass None for first session.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
ContinuityResult with the compressed continuity text and metadata.
|
|
317
|
+
"""
|
|
318
|
+
# Extract session data from Memory via internal attributes.
|
|
319
|
+
# TODO: Memory should expose a public snapshot() method to avoid
|
|
320
|
+
# coupling to private attributes. For now, this is the only way
|
|
321
|
+
# to get the full graph data needed for compression.
|
|
322
|
+
nodes = list(memory._nodes.values())
|
|
323
|
+
relationships = list(memory._relationships)
|
|
324
|
+
states = list(memory._states)
|
|
325
|
+
temporal_map = dict(memory._temporal_map)
|
|
326
|
+
|
|
327
|
+
return self.produce_from_nodes(
|
|
328
|
+
nodes, relationships, states, existing_continuity, temporal_map
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def produce_from_nodes(
|
|
332
|
+
self,
|
|
333
|
+
nodes: list[Any],
|
|
334
|
+
relationships: list[Any],
|
|
335
|
+
states: list[Any],
|
|
336
|
+
existing_continuity: str | None = None,
|
|
337
|
+
temporal_map: dict[str, Any] | None = None,
|
|
338
|
+
) -> ContinuityResult:
|
|
339
|
+
"""Produce continuity from raw node lists (alternative to Memory instance).
|
|
340
|
+
|
|
341
|
+
Useful when you have nodes but not a full Memory object, e.g.,
|
|
342
|
+
from a filtered set or from deserialized data.
|
|
343
|
+
"""
|
|
344
|
+
session_summary = _format_session_nodes(
|
|
345
|
+
nodes, relationships, states, temporal_map
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
prompt = _build_wrap_prompt(
|
|
349
|
+
session_summary=session_summary,
|
|
350
|
+
existing_continuity=existing_continuity,
|
|
351
|
+
project_name=self._project_name,
|
|
352
|
+
max_chars=self._max_chars,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
_log(f"Producing continuity ({len(nodes)} nodes, max {self._max_chars} chars)")
|
|
356
|
+
|
|
357
|
+
raw_output = self._llm(prompt)
|
|
358
|
+
text = raw_output.strip()
|
|
359
|
+
|
|
360
|
+
# Validate structural integrity — the LLM must produce all 4 sections.
|
|
361
|
+
# If validation fails, return existing continuity unchanged (fail-safe).
|
|
362
|
+
if not self._validate_structure(text):
|
|
363
|
+
_log("WARNING: LLM output missing required sections — keeping existing continuity")
|
|
364
|
+
if existing_continuity:
|
|
365
|
+
return ContinuityResult(
|
|
366
|
+
text=existing_continuity,
|
|
367
|
+
char_count=len(existing_continuity),
|
|
368
|
+
section_sizes=self._measure_sections(existing_continuity),
|
|
369
|
+
truncated=False,
|
|
370
|
+
session_nodes_count=len(nodes),
|
|
371
|
+
patterns_extracted=len(re.findall(r"\|\s*\d+x", existing_continuity)),
|
|
372
|
+
)
|
|
373
|
+
# No existing continuity and LLM failed — return the output anyway
|
|
374
|
+
# (first session, something is better than nothing)
|
|
375
|
+
_log("WARNING: No existing continuity to fall back to — using LLM output as-is")
|
|
376
|
+
|
|
377
|
+
truncated = False
|
|
378
|
+
if len(text) > self._max_chars:
|
|
379
|
+
truncated = True
|
|
380
|
+
text = self._truncate_to_sections(text)
|
|
381
|
+
|
|
382
|
+
section_sizes = self._measure_sections(text)
|
|
383
|
+
|
|
384
|
+
patterns_extracted = len(re.findall(r"\|\s*\d+x", text))
|
|
385
|
+
|
|
386
|
+
return ContinuityResult(
|
|
387
|
+
text=text,
|
|
388
|
+
char_count=len(text),
|
|
389
|
+
section_sizes=section_sizes,
|
|
390
|
+
truncated=truncated,
|
|
391
|
+
session_nodes_count=len(nodes),
|
|
392
|
+
patterns_extracted=patterns_extracted,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# -- File I/O --
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def continuity_path(memory_path: str) -> str:
|
|
399
|
+
"""Get the sidecar continuity file path for a memory file.
|
|
400
|
+
|
|
401
|
+
Pattern: ./agent.json → ./agent.continuity.md
|
|
402
|
+
Matches the VectorIndex sidecar pattern (.embeddings.json).
|
|
403
|
+
"""
|
|
404
|
+
p = Path(memory_path)
|
|
405
|
+
return str(p.parent / f"{p.stem}.continuity.md")
|
|
406
|
+
|
|
407
|
+
# -- Validation --
|
|
408
|
+
|
|
409
|
+
_REQUIRED_SECTIONS = {"state", "patterns", "decisions", "context"}
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def _validate_structure(cls, text: str) -> bool:
|
|
413
|
+
"""Validate that LLM output contains all 4 required sections.
|
|
414
|
+
|
|
415
|
+
Checks case-insensitively for ## headers containing each section name.
|
|
416
|
+
Returns True if all sections found, False otherwise.
|
|
417
|
+
"""
|
|
418
|
+
text_lower = text.lower()
|
|
419
|
+
found = set()
|
|
420
|
+
for line in text_lower.split("\n"):
|
|
421
|
+
if line.startswith("##"):
|
|
422
|
+
for section in cls._REQUIRED_SECTIONS:
|
|
423
|
+
if section in line:
|
|
424
|
+
found.add(section)
|
|
425
|
+
return found == cls._REQUIRED_SECTIONS
|
|
426
|
+
|
|
427
|
+
# -- File I/O --
|
|
428
|
+
|
|
429
|
+
def save(self, text: str, memory_path: str) -> str:
|
|
430
|
+
"""Save continuity text to the sidecar file.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
text: The continuity file content.
|
|
434
|
+
memory_path: Path to the memory JSON file (sidecar derived from this).
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
The path where the continuity file was saved.
|
|
438
|
+
"""
|
|
439
|
+
path = self.continuity_path(memory_path)
|
|
440
|
+
# Atomic write: temp file + rename (crash-safe)
|
|
441
|
+
tmp_path = path + ".tmp"
|
|
442
|
+
try:
|
|
443
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
444
|
+
f.write(text)
|
|
445
|
+
f.flush()
|
|
446
|
+
os.fsync(f.fileno())
|
|
447
|
+
os.replace(tmp_path, path)
|
|
448
|
+
except Exception:
|
|
449
|
+
# Clean up temp file on failure
|
|
450
|
+
try:
|
|
451
|
+
os.unlink(tmp_path)
|
|
452
|
+
except OSError:
|
|
453
|
+
pass
|
|
454
|
+
raise
|
|
455
|
+
_log(f"Saved continuity to {path} ({len(text)} chars)")
|
|
456
|
+
return path
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def load(memory_path: str) -> str | None:
|
|
460
|
+
"""Load continuity text from the sidecar file.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
memory_path: Path to the memory JSON file.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
The continuity text, or None if no continuity file exists.
|
|
467
|
+
"""
|
|
468
|
+
path = ContinuityManager.continuity_path(memory_path)
|
|
469
|
+
if not os.path.exists(path):
|
|
470
|
+
return None
|
|
471
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
472
|
+
return f.read()
|
|
473
|
+
|
|
474
|
+
# -- Internal helpers --
|
|
475
|
+
|
|
476
|
+
def _truncate_to_sections(self, text: str) -> str:
|
|
477
|
+
"""Truncate to fit max_chars while preserving complete sections.
|
|
478
|
+
|
|
479
|
+
Cuts from the bottom of the last section that exceeds the limit,
|
|
480
|
+
ensuring we never have a partial section.
|
|
481
|
+
"""
|
|
482
|
+
if len(text) <= self._max_chars:
|
|
483
|
+
return text
|
|
484
|
+
|
|
485
|
+
# Find section boundaries (## headers)
|
|
486
|
+
lines = text.split("\n")
|
|
487
|
+
section_starts: list[int] = []
|
|
488
|
+
for i, line in enumerate(lines):
|
|
489
|
+
if line.startswith("## "):
|
|
490
|
+
section_starts.append(i)
|
|
491
|
+
|
|
492
|
+
if not section_starts:
|
|
493
|
+
# No sections found — hard truncate
|
|
494
|
+
return text[: self._max_chars]
|
|
495
|
+
|
|
496
|
+
# Build text incrementally by section, stop when we'd exceed limit
|
|
497
|
+
result_lines: list[str] = []
|
|
498
|
+
|
|
499
|
+
# Include everything before first section (title line)
|
|
500
|
+
for i in range(section_starts[0]):
|
|
501
|
+
result_lines.append(lines[i])
|
|
502
|
+
|
|
503
|
+
# Add sections until we'd exceed limit
|
|
504
|
+
for idx, start in enumerate(section_starts):
|
|
505
|
+
end = section_starts[idx + 1] if idx + 1 < len(section_starts) else len(lines)
|
|
506
|
+
section_lines = lines[start:end]
|
|
507
|
+
candidate = "\n".join(result_lines + section_lines)
|
|
508
|
+
if len(candidate) > self._max_chars and result_lines:
|
|
509
|
+
# This section would exceed limit — truncate within it
|
|
510
|
+
remaining = self._max_chars - len("\n".join(result_lines)) - 1
|
|
511
|
+
partial: list[str] = []
|
|
512
|
+
char_count = 0
|
|
513
|
+
for line in section_lines:
|
|
514
|
+
if char_count + len(line) + 1 > remaining:
|
|
515
|
+
break
|
|
516
|
+
partial.append(line)
|
|
517
|
+
char_count += len(line) + 1
|
|
518
|
+
result_lines.extend(partial)
|
|
519
|
+
break
|
|
520
|
+
result_lines.extend(section_lines)
|
|
521
|
+
|
|
522
|
+
return "\n".join(result_lines)
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def _measure_sections(text: str) -> dict[str, int]:
|
|
526
|
+
"""Measure character count per section."""
|
|
527
|
+
sections: dict[str, int] = {}
|
|
528
|
+
current_section = "_header"
|
|
529
|
+
current_chars = 0
|
|
530
|
+
|
|
531
|
+
for line in text.split("\n"):
|
|
532
|
+
if line.startswith("## "):
|
|
533
|
+
# Save previous section
|
|
534
|
+
if current_chars > 0:
|
|
535
|
+
sections[current_section] = current_chars
|
|
536
|
+
current_section = line[3:].strip()
|
|
537
|
+
current_chars = len(line) + 1
|
|
538
|
+
else:
|
|
539
|
+
current_chars += len(line) + 1
|
|
540
|
+
|
|
541
|
+
# Save last section
|
|
542
|
+
if current_chars > 0:
|
|
543
|
+
sections[current_section] = current_chars
|
|
544
|
+
|
|
545
|
+
return sections
|