activegraph 1.0.0rc2__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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,495 @@
1
+ """Trace formatter. CONTRACT #18 — format is the public contract.
2
+
3
+ Layout: tag column is left-aligned, padded to 26 chars; if the tag itself is
4
+ longer, exactly one space follows it.
5
+
6
+ Standard event types get specific renderings (see CONTRACT for the table).
7
+ Anything else is rendered as `[event.emitted] {type} k=v...`.
8
+
9
+ CONTRACT v0.5 #22: replayed events are rendered with a `[replay.event]`
10
+ prefix. After the last replayed event, two synthetic lines appear so
11
+ operators can see the load boundary:
12
+ [replay.complete] N events replayed, graph reconstructed
13
+ [runtime.idle] ready to resume
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from activegraph.core.event import Event
21
+ from activegraph.core.graph import Graph
22
+
23
+
24
+ TAG_COL = 26
25
+
26
+
27
+ def _format_tag(tag_text: str) -> str:
28
+ """Bracketed tag, left-padded to TAG_COL (or one trailing space if longer)."""
29
+ bracketed = f"[{tag_text}]"
30
+ if len(bracketed) >= TAG_COL:
31
+ return bracketed + " "
32
+ return bracketed.ljust(TAG_COL)
33
+
34
+
35
+ def _plural(n: int, word: str) -> str:
36
+ return f"{n} {word}{'s' if n != 1 else ''}"
37
+
38
+
39
+ # ---------- per-event formatters ----------
40
+
41
+
42
+ def _fmt_goal_created(e: Event) -> str:
43
+ actor = e.actor or "user"
44
+ goal = e.payload.get("goal", "")
45
+ return f'{_format_tag("goal.created")}{actor}: "{goal}"'
46
+
47
+
48
+ def _fmt_object_created(e: Event) -> str:
49
+ o = e.payload.get("object", {})
50
+ data = o.get("data", {})
51
+ label = data.get("title") or data.get("text") or ""
52
+ label_s = f' "{label}"' if label else ""
53
+ status = data.get("status")
54
+ status_s = f" ({status})" if status else ""
55
+ return f'{_format_tag("object.created")}{o.get("id", "?")}{label_s}{status_s}'
56
+
57
+
58
+ def _fmt_object_removed(e: Event) -> str:
59
+ return f'{_format_tag("object.removed")}{e.payload.get("id", "?")}'
60
+
61
+
62
+ def _fmt_relation_created(e: Event) -> str:
63
+ r = e.payload.get("relation", {})
64
+ return (
65
+ f'{_format_tag("relation.created")}'
66
+ f'{r.get("source", "?")} --{r.get("type", "?")}--> {r.get("target", "?")}'
67
+ )
68
+
69
+
70
+ def _fmt_relation_removed(e: Event) -> str:
71
+ return f'{_format_tag("relation.removed")}{e.payload.get("id", "?")}'
72
+
73
+
74
+ def _fmt_patch_applied(e: Event) -> str:
75
+ target = e.payload.get("target", "?")
76
+ diff = e.payload.get("diff", {})
77
+ if not diff:
78
+ return f'{_format_tag("patch.applied")}{target} (no change)'
79
+ lines = []
80
+ for field, change in diff.items():
81
+ old = change.get("old")
82
+ new = change.get("new")
83
+ body = f"{target} {field}: {old} -> {new}"
84
+ lines.append(f'{_format_tag("patch.applied")}{body}')
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _fmt_patch_proposed(e: Event) -> str:
89
+ p = e.payload.get("patch", {})
90
+ return (
91
+ f'{_format_tag("patch.proposed")}'
92
+ f'{p.get("target", "?")} {p.get("op", "?")} by {p.get("proposed_by", "?")}'
93
+ )
94
+
95
+
96
+ def _fmt_patch_rejected(e: Event) -> str:
97
+ return (
98
+ f'{_format_tag("patch.rejected")}'
99
+ f'{e.payload.get("patch_id", "?")}: {e.payload.get("reason", "?")}'
100
+ )
101
+
102
+
103
+ def _fmt_behavior_started(e: Event) -> str:
104
+ name = e.payload.get("behavior", "?")
105
+ triggering_type = e.payload.get("triggering_event_type")
106
+ triggering_id = e.payload.get("triggering_object_id")
107
+ if triggering_type and triggering_id:
108
+ return f'{_format_tag("behavior.started")}{name} (matched {triggering_type}: {triggering_id})'
109
+ return f'{_format_tag("behavior.started")}{name}'
110
+
111
+
112
+ def _fmt_relation_behavior_started(e: Event) -> str:
113
+ name = e.payload.get("behavior", "?")
114
+ triggering_type = e.payload.get("triggering_event_type", "?")
115
+ relation_type = e.payload.get("relation_type", "?")
116
+ return (
117
+ f'{_format_tag("relation_behavior.started")}'
118
+ f'{name} (matched {triggering_type} on {relation_type} edge)'
119
+ )
120
+
121
+
122
+ def _fmt_behavior_completed(e: Event) -> str:
123
+ name = e.payload.get("behavior", "?")
124
+ n_obj = int(e.payload.get("objects_created", 0))
125
+ n_rel = int(e.payload.get("relations_created", 0))
126
+ # Count summary only when behavior produced structure (>= 2 mutations
127
+ # combined). Single-action behaviors render as just `name`. See CONTRACT.
128
+ if n_obj + n_rel >= 2:
129
+ return (
130
+ f'{_format_tag("behavior.completed")}'
131
+ f'{name} ({_plural(n_obj, "object")}, {_plural(n_rel, "relation")})'
132
+ )
133
+ return f'{_format_tag("behavior.completed")}{name}'
134
+
135
+
136
+ def _fmt_behavior_failed(e: Event) -> str:
137
+ name = e.payload.get("behavior", "?")
138
+ et = e.payload.get("exception_type", "?")
139
+ msg = e.payload.get("message", "")
140
+ return f'{_format_tag("behavior.failed")}{name}: {et}: {msg}'
141
+
142
+
143
+ def _fmt_llm_requested(e: Event, *, hide_prompt_normalized: bool = False) -> str:
144
+ """CONTRACT v0.6 #14 (+ v0.7 turn_index + v0.9.1 prompt_normalized rollup):
145
+ `[llm.requested] evt_NNN behavior model=... tokens_in~NNNN budget_remaining=$X.XX`
146
+
147
+ The `~` prefix on `tokens_in` marks an estimate; absent if no
148
+ pre-call count was made (no cost budget OR cache hit). The
149
+ `budget_remaining=$...` segment is dropped if no cost budget.
150
+ v0.7 adds `turn=N` for tool-loop turns past the first.
151
+
152
+ `prompt_normalized=true` was on every line in v0.7-v0.9. v0.9.1
153
+ rolls it up to a single `[trace.flags]` header when uniform across
154
+ all non-replayed `llm.requested` events, dropping the per-line
155
+ flag. Mixed-state traces (rare) keep the per-line flag for
156
+ precision. `hide_prompt_normalized=True` is set by the trace
157
+ facade when the rollup is active.
158
+ """
159
+ p = e.payload or {}
160
+ name = p.get("behavior", "?")
161
+ parts: list[str] = [f"{e.id} {name}", f"model={p.get('model', '?')}"]
162
+ if p.get("cache_hit"):
163
+ parts.append("cache_hit=true")
164
+ turn_idx = p.get("turn_index")
165
+ if turn_idx is not None and turn_idx > 0:
166
+ parts.append(f"turn={turn_idx}")
167
+ if "estimated_input_tokens" in p:
168
+ parts.append(f"tokens_in~{p['estimated_input_tokens']}")
169
+ if p.get("budget_remaining_usd") is not None:
170
+ parts.append(f"budget_remaining=${_money(p['budget_remaining_usd'])}")
171
+ if p.get("prompt_normalized") and not hide_prompt_normalized:
172
+ parts.append("prompt_normalized=true")
173
+ return f'{_format_tag("llm.requested")}{" ".join([parts[0], " ".join(parts[1:])])}'
174
+
175
+
176
+ def _fmt_llm_responded(e: Event) -> str:
177
+ """CONTRACT v0.6 #14:
178
+ `[llm.responded] evt_NNN behavior tokens_in=NNNN tokens_out=NNN cost=$X.XXX latency=X.Xs`
179
+
180
+ Cache hits render with `cache_hit=true` and no cost/latency segments
181
+ (latency is the cached response's recorded latency, not now).
182
+ """
183
+ p = e.payload or {}
184
+ name = p.get("behavior", "?")
185
+ parts: list[str] = [f"{e.id} {name}"]
186
+ if p.get("cache_hit"):
187
+ parts.append("cache_hit=true")
188
+ in_tok = p.get("input_tokens")
189
+ out_tok = p.get("output_tokens")
190
+ if in_tok is not None:
191
+ parts.append(f"tokens_in={in_tok}")
192
+ if out_tok is not None:
193
+ parts.append(f"tokens_out={out_tok}")
194
+ cost = p.get("cost_usd")
195
+ if cost is not None and not p.get("cache_hit"):
196
+ parts.append(f"cost=${_money(cost)}")
197
+ lat = p.get("latency_seconds")
198
+ if lat is not None and not p.get("cache_hit"):
199
+ parts.append(f"latency={float(lat):.1f}s")
200
+ return f'{_format_tag("llm.responded")}{" ".join([parts[0], " ".join(parts[1:])])}'
201
+
202
+
203
+ def _money(v: Any) -> str:
204
+ try:
205
+ return f"{float(v):.3f}"
206
+ except (TypeError, ValueError):
207
+ return str(v)
208
+
209
+
210
+ # ---------- v0.7 trace lines: tool.* / pattern.* / behavior.scheduled ------
211
+
212
+
213
+ def _fmt_tool_requested(e: Event) -> str:
214
+ """CONTRACT v0.7 #18:
215
+ `[tool.requested] evt_NNN behavior tool=name args_hash=AAA cache_hit=false`
216
+ """
217
+ p = e.payload or {}
218
+ name = p.get("behavior", "?")
219
+ tool = p.get("tool", "?")
220
+ parts: list[str] = [
221
+ f"{e.id} {name}",
222
+ f"tool={tool}",
223
+ f"args_hash={_short_hash(p.get('args_hash', '?'))}",
224
+ ]
225
+ if p.get("cache_hit"):
226
+ parts.append("cache_hit=true")
227
+ if p.get("deterministic"):
228
+ parts.append("deterministic=true")
229
+ return f'{_format_tag("tool.requested")}{" ".join([parts[0], " ".join(parts[1:])])}'
230
+
231
+
232
+ def _fmt_tool_responded(e: Event) -> str:
233
+ """CONTRACT v0.7 #18:
234
+ `[tool.responded] evt_NNN behavior tool=name latency=X.Xs cost=$X.XXX cache_hit=false`
235
+ """
236
+ p = e.payload or {}
237
+ name = p.get("behavior", "?")
238
+ tool = p.get("tool", "?")
239
+ parts: list[str] = [f"{e.id} {name}", f"tool={tool}"]
240
+ if p.get("cache_hit"):
241
+ parts.append("cache_hit=true")
242
+ err = p.get("error")
243
+ if err:
244
+ reason = err.get("reason", "tool.error") if isinstance(err, dict) else "tool.error"
245
+ parts.append(f"error={reason}")
246
+ else:
247
+ lat = p.get("latency_seconds")
248
+ cost = p.get("cost_usd")
249
+ if lat is not None and not p.get("cache_hit"):
250
+ parts.append(f"latency={float(lat):.1f}s")
251
+ if cost is not None and not p.get("cache_hit"):
252
+ parts.append(f"cost=${_money(cost)}")
253
+ return f'{_format_tag("tool.responded")}{" ".join([parts[0], " ".join(parts[1:])])}'
254
+
255
+
256
+ def _fmt_pattern_matched(e: Event) -> str:
257
+ """CONTRACT v0.7 #18:
258
+ `[pattern.matched] evt_NNN behavior matches=N`
259
+ """
260
+ p = e.payload or {}
261
+ name = p.get("behavior", "?")
262
+ n = int(p.get("matches_count", 0))
263
+ return (
264
+ f'{_format_tag("pattern.matched")}'
265
+ f'{e.id} {name} matches={n}'
266
+ )
267
+
268
+
269
+ def _fmt_behavior_scheduled(e: Event) -> str:
270
+ """CONTRACT v0.7 #18:
271
+ `[behavior.scheduled] evt_NNN behavior activate_after=N_events`
272
+ """
273
+ p = e.payload or {}
274
+ name = p.get("behavior", "?")
275
+ n = int(p.get("activate_after", 0))
276
+ return (
277
+ f'{_format_tag("behavior.scheduled")}'
278
+ f'{e.id} {name} activate_after={n}_event{"s" if n != 1 else ""}'
279
+ )
280
+
281
+
282
+ def _short_hash(h: str) -> str:
283
+ """Trim a long hex hash to 8 chars for trace readability."""
284
+ if not isinstance(h, str):
285
+ return str(h)
286
+ return h[:8] if len(h) > 8 else h
287
+
288
+
289
+ def _fmt_runtime_idle(_: Event) -> str:
290
+ return f'{_format_tag("runtime.idle")}queue empty, budget remaining'
291
+
292
+
293
+ def _fmt_pack_loaded(e: Event) -> str:
294
+ """CONTRACT v0.9 #25:
295
+ `[pack.loaded] diligence v0.1.0 (8 object_types, 6 relation_types,
296
+ 7 behaviors, 3 tools, 2 policies, 5 prompts)`
297
+
298
+ The pack.loaded event payload carries the pack name, version, and
299
+ the full structural inventory. The trace line summarizes counts;
300
+ the full payload is in the JSONL export and on `activegraph
301
+ inspect --pack-version` for operators who need the prompt hashes.
302
+ """
303
+ p = e.payload or {}
304
+ name = p.get("name", "?")
305
+ version = p.get("version", "?")
306
+ # (count, singular, plural) — "policy" needs the irregular plural;
307
+ # the rest are regular but the table keeps the form explicit so
308
+ # future additions don't drift through _plural()'s simple +s rule.
309
+ counts = [
310
+ (len(p.get("object_types") or []), "object_type", "object_types"),
311
+ (len(p.get("relation_types") or []), "relation_type", "relation_types"),
312
+ (len(p.get("behaviors") or []), "behavior", "behaviors"),
313
+ (len(p.get("tools") or []), "tool", "tools"),
314
+ (len(p.get("policies") or []), "policy", "policies"),
315
+ (len(p.get("prompts") or {}), "prompt", "prompts"),
316
+ ]
317
+ summary = ", ".join(
318
+ f"{n} {singular if n == 1 else plural}"
319
+ for n, singular, plural in counts
320
+ if n > 0
321
+ )
322
+ return f'{_format_tag("pack.loaded")}{name} v{version} ({summary})'
323
+
324
+
325
+ def _fmt_runtime_budget_exhausted(e: Event) -> str:
326
+ by = e.payload.get("exhausted_by", "?")
327
+ return f'{_format_tag("runtime.budget_exhausted")}stopped: {by}'
328
+
329
+
330
+ def _fmt_event_emitted(e: Event) -> str:
331
+ """Fallback for user-emitted (custom) events."""
332
+ payload_kvs = []
333
+ for k, v in (e.payload or {}).items():
334
+ payload_kvs.append(f"{k}={_short(v)}")
335
+ body = " ".join([e.type] + payload_kvs)
336
+ return f'{_format_tag("event.emitted")}{body}'
337
+
338
+
339
+ def _short(v: Any) -> str:
340
+ if isinstance(v, str):
341
+ return v
342
+ return repr(v)
343
+
344
+
345
+ _FORMATTERS = {
346
+ "goal.created": _fmt_goal_created,
347
+ "object.created": _fmt_object_created,
348
+ "object.removed": _fmt_object_removed,
349
+ "relation.created": _fmt_relation_created,
350
+ "relation.removed": _fmt_relation_removed,
351
+ "patch.applied": _fmt_patch_applied,
352
+ "patch.proposed": _fmt_patch_proposed,
353
+ "patch.rejected": _fmt_patch_rejected,
354
+ "behavior.started": _fmt_behavior_started,
355
+ "behavior.completed": _fmt_behavior_completed,
356
+ "behavior.failed": _fmt_behavior_failed,
357
+ "behavior.scheduled": _fmt_behavior_scheduled,
358
+ "relation_behavior.started": _fmt_relation_behavior_started,
359
+ "llm.requested": _fmt_llm_requested,
360
+ "llm.responded": _fmt_llm_responded,
361
+ "tool.requested": _fmt_tool_requested,
362
+ "tool.responded": _fmt_tool_responded,
363
+ "pattern.matched": _fmt_pattern_matched,
364
+ "runtime.idle": _fmt_runtime_idle,
365
+ "pack.loaded": _fmt_pack_loaded,
366
+ "runtime.budget_exhausted": _fmt_runtime_budget_exhausted,
367
+ }
368
+
369
+
370
+ def format_event(event: Event, *, hide_prompt_normalized: bool = False) -> str:
371
+ if event.type == "llm.requested":
372
+ return _fmt_llm_requested(event, hide_prompt_normalized=hide_prompt_normalized)
373
+ fn = _FORMATTERS.get(event.type, _fmt_event_emitted)
374
+ return fn(event)
375
+
376
+
377
+ # ---------- v0.9.1: prompt_normalized rollup ----------
378
+
379
+
380
+ def _compute_prompt_normalized_rollup(
381
+ events: list[Event],
382
+ replayed: set[str],
383
+ ) -> dict | None:
384
+ """Return rollup info if every non-replayed `llm.requested` event carries
385
+ `prompt_normalized=true`. Returns None if there are no such events or if
386
+ the flag is mixed across them — in the mixed case the per-line flag is
387
+ kept (rare; signals a real divergence worth seeing).
388
+ """
389
+ llm_reqs = [
390
+ e for e in events
391
+ if e.type == "llm.requested" and e.id not in replayed
392
+ ]
393
+ if not llm_reqs:
394
+ return None
395
+ if not all((e.payload or {}).get("prompt_normalized") for e in llm_reqs):
396
+ return None
397
+ return {"prompt_normalized": True, "count": len(llm_reqs)}
398
+
399
+
400
+ def _fmt_trace_flags(rollup: dict) -> str:
401
+ n = int(rollup["count"])
402
+ return (
403
+ f'{_format_tag("trace.flags")}'
404
+ f'prompt_normalized=true ({_plural(n, "llm request")})'
405
+ )
406
+
407
+
408
+ # ---------- replay rendering (CONTRACT v0.5 #22) ----------
409
+
410
+
411
+ def _fmt_replay(event: Event) -> str:
412
+ """Render a replayed event with the `[replay.event]` prefix.
413
+
414
+ Format: `[replay.event] <evt_id> <event.type> <one-line summary>`
415
+ """
416
+ t = event.type
417
+ p = event.payload or {}
418
+ if t == "object.created":
419
+ o = p.get("object", {})
420
+ oid = o.get("id", "?")
421
+ label = (o.get("data") or {}).get("title") or (o.get("data") or {}).get("text") or ""
422
+ label_s = f' "{label}"' if label else ""
423
+ body = f"{event.id} {t} {oid}{label_s}"
424
+ elif t == "relation.created":
425
+ r = p.get("relation", {})
426
+ body = f'{event.id} {t} {r.get("source")} --{r.get("type")}--> {r.get("target")}'
427
+ elif t == "patch.applied":
428
+ body = f'{event.id} {t} {p.get("target", "?")}'
429
+ elif t == "goal.created":
430
+ body = f'{event.id} {t} "{p.get("goal", "")}"'
431
+ elif t in ("behavior.started", "behavior.completed", "behavior.failed", "relation_behavior.started"):
432
+ body = f'{event.id} {t} {p.get("behavior", "?")}'
433
+ else:
434
+ body = f"{event.id} {t}"
435
+ return f'{_format_tag("replay.event")}{body}'
436
+
437
+
438
+ def _fmt_replay_complete(n: int) -> str:
439
+ return f'{_format_tag("replay.complete")}{n} events replayed, graph reconstructed'
440
+
441
+
442
+ def _fmt_replay_ready() -> str:
443
+ return f'{_format_tag("runtime.idle")}ready to resume'
444
+
445
+
446
+ # ---------- Trace facade exposed via runtime.trace ----------
447
+
448
+
449
+ class Trace:
450
+ def __init__(self, graph: Graph) -> None:
451
+ self._graph = graph
452
+
453
+ def lines(self) -> list[str]:
454
+ replayed = self._graph.replayed_ids
455
+ replayed_count = len(replayed)
456
+ rollup = _compute_prompt_normalized_rollup(
457
+ list(self._graph.events), replayed
458
+ )
459
+ hide_per_line = rollup is not None
460
+ out: list[str] = []
461
+ emitted_boundary = False
462
+ emitted_flags = False
463
+ seen_replayed = 0
464
+ for e in self._graph.events:
465
+ if e.id in replayed:
466
+ out.append(_fmt_replay(e))
467
+ seen_replayed += 1
468
+ continue
469
+ if replayed_count > 0 and not emitted_boundary:
470
+ out.append(_fmt_replay_complete(replayed_count))
471
+ out.append(_fmt_replay_ready())
472
+ emitted_boundary = True
473
+ if rollup is not None and not emitted_flags:
474
+ out.append(_fmt_trace_flags(rollup))
475
+ emitted_flags = True
476
+ line = format_event(e, hide_prompt_normalized=hide_per_line)
477
+ out.append(line)
478
+ if replayed_count > 0 and not emitted_boundary:
479
+ out.append(_fmt_replay_complete(replayed_count))
480
+ out.append(_fmt_replay_ready())
481
+ return out
482
+
483
+ def print(self) -> None:
484
+ for line in self.lines():
485
+ print(line)
486
+
487
+ def export(self, path: str) -> None:
488
+ with open(path, "w") as f:
489
+ for line in self.lines():
490
+ f.write(line + "\n")
491
+
492
+ def causal_chain(self, object_id: str) -> str:
493
+ from activegraph.trace.causal import causal_chain
494
+
495
+ return causal_chain(self._graph, object_id)