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.
Files changed (70) hide show
  1. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/PKG-INFO +1 -1
  2. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/__init__.py +4 -1
  3. flowscript_agents-0.4.0/flowscript_agents/continuity.py +545 -0
  4. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/mcp.py +371 -28
  5. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/tool-integrity.json +6 -3
  6. flowscript_agents-0.4.0/guides/recommended_claude_md.md +56 -0
  7. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/pyproject.toml +1 -1
  8. flowscript_agents-0.4.0/tests/test_continuity.py +491 -0
  9. flowscript_agents-0.4.0/tests/test_integration_continuity.py +328 -0
  10. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_mcp.py +129 -4
  11. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/.github/workflows/test.yml +0 -0
  12. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/.gitignore +0 -0
  13. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/AUDIT_TRAIL_DESIGN.md +0 -0
  14. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/README.md +0 -0
  15. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/adapters.md +0 -0
  16. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/api-reference.md +0 -0
  17. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/audit-trail.md +0 -0
  18. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/brand/logo-512.png +0 -0
  19. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/brand/social-preview.png +0 -0
  20. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/flowscript-demo.png +0 -0
  21. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/docs/lifecycle.md +0 -0
  22. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/examples/CLAUDE.md.example +0 -0
  23. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/examples/langgraph_live_test.py +0 -0
  24. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/examples/temporal_e2e_test.py +0 -0
  25. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/audit.py +0 -0
  26. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/camel_ai.py +0 -0
  27. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/client.py +0 -0
  28. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/cloud.py +0 -0
  29. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/crewai.py +0 -0
  30. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/__init__.py +0 -0
  31. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/_utils.py +0 -0
  32. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/consolidate.py +0 -0
  33. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/extract.py +0 -0
  34. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/index.py +0 -0
  35. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/providers.py +0 -0
  36. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/embeddings/search.py +0 -0
  37. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/explain.py +0 -0
  38. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/fixpoint.py +0 -0
  39. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/google_adk.py +0 -0
  40. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/haystack.py +0 -0
  41. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/langgraph.py +0 -0
  42. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/llamaindex.py +0 -0
  43. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/memory.py +0 -0
  44. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/openai_agents.py +0 -0
  45. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/pydantic_ai.py +0 -0
  46. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/query.py +0 -0
  47. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/smolagents.py +0 -0
  48. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/types.py +0 -0
  49. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/flowscript_agents/unified.py +0 -0
  50. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/scripts/validate_dedup_threshold.py +0 -0
  51. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/conftest.py +0 -0
  52. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_audit.py +0 -0
  53. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_camel_ai.py +0 -0
  54. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_client.py +0 -0
  55. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_cloud.py +0 -0
  56. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_cloud_fixpoint.py +0 -0
  57. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_consolidation.py +0 -0
  58. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_crewai.py +0 -0
  59. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_embeddings.py +0 -0
  60. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_explain.py +0 -0
  61. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_fixpoint.py +0 -0
  62. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_google_adk.py +0 -0
  63. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_haystack.py +0 -0
  64. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_langgraph.py +0 -0
  65. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_llamaindex.py +0 -0
  66. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_memory.py +0 -0
  67. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_openai_agents.py +0 -0
  68. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_pydantic_ai.py +0 -0
  69. {flowscript_agents-0.3.0 → flowscript_agents-0.4.0}/tests/test_smolagents.py +0 -0
  70. {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.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.3.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