flowscript-agents 0.2.7__tar.gz → 0.2.9__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 (63) hide show
  1. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/PKG-INFO +1 -1
  2. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/__init__.py +5 -1
  3. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/audit.py +5 -1
  4. flowscript_agents-0.2.9/flowscript_agents/cloud.py +391 -0
  5. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/extract.py +30 -1
  6. flowscript_agents-0.2.9/flowscript_agents/fixpoint.py +339 -0
  7. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/pyproject.toml +1 -1
  8. flowscript_agents-0.2.9/tests/test_cloud.py +434 -0
  9. flowscript_agents-0.2.9/tests/test_fixpoint.py +671 -0
  10. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/.github/workflows/test.yml +0 -0
  11. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/.gitignore +0 -0
  12. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/AUDIT_TRAIL_DESIGN.md +0 -0
  13. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/README.md +0 -0
  14. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/adapters.md +0 -0
  15. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/api-reference.md +0 -0
  16. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/audit-trail.md +0 -0
  17. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/brand/logo-512.png +0 -0
  18. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/brand/social-preview.png +0 -0
  19. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/flowscript-demo.png +0 -0
  20. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/docs/lifecycle.md +0 -0
  21. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/examples/CLAUDE.md.example +0 -0
  22. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/examples/langgraph_live_test.py +0 -0
  23. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/examples/temporal_e2e_test.py +0 -0
  24. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/camel_ai.py +0 -0
  25. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/crewai.py +0 -0
  26. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/__init__.py +0 -0
  27. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/_utils.py +0 -0
  28. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/consolidate.py +0 -0
  29. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/index.py +0 -0
  30. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/providers.py +0 -0
  31. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/embeddings/search.py +0 -0
  32. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/explain.py +0 -0
  33. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/google_adk.py +0 -0
  34. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/haystack.py +0 -0
  35. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/langgraph.py +0 -0
  36. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/llamaindex.py +0 -0
  37. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/mcp.py +0 -0
  38. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/memory.py +0 -0
  39. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/openai_agents.py +0 -0
  40. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/pydantic_ai.py +0 -0
  41. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/query.py +0 -0
  42. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/smolagents.py +0 -0
  43. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/tool-integrity.json +0 -0
  44. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/types.py +0 -0
  45. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/flowscript_agents/unified.py +0 -0
  46. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/scripts/validate_dedup_threshold.py +0 -0
  47. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/conftest.py +0 -0
  48. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_audit.py +0 -0
  49. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_camel_ai.py +0 -0
  50. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_consolidation.py +0 -0
  51. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_crewai.py +0 -0
  52. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_embeddings.py +0 -0
  53. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_explain.py +0 -0
  54. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_google_adk.py +0 -0
  55. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_haystack.py +0 -0
  56. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_langgraph.py +0 -0
  57. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_llamaindex.py +0 -0
  58. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_mcp.py +0 -0
  59. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_memory.py +0 -0
  60. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_openai_agents.py +0 -0
  61. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_pydantic_ai.py +0 -0
  62. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_smolagents.py +0 -0
  63. {flowscript_agents-0.2.7 → flowscript_agents-0.2.9}/tests/test_temporal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowscript-agents
3
- Version: 0.2.7
3
+ Version: 0.2.9
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
@@ -27,6 +27,7 @@ Usage:
27
27
  """
28
28
 
29
29
  from .audit import AuditConfig, AuditQueryResult, AuditVerifyResult
30
+ from .cloud import CloudClient, CloudFlushResult, CloudWitness
30
31
  from .memory import (
31
32
  Memory,
32
33
  MemoryOptions,
@@ -44,12 +45,15 @@ from .memory import (
44
45
  from .unified import UnifiedMemory
45
46
  from .explain import explain
46
47
 
47
- __version__ = "0.2.7"
48
+ __version__ = "0.2.9"
48
49
  __all__ = [
49
50
  "explain",
50
51
  "AuditConfig",
51
52
  "AuditQueryResult",
52
53
  "AuditVerifyResult",
54
+ "CloudClient",
55
+ "CloudFlushResult",
56
+ "CloudWitness",
53
57
  "Memory",
54
58
  "MemoryOptions",
55
59
  "NodeRef",
@@ -19,6 +19,7 @@ File layout:
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
+ import copy
22
23
  import gzip
23
24
  import atexit
24
25
  import hashlib
@@ -407,7 +408,10 @@ class AuditWriter:
407
408
  # Fire on_event callback (failure must never block audit, but log to stderr)
408
409
  if self._config.on_event:
409
410
  if self._config.on_event_async:
410
- self._get_executor().submit(self._fire_on_event, entry)
411
+ # Deep copy to prevent caller mutation between write() return and
412
+ # async callback execution from producing different canonical JSON
413
+ # (which would break hash chain verification in CloudClient).
414
+ self._get_executor().submit(self._fire_on_event, copy.deepcopy(entry))
411
415
  else:
412
416
  self._fire_on_event(entry)
413
417
 
@@ -0,0 +1,391 @@
1
+ """
2
+ CloudClient — Batching client for FlowScript Cloud API.
3
+
4
+ Sends audit trail events to api.flowscript.org for independent cryptographic
5
+ witnessing. Events are buffered locally and flushed in batches.
6
+
7
+ Integration with AuditWriter via on_event callback:
8
+
9
+ from flowscript_agents import AuditConfig
10
+ from flowscript_agents.cloud import CloudClient
11
+
12
+ cloud = CloudClient(api_key="fsk_...", namespace="myorg/myagent")
13
+ config = AuditConfig(on_event=cloud.queue_event, on_event_async=True)
14
+ writer = AuditWriter(Path("agent.json"), config)
15
+ # Events are automatically queued and flushed to Cloud
16
+
17
+ # At shutdown:
18
+ cloud.flush() # Send any remaining buffered events
19
+
20
+ Manual usage:
21
+
22
+ cloud = CloudClient(api_key="fsk_...", namespace="myorg/myagent")
23
+ cloud.queue_event(entry_dict)
24
+ result = cloud.flush()
25
+ print(result.accepted, result.witness)
26
+
27
+ Configuration via environment:
28
+
29
+ FLOWSCRIPT_API_KEY=fsk_...
30
+ FLOWSCRIPT_CLOUD_URL=https://api.flowscript.org (default)
31
+ FLOWSCRIPT_NAMESPACE=myorg/myagent
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import sys
39
+ import threading
40
+ import urllib.error
41
+ import urllib.request
42
+ from dataclasses import dataclass, field
43
+ from typing import Any, Callable, Optional
44
+
45
+
46
+ # =============================================================================
47
+ # Configuration
48
+ # =============================================================================
49
+
50
+ DEFAULT_CLOUD_URL = "https://api.flowscript.org"
51
+ DEFAULT_BATCH_SIZE = 100
52
+ DEFAULT_MAX_BUFFER_SIZE = 10000
53
+ DEFAULT_FLUSH_INTERVAL = 5.0 # seconds (for future auto-flush)
54
+ def _get_user_agent() -> str:
55
+ try:
56
+ from flowscript_agents import __version__
57
+ return f"flowscript-agents-python/{__version__}"
58
+ except ImportError:
59
+ return "flowscript-agents-python/unknown"
60
+
61
+ USER_AGENT = _get_user_agent()
62
+
63
+
64
+ # =============================================================================
65
+ # Result Types
66
+ # =============================================================================
67
+
68
+
69
+ @dataclass
70
+ class CloudFlushResult:
71
+ """Result of a flush operation."""
72
+
73
+ accepted: int
74
+ witness: Optional[dict[str, Any]] = None
75
+ error: Optional[str] = None
76
+ status_code: Optional[int] = None
77
+
78
+
79
+ @dataclass
80
+ class CloudWitness:
81
+ """Witness attestation from Cloud."""
82
+
83
+ id: str
84
+ chain_head_seq: int
85
+ chain_head_hash: str
86
+ witnessed_at: str
87
+
88
+
89
+ # =============================================================================
90
+ # CloudClient
91
+ # =============================================================================
92
+
93
+
94
+ class CloudClient:
95
+ """Batching client for FlowScript Cloud event ingestion.
96
+
97
+ Events are buffered locally and sent in batches to minimize network
98
+ round-trips. Each flush returns a witness attestation from the Cloud
99
+ service, proving independent verification of the hash chain.
100
+
101
+ Thread-safe: multiple threads can call queue_event() concurrently.
102
+
103
+ Args:
104
+ api_key: FlowScript Cloud API key (fsk_...). Falls back to
105
+ FLOWSCRIPT_API_KEY env var.
106
+ namespace: Agent namespace in "owner/agent" format. Falls back to
107
+ FLOWSCRIPT_NAMESPACE env var.
108
+ endpoint: Cloud API base URL. Falls back to FLOWSCRIPT_CLOUD_URL
109
+ env var, then https://api.flowscript.org.
110
+ batch_size: Flush automatically when buffer reaches this size.
111
+ Default 100.
112
+ max_buffer_size: Maximum events to buffer before dropping new events.
113
+ Prevents unbounded memory growth when Cloud is unreachable.
114
+ Dropped events are still in the local audit trail (AuditWriter
115
+ writes to disk before calling on_event). Use the backfill
116
+ endpoint to recover after Cloud connectivity is restored.
117
+ Default 10000.
118
+ timeout: HTTP request timeout in seconds. Default 30.
119
+ on_witness: Optional callback invoked with each CloudWitness after
120
+ successful flush. Use for logging or monitoring.
121
+ on_error: Optional callback invoked on flush errors. Receives the
122
+ CloudFlushResult with error details. Default: print to stderr.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ api_key: Optional[str] = None,
128
+ namespace: Optional[str] = None,
129
+ endpoint: Optional[str] = None,
130
+ batch_size: int = DEFAULT_BATCH_SIZE,
131
+ max_buffer_size: int = DEFAULT_MAX_BUFFER_SIZE,
132
+ timeout: int = 30,
133
+ on_witness: Optional[Callable] = None,
134
+ on_error: Optional[Callable] = None,
135
+ ) -> None:
136
+ self._api_key = api_key or os.environ.get("FLOWSCRIPT_API_KEY", "")
137
+ self._namespace = namespace or os.environ.get("FLOWSCRIPT_NAMESPACE", "")
138
+ self._endpoint = (
139
+ endpoint or os.environ.get("FLOWSCRIPT_CLOUD_URL", DEFAULT_CLOUD_URL)
140
+ ).rstrip("/")
141
+ self._batch_size = batch_size
142
+ self._max_buffer_size = max_buffer_size
143
+ self._timeout = timeout
144
+ self._on_witness = on_witness
145
+ self._on_error = on_error
146
+ self._buffer: list[str] = []
147
+ self._lock = threading.Lock()
148
+ self._total_sent = 0
149
+ self._total_accepted = 0
150
+ self._total_dropped = 0
151
+ self._last_witness: Optional[CloudWitness] = None
152
+
153
+ if not self._api_key:
154
+ raise ValueError(
155
+ "API key required. Pass api_key= or set FLOWSCRIPT_API_KEY env var."
156
+ )
157
+ if not self._namespace:
158
+ raise ValueError(
159
+ "Namespace required. Pass namespace= or set FLOWSCRIPT_NAMESPACE env var. "
160
+ "Format: owner/agent"
161
+ )
162
+
163
+ @property
164
+ def buffered_count(self) -> int:
165
+ """Number of events currently buffered."""
166
+ with self._lock:
167
+ return len(self._buffer)
168
+
169
+ @property
170
+ def total_sent(self) -> int:
171
+ """Total events sent to Cloud (may include retransmissions)."""
172
+ return self._total_sent
173
+
174
+ @property
175
+ def total_accepted(self) -> int:
176
+ """Total events accepted by Cloud (excludes replays)."""
177
+ return self._total_accepted
178
+
179
+ @property
180
+ def total_dropped(self) -> int:
181
+ """Total events dropped due to buffer overflow.
182
+
183
+ Dropped events are NOT lost — they exist in the local audit trail
184
+ (AuditWriter writes to disk before calling on_event). Use the
185
+ backfill endpoint to sync them to Cloud after connectivity is restored.
186
+ """
187
+ return self._total_dropped
188
+
189
+ @property
190
+ def last_witness(self) -> Optional[CloudWitness]:
191
+ """Most recent witness attestation received."""
192
+ return self._last_witness
193
+
194
+ # -------------------------------------------------------------------------
195
+ # Queue + Flush
196
+ # -------------------------------------------------------------------------
197
+
198
+ def queue_event(self, entry: dict[str, Any]) -> None:
199
+ """Queue an audit event for batch upload to Cloud.
200
+
201
+ This is designed to be used as the AuditConfig.on_event callback.
202
+ The entry dict is re-serialized to canonical JSON (matching the
203
+ AuditWriter's serialization) for hash-chain-compatible transmission.
204
+
205
+ If the buffer has reached max_buffer_size, the event is dropped with
206
+ a warning to stderr. Dropped events are NOT lost — they exist in the
207
+ local audit trail. Use the backfill endpoint to recover.
208
+
209
+ Args:
210
+ entry: Audit event dict as produced by AuditWriter.write()
211
+ """
212
+ # Re-serialize to canonical JSON — identical to AuditWriter line 390.
213
+ # Python json.dumps with sort_keys=True is deterministic for the same dict.
214
+ json_line = json.dumps(entry, sort_keys=True, separators=(",", ":"))
215
+
216
+ batch_to_send = None
217
+ with self._lock:
218
+ if len(self._buffer) >= self._max_buffer_size:
219
+ self._total_dropped += 1
220
+ if self._total_dropped == 1 or self._total_dropped % 1000 == 0:
221
+ print(
222
+ f"CloudClient: buffer full ({self._max_buffer_size} events). "
223
+ f"Dropping Cloud sync ({self._total_dropped} dropped total). "
224
+ f"Local audit trail is unaffected. Use backfill to recover.",
225
+ file=sys.stderr,
226
+ )
227
+ return
228
+
229
+ self._buffer.append(json_line)
230
+ if len(self._buffer) >= self._batch_size:
231
+ batch_to_send = list(self._buffer)
232
+ self._buffer.clear()
233
+
234
+ # Auto-flush outside the lock — same pattern as flush() to avoid
235
+ # blocking other queue_event() callers during HTTP I/O.
236
+ if batch_to_send is not None:
237
+ self._send_batch(batch_to_send)
238
+
239
+ def flush(self) -> Optional[CloudFlushResult]:
240
+ """Send all buffered events to Cloud.
241
+
242
+ Returns None if buffer is empty. Otherwise returns CloudFlushResult
243
+ with accepted count and witness attestation.
244
+
245
+ Safe to call from any thread, including atexit handlers.
246
+ """
247
+ # Take events from buffer under lock, then release lock for HTTP I/O.
248
+ # This allows other threads to queue_event() while we're sending.
249
+ with self._lock:
250
+ if not self._buffer:
251
+ return None
252
+ events = list(self._buffer)
253
+ self._buffer.clear()
254
+
255
+ return self._send_batch(events)
256
+
257
+ def _send_batch(self, events: list[str]) -> CloudFlushResult:
258
+ """Send a batch of canonical JSON event strings to Cloud.
259
+
260
+ Handles response parsing, witness tracking, error handling, and retry
261
+ (putting events back in buffer on 5xx/network errors).
262
+ """
263
+ body = json.dumps({
264
+ "namespace": self._namespace,
265
+ "events": events,
266
+ }).encode("utf-8")
267
+
268
+ url = f"{self._endpoint}/v1/events"
269
+ req = urllib.request.Request(
270
+ url,
271
+ data=body,
272
+ headers={
273
+ "Content-Type": "application/json",
274
+ "Authorization": f"Bearer {self._api_key}",
275
+ "User-Agent": USER_AGENT,
276
+ },
277
+ method="POST",
278
+ )
279
+
280
+ try:
281
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
282
+ resp_body = json.loads(resp.read().decode("utf-8"))
283
+ self._total_sent += len(events)
284
+ accepted = resp_body.get("accepted", 0)
285
+ self._total_accepted += accepted
286
+
287
+ # Parse witness — defensive against malformed responses.
288
+ # Events are already accepted; witness parse failure should not
289
+ # cause data loss or propagate errors.
290
+ witness_data = resp_body.get("witness")
291
+ witness = None
292
+ if witness_data:
293
+ try:
294
+ witness = CloudWitness(
295
+ id=witness_data["id"],
296
+ chain_head_seq=witness_data["chain_head_seq"],
297
+ chain_head_hash=witness_data["chain_head_hash"],
298
+ witnessed_at=witness_data["witnessed_at"],
299
+ )
300
+ self._last_witness = witness
301
+ except (KeyError, TypeError) as e:
302
+ print(
303
+ f"CloudClient: malformed witness response: {e}",
304
+ file=sys.stderr,
305
+ )
306
+
307
+ if witness and self._on_witness:
308
+ try:
309
+ self._on_witness(witness)
310
+ except Exception as e:
311
+ print(
312
+ f"CloudClient: on_witness callback failed: {e}",
313
+ file=sys.stderr,
314
+ )
315
+
316
+ return CloudFlushResult(
317
+ accepted=accepted,
318
+ witness=witness_data,
319
+ status_code=resp.status,
320
+ )
321
+
322
+ except urllib.error.HTTPError as e:
323
+ error_body = ""
324
+ try:
325
+ error_body = e.read().decode("utf-8")
326
+ except Exception:
327
+ pass
328
+
329
+ result = CloudFlushResult(
330
+ accepted=0,
331
+ error=f"HTTP {e.code}: {error_body}",
332
+ status_code=e.code,
333
+ )
334
+
335
+ # On 409 (chain break), don't retry — this is a data integrity issue
336
+ # that needs human attention. On 4xx, also don't retry (client error).
337
+ # On 5xx, put events back in buffer for retry.
338
+ if e.code >= 500:
339
+ with self._lock:
340
+ self._buffer = events + self._buffer
341
+
342
+ self._handle_error(result)
343
+ return result
344
+
345
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
346
+ # Network error — put events back for retry
347
+ with self._lock:
348
+ self._buffer = events + self._buffer
349
+ result = CloudFlushResult(
350
+ accepted=0,
351
+ error=f"Network error: {e}",
352
+ )
353
+ self._handle_error(result)
354
+ return result
355
+
356
+ def _handle_error(self, result: CloudFlushResult) -> None:
357
+ """Handle flush errors."""
358
+ if self._on_error:
359
+ try:
360
+ self._on_error(result)
361
+ except Exception:
362
+ pass
363
+ else:
364
+ print(
365
+ f"CloudClient: flush failed: {result.error}",
366
+ file=sys.stderr,
367
+ )
368
+
369
+ # -------------------------------------------------------------------------
370
+ # Convenience
371
+ # -------------------------------------------------------------------------
372
+
373
+ def send_events(self, entries: list[dict[str, Any]]) -> CloudFlushResult:
374
+ """Send a list of events immediately (no buffering).
375
+
376
+ Useful for backfill or one-shot uploads.
377
+ """
378
+ json_lines = [
379
+ json.dumps(entry, sort_keys=True, separators=(",", ":"))
380
+ for entry in entries
381
+ ]
382
+ with self._lock:
383
+ self._buffer.extend(json_lines)
384
+ return self.flush() or CloudFlushResult(accepted=0)
385
+
386
+ def health(self) -> dict[str, Any]:
387
+ """Check Cloud API health. Returns health response dict."""
388
+ url = f"{self._endpoint}/v1/health"
389
+ req = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
390
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
391
+ return json.loads(resp.read().decode("utf-8"))
@@ -611,6 +611,18 @@ class AutoExtract:
611
611
  4. Create extraction relationships for surviving nodes
612
612
  5. Apply extraction states for surviving nodes
613
613
  """
614
+ # Capture pre-extraction graph hash for attestation.
615
+ # MUST be before node creation — the certificate's initial_graph_hash
616
+ # must represent the graph BEFORE any extraction/consolidation work.
617
+ # Otherwise ADD actions (novel nodes already in memory from step 1)
618
+ # produce initial_hash == final_hash, contradicting delta > 0.
619
+ pre_hash = None
620
+ try:
621
+ from ..fixpoint import FixpointContext
622
+ pre_hash = FixpointContext._compute_graph_hash_static(self._memory)
623
+ except Exception:
624
+ pass # Attestation is optional — consolidation always runs
625
+
614
626
  # Create all nodes and embed them
615
627
  node_refs: list[NodeRef | None] = []
616
628
  extracted_node_dicts: list[dict[str, Any]] = []
@@ -624,11 +636,28 @@ class AutoExtract:
624
636
  "content": extracted_node.content,
625
637
  })
626
638
 
627
- # Run consolidation
639
+ # Run consolidation (always runs — this is the core operation)
628
640
  consolidation_result = self._consolidation_engine.consolidate(
629
641
  extracted_node_dicts, node_refs
630
642
  )
631
643
 
644
+ # Wrap with fixpoint attestation (fail-open: never blocks consolidation)
645
+ try:
646
+ from ..fixpoint import FixpointContext
647
+
648
+ with FixpointContext(self._memory, name="consolidation", constraint="L1",
649
+ _pre_hash=pre_hash) as fix_ctx:
650
+ delta = (
651
+ consolidation_result.nodes_added
652
+ + consolidation_result.nodes_updated
653
+ + consolidation_result.nodes_related
654
+ + consolidation_result.nodes_resolved
655
+ )
656
+ fix_ctx.record_iteration(delta)
657
+ fix_ctx.record_iteration(0) # convergence marker (degenerate @fix)
658
+ except Exception as e:
659
+ print(f"AutoExtract: fixpoint attestation failed: {e}", file=sys.stderr)
660
+
632
661
  # Create extraction relationships for surviving nodes only
633
662
  # (consolidation may have removed some via UPDATE/NONE)
634
663
  rels_created = self._create_extraction_relationships(extraction, node_refs)