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,556 @@
1
+ """``activegraph quickstart`` — the v1.0 onboarding command.
2
+
3
+ Two modes:
4
+
5
+ - ``activegraph quickstart`` — fixture-backed diligence demo. No API
6
+ key, no network. Always-deterministic (FrozenClock + fixed run id +
7
+ seeded behaviors) so the output is byte-identical across machines.
8
+ - ``activegraph quickstart --interactive`` — guided REPL that walks
9
+ the developer through writing their first behavior.
10
+
11
+ The transcript at ``examples/quickstart_session.txt`` is the contract
12
+ for both modes. Every behavior of the command matches a line in the
13
+ transcript. The trace lines in the output come from the canonical
14
+ :class:`activegraph.trace.printer.Trace` — the quickstart command
15
+ does not reformat trace output. The prose framing ("what just
16
+ happened", "try next") is quickstart-specific.
17
+
18
+ CONTRACT v1.0 #1 (the spec), #C3 (no ``--live`` mode), #4d (every
19
+ ``More:`` link in error messages resolves; same discipline applies
20
+ to the "try next" footer's doc links).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import shutil
27
+ import sys
28
+ import tempfile
29
+ from pathlib import Path
30
+ from typing import Optional, TextIO
31
+
32
+ import click
33
+
34
+
35
+ # Always-deterministic demo setup. The quickstart command is a demo;
36
+ # its output should be the same on every machine. Real-run behavior
37
+ # is what examples/diligence_real_run.py already provides for
38
+ # developers who graduate past quickstart.
39
+ #
40
+ # The frozen timestamp is deliberately stylized (2026-01-01) so it
41
+ # reads as obviously synthetic rather than "this demo is from a year
42
+ # ago" — relevant when a developer runs quickstart in 2027 or later.
43
+ _QUICKSTART_FROZEN_TIMESTAMP = "2026-01-01T00:00:00Z"
44
+ _QUICKSTART_RUN_ID = "quickstart_demo_run"
45
+ _QUICKSTART_DB_DIR = "/tmp/activegraph_quickstart"
46
+ _QUICKSTART_DB_PATH = f"{_QUICKSTART_DB_DIR}/{_QUICKSTART_RUN_ID}.db"
47
+
48
+ # Interactive mode's behavior-file location: cwd-subdir for
49
+ # discoverability (per CONTRACT v1.0 #1 prompt review — better than
50
+ # tempdir because the developer wants to refer back to what they
51
+ # just wrote).
52
+ _INTERACTIVE_SUBDIR = "activegraph_quickstart"
53
+ _INTERACTIVE_BEHAVIOR_FILENAME = "my_first_behavior.py"
54
+
55
+
56
+ # ---------- fixture-backed mode ------------------------------------------
57
+
58
+
59
+ def run_fixture_mode(stream: Optional[TextIO] = None) -> int:
60
+ """Run the fixture-backed diligence demo.
61
+
62
+ All output is written to ``stream`` (default stdout). Returns 0.
63
+ Always-deterministic; safe to snapshot-test.
64
+ """
65
+ out = stream if stream is not None else sys.stdout
66
+ write = lambda s: out.write(s + "\n")
67
+
68
+ from activegraph import Graph, IDGen, FrozenClock, Runtime, configure_logging
69
+ from activegraph.packs.diligence import (
70
+ DiligenceSettings,
71
+ pack as diligence_pack,
72
+ )
73
+ from activegraph.packs.diligence.fixtures import (
74
+ RecordedDiligenceProvider,
75
+ THREE_COMPANIES,
76
+ company_goal,
77
+ )
78
+
79
+ # Header — matches transcript BEAT 2.
80
+ write("activegraph quickstart — running the bundled Diligence pack on fixtures.")
81
+ write("")
82
+ write("This will take about 20 seconds. No API key required.")
83
+ write(f" pack: {diligence_pack.name} v{diligence_pack.version} (no external network calls)")
84
+ write(f" companies: {', '.join(c['name'] for c in THREE_COMPANIES)}")
85
+ write(" provider: RecordedDiligenceProvider (fixture-backed)")
86
+ write("")
87
+
88
+ # Suppress structured logging so the only output is the quickstart's.
89
+ configure_logging(level="ERROR", json_output=False)
90
+
91
+ # Fresh DB on every run. The fixed run_id means re-running quickstart
92
+ # overwrites the previous demo — correct for a demo; we don't want
93
+ # quickstart leaving N database files in /tmp.
94
+ Path(_QUICKSTART_DB_DIR).mkdir(parents=True, exist_ok=True)
95
+ if os.path.exists(_QUICKSTART_DB_PATH):
96
+ os.remove(_QUICKSTART_DB_PATH)
97
+
98
+ provider = RecordedDiligenceProvider(companies=THREE_COMPANIES)
99
+ graph = Graph(
100
+ ids=IDGen(),
101
+ clock=FrozenClock(_QUICKSTART_FROZEN_TIMESTAMP),
102
+ run_id=_QUICKSTART_RUN_ID,
103
+ )
104
+ rt = Runtime(
105
+ graph,
106
+ llm_provider=provider,
107
+ persist_to=_QUICKSTART_DB_PATH,
108
+ budget={
109
+ "max_llm_calls": 80,
110
+ "max_tool_calls": 100,
111
+ "max_cost_usd": "5.00",
112
+ },
113
+ seed=0,
114
+ )
115
+ rt.load_pack(
116
+ diligence_pack,
117
+ settings=DiligenceSettings(
118
+ llm_model="claude-sonnet-4-5",
119
+ max_documents_per_company=5,
120
+ max_claims_per_document=10,
121
+ confidence_threshold_for_review=0.7,
122
+ min_questions=8,
123
+ max_questions=12,
124
+ ),
125
+ )
126
+
127
+ for company in THREE_COMPANIES:
128
+ rt.run_goal(company_goal(company))
129
+
130
+ rt.save_state()
131
+
132
+ # Trace — canonical printer output. The quickstart does not reformat;
133
+ # any drift between the trace shown here and the trace documented on
134
+ # the doc site is a bug in this code path, not the printer.
135
+ for line in rt.trace.lines():
136
+ write(line)
137
+ write("")
138
+
139
+ # Pull the memos. Print the first in full, mention the others.
140
+ memos = [o for o in rt.graph.all_objects() if o.type == "memo"]
141
+ if memos:
142
+ _print_memo_section(write, rt, memos[0])
143
+ if len(memos) > 1:
144
+ others = [_company_name_for_memo(rt, m) for m in memos[1:]]
145
+ write(
146
+ f"Memos for {', '.join(others)} were produced under the same "
147
+ f"contract. Output written to sqlite:///{_QUICKSTART_DB_PATH}."
148
+ )
149
+ write("")
150
+
151
+ _print_what_just_happened(write)
152
+ _print_try_next(write)
153
+ return 0
154
+
155
+
156
+ def _company_name_for_memo(rt, memo) -> str:
157
+ """Resolve a memo's company_id to a human name for the prose."""
158
+ cid = (memo.data or {}).get("company_id")
159
+ if cid is None:
160
+ return "<unknown>"
161
+ co = rt.graph.get_object(cid)
162
+ if co is None:
163
+ return cid
164
+ return (co.data or {}).get("name") or cid
165
+
166
+
167
+ def _print_memo_section(write, rt, memo) -> None:
168
+ write("-" * 76)
169
+ company = _company_name_for_memo(rt, memo)
170
+ write(f" Memo: {company}")
171
+ write("-" * 76)
172
+ write("")
173
+ data = memo.data or {}
174
+
175
+ summary = data.get("summary", "")
176
+ if summary:
177
+ write("Summary:")
178
+ for line in _wrap_indented(summary, indent=" ", width=74):
179
+ write(line)
180
+ write("")
181
+
182
+ claims = data.get("key_claims") or []
183
+ if claims:
184
+ write("Key claims:")
185
+ for c in claims:
186
+ text = c.get("text") or c.get("claim", "")
187
+ evidence_ids = c.get("evidence_ids") or []
188
+ ev_clause = (
189
+ f" (evidence: {', '.join(evidence_ids)})" if evidence_ids else ""
190
+ )
191
+ write(f" - {text}{ev_clause}")
192
+ write("")
193
+
194
+ contradictions = data.get("open_contradictions") or []
195
+ note = data.get("contradictions_note")
196
+ write("Open contradictions:")
197
+ if contradictions:
198
+ for c in contradictions:
199
+ write(f" - {c}")
200
+ elif note:
201
+ write(f" ({note})")
202
+ else:
203
+ write(" (none surfaced for this company)")
204
+ write("")
205
+
206
+ risks = data.get("risks") or []
207
+ write("Risks:")
208
+ if risks:
209
+ for r in risks:
210
+ title = r.get("title", "")
211
+ severity = r.get("severity", "")
212
+ related = r.get("related_claim_ids") or []
213
+ sev_clause = f"; severity: {severity}" if severity else ""
214
+ rel_clause = (
215
+ f" (related claims: {', '.join(related)}{sev_clause})"
216
+ if related
217
+ else (f" ({sev_clause.lstrip('; ')})" if severity else "")
218
+ )
219
+ write(f" - {title}{rel_clause}")
220
+ else:
221
+ write(" (none identified)")
222
+ write("")
223
+
224
+
225
+ def _wrap_indented(text: str, *, indent: str, width: int) -> list[str]:
226
+ """Wrap prose to a width with a leading indent. Standalone (no
227
+ textwrap import) to keep output stable across Python versions."""
228
+ out: list[str] = []
229
+ current = indent
230
+ for word in text.split():
231
+ if len(current) + len(word) + 1 > width and current.strip():
232
+ out.append(current.rstrip())
233
+ current = indent + word
234
+ else:
235
+ current = (current + " " + word) if current.strip() else (indent + word)
236
+ if current.strip():
237
+ out.append(current.rstrip())
238
+ return out
239
+
240
+
241
+ def _print_what_just_happened(write) -> None:
242
+ write("-" * 76)
243
+ write(" What just happened")
244
+ write("-" * 76)
245
+ write("")
246
+ write(" 1. You loaded a pack. A pack is a Python package that registers")
247
+ write(" object types, behaviors, tools, and prompts. The Diligence pack")
248
+ write(" ships with activegraph.")
249
+ write("")
250
+ write(" 2. The runtime received three goals (one per company) and reactive")
251
+ write(" behaviors fired automatically as objects appeared on the graph.")
252
+ write(" You did not write a workflow. The behaviors are pattern-matched")
253
+ write(" against event types and object shapes.")
254
+ write("")
255
+ write(" 3. Every LLM call was served by the bundled fixture provider")
256
+ write(" (RecordedDiligenceProvider), so the run is deterministic and runs")
257
+ write(" offline. The trace above shows cost and latency on each llm.responded")
258
+ write(" line — those are the fixture's recorded numbers. Production runs")
259
+ write(" against a real provider would show real costs and latencies.")
260
+ write("")
261
+ write(" (The framework also has a separate replay cache that records")
262
+ write(" llm.responded events and serves them back under strict-replay mode")
263
+ write(" or in-process Runtime.fork() — that's where `cache_hit=true` appears")
264
+ write(" in the trace. Different layer from the provider; see the")
265
+ write(" concepts/replay and concepts/forking pages for the deep dive.)")
266
+ write("")
267
+ write(" 4. Each memo cites evidence for every claim, surfaces at least one")
268
+ write(" risk, and either lists open contradictions or states explicitly")
269
+ write(" that none were found. This is the pack's \"verifiable memo bar\" —")
270
+ write(" a memo without these properties is a bug in the pack.")
271
+ write("")
272
+ write(" 5. Every event is on disk. The full causal chain from goal to memo")
273
+ write(" is reconstructable from the event log alone.")
274
+ write("")
275
+
276
+
277
+ def _print_try_next(write) -> None:
278
+ from activegraph.errors import DOCS_BASE_URL
279
+ write("-" * 76)
280
+ write(" Try next")
281
+ write("-" * 76)
282
+ write("")
283
+ write(f" See your run: activegraph inspect sqlite:///{_QUICKSTART_DB_PATH}")
284
+ write(" Write a behavior: activegraph quickstart --interactive")
285
+ write(f" Read the tutorial: {DOCS_BASE_URL}/quickstart")
286
+ write(f" Concept: the graph: {DOCS_BASE_URL}/concepts/graph")
287
+ write(f" Concept: behaviors: {DOCS_BASE_URL}/concepts/behaviors")
288
+ write(f" Common patterns: {DOCS_BASE_URL}/cookbook/common-patterns")
289
+ write("")
290
+
291
+
292
+ # ---------- interactive mode ---------------------------------------------
293
+
294
+
295
+ _INTERACTIVE_SCAFFOLD = '''\
296
+ """Your first activegraph behavior — scaffolded by `activegraph quickstart --interactive`.
297
+
298
+ This behavior fires whenever a claim object is created and emits a
299
+ `growth.flagged` event for claims that mention revenue growth above 25%.
300
+
301
+ Edit the TODO below, save, then type `continue` in the quickstart prompt.
302
+ """
303
+
304
+ import re
305
+
306
+ from activegraph import behavior
307
+
308
+
309
+ @behavior(
310
+ name="growth_flagger",
311
+ on=["object.created"],
312
+ where={"object.type": "claim"},
313
+ )
314
+ def growth_flagger(event, graph, ctx):
315
+ """Flag claims that mention revenue growth above 25%."""
316
+ text = event.payload["object"]["data"].get("text", "")
317
+ # TODO: parse the text for a growth percentage and emit
318
+ # `growth.flagged` with the claim id when the growth is > 25%.
319
+ #
320
+ # Hint: a regex like r"(\\d+)%\\s+YoY" captures the percentage.
321
+ # Then: graph.emit("growth.flagged", {"claim_id": ..., "growth": ...})
322
+ pass
323
+ '''
324
+
325
+
326
+ def _prepare_interactive_subdir(stream: TextIO, prompt_fn) -> Optional[Path]:
327
+ """Set up the activegraph_quickstart/ subdir, handling collision
328
+ helpfully (CONTRACT v1.0 #1 errata clarified: the quickstart is in
329
+ onboarding mode, not debug mode; fail-loud is wrong here).
330
+
331
+ Returns the Path to the behavior file, or None if the developer
332
+ chose to quit.
333
+ """
334
+ subdir = Path.cwd() / _INTERACTIVE_SUBDIR
335
+ behavior_file = subdir / _INTERACTIVE_BEHAVIOR_FILENAME
336
+ if not subdir.exists():
337
+ subdir.mkdir(parents=True)
338
+ return behavior_file
339
+
340
+ # Collision. Offer overwrite / suffix / quit.
341
+ stream.write(
342
+ f"\nAn `{_INTERACTIVE_SUBDIR}/` directory already exists in the\n"
343
+ f"current working directory. What should I do?\n"
344
+ f" [o] overwrite the existing behavior file\n"
345
+ f" [s] suffix the new one (my_first_behavior_2.py, etc.)\n"
346
+ f" [q] quit\n"
347
+ )
348
+ # Re-prompt on unrecognized input. Pre-rc2 behavior fell through
349
+ # to suffix on any non-o/q input, which swallowed typeahead from
350
+ # the next step's prompt (CONTRACT v1.0-rc2 finding M1). Mirrors
351
+ # the iteration loop's "(unrecognized: ...; type X or Y)" pattern
352
+ # below so the interactive flow's two prompts have the same voice.
353
+ while True:
354
+ choice = prompt_fn("choose [o/s/q]: ", default="s").strip().lower()
355
+ if choice == "q":
356
+ return None
357
+ if choice == "o":
358
+ return behavior_file
359
+ if choice == "s" or choice == "":
360
+ n = 2
361
+ while True:
362
+ candidate = subdir / f"my_first_behavior_{n}.py"
363
+ if not candidate.exists():
364
+ return candidate
365
+ n += 1
366
+ stream.write(f"(unrecognized: {choice!r}; choose o, s, or q)\n")
367
+
368
+
369
+ def run_interactive_mode(
370
+ stream: Optional[TextIO] = None,
371
+ *,
372
+ prompt_fn=None,
373
+ ) -> int:
374
+ """Guided REPL for writing the developer's first behavior.
375
+
376
+ ``prompt_fn`` is injectable for testing — production passes
377
+ :func:`click.prompt`; tests pass a scripted-stdin function.
378
+ Returns 0 on success, 1 if the developer chose to quit.
379
+ """
380
+ out = stream if stream is not None else sys.stdout
381
+ prompt_fn = prompt_fn if prompt_fn is not None else _default_prompt
382
+ write = lambda s: out.write(s + "\n")
383
+
384
+ write("activegraph quickstart --interactive — write your first behavior.")
385
+ write("")
386
+ write("We'll add a behavior that flags claims about revenue growth above")
387
+ write("25%. The behavior will fire whenever a claim is created and emit a")
388
+ write("`growth.flagged` event with the claim id.")
389
+ write("")
390
+ write("Step 1 of 4 — create a file.")
391
+ write("")
392
+
393
+ behavior_file = _prepare_interactive_subdir(out, prompt_fn)
394
+ if behavior_file is None:
395
+ write("Goodbye.")
396
+ return 1
397
+
398
+ behavior_file.write_text(_INTERACTIVE_SCAFFOLD)
399
+ write(f"Created {behavior_file}.")
400
+ write("")
401
+ write("Step 2 of 4 — fill in the TODO.")
402
+ write("")
403
+ write(
404
+ f"Open {behavior_file} in your editor and replace the TODO with the\n"
405
+ f"parsing logic. When you've saved the file, type `continue` to test\n"
406
+ f"it. (We don't watch your filesystem — explicit over magic.)"
407
+ )
408
+ write("")
409
+
410
+ while True:
411
+ cmd = prompt_fn("[continue / quit]: ", default="continue").strip().lower()
412
+ if cmd in ("quit", "q"):
413
+ write("")
414
+ write(
415
+ f"Goodbye. Your behavior is at {behavior_file} — keep it, modify\n"
416
+ f"it, or delete the {_INTERACTIVE_SUBDIR}/ directory when you're done."
417
+ )
418
+ return 0
419
+ if cmd not in ("continue", "c", ""):
420
+ write(f"(unrecognized: {cmd!r}; type `continue` or `quit`)")
421
+ continue
422
+
423
+ # Run the goal against the developer's behavior. The run is
424
+ # silent except for the per-fire summary at the end — we don't
425
+ # repeat the full trace from fixture mode here.
426
+ n_fires = _run_user_behavior(behavior_file, write)
427
+
428
+ write("")
429
+ write(f"Step 3 of 4 — your behavior fired {n_fires} time(s) across the run.")
430
+ write("")
431
+ write("Edit the file again and type `continue` to re-test, or `quit` to")
432
+ write("finish.")
433
+ write("")
434
+
435
+
436
+ def _run_user_behavior(behavior_file: Path, write) -> int:
437
+ """Import the developer's behavior in a fresh subprocess-equivalent
438
+ (importlib mechanics — we deliberately do NOT use importlib.reload;
439
+ auto-reload was rejected per CONTRACT v1.0 BEAT 5 errata). Run a
440
+ single-company goal against the fixture data and report how many
441
+ times the developer's behavior fired.
442
+
443
+ For simplicity in v1.0-rc1, this uses importlib.util.spec_from_file_location
444
+ to load the file fresh on each `continue` — each invocation gets a
445
+ fresh module object, so the @behavior decorator's side effects re-fire
446
+ each round.
447
+ """
448
+ import importlib.util
449
+ from activegraph import (
450
+ Graph,
451
+ IDGen,
452
+ FrozenClock,
453
+ Runtime,
454
+ clear_registry,
455
+ configure_logging,
456
+ )
457
+ from activegraph.packs.diligence import (
458
+ DiligenceSettings,
459
+ pack as diligence_pack,
460
+ )
461
+ from activegraph.packs.diligence.fixtures import (
462
+ RecordedDiligenceProvider,
463
+ THREE_COMPANIES,
464
+ company_goal,
465
+ )
466
+
467
+ # Fresh registry per round so the developer's behavior re-registers
468
+ # cleanly. The diligence pack's behaviors re-register via load_pack
469
+ # in the same shape they did in fixture mode.
470
+ clear_registry()
471
+ spec = importlib.util.spec_from_file_location(
472
+ "_activegraph_quickstart_user_behavior", str(behavior_file)
473
+ )
474
+ if spec is None or spec.loader is None:
475
+ write(f"(could not load {behavior_file}; check for syntax errors)")
476
+ return 0
477
+ module = importlib.util.module_from_spec(spec)
478
+ try:
479
+ spec.loader.exec_module(module)
480
+ except Exception as e:
481
+ write(f"(error loading your behavior: {type(e).__name__}: {e})")
482
+ return 0
483
+
484
+ configure_logging(level="ERROR", json_output=False)
485
+ provider = RecordedDiligenceProvider(companies=[THREE_COMPANIES[0]])
486
+ graph = Graph(ids=IDGen(), clock=FrozenClock(_QUICKSTART_FROZEN_TIMESTAMP))
487
+ rt = Runtime(
488
+ graph,
489
+ llm_provider=provider,
490
+ budget={
491
+ "max_llm_calls": 80,
492
+ "max_tool_calls": 100,
493
+ "max_cost_usd": "5.00",
494
+ },
495
+ seed=0,
496
+ )
497
+ rt.load_pack(
498
+ diligence_pack,
499
+ settings=DiligenceSettings(
500
+ llm_model="claude-sonnet-4-5",
501
+ max_documents_per_company=5,
502
+ max_claims_per_document=10,
503
+ ),
504
+ )
505
+ rt.run_goal(company_goal(THREE_COMPANIES[0]))
506
+
507
+ # Count behavior.completed events for the developer's behavior name.
508
+ # If the user renamed it, this returns 0 — that's a finding worth
509
+ # surfacing in v1.1 (read all behavior.completed events, list any
510
+ # that aren't pack-prefixed). For rc1, the scaffold's name is
511
+ # 'growth_flagger'; matching by name keeps the count honest.
512
+ return sum(
513
+ 1 for e in rt.graph.events
514
+ if e.type == "behavior.completed"
515
+ and (e.payload or {}).get("behavior") == "growth_flagger"
516
+ )
517
+
518
+
519
+ def _default_prompt(question: str, *, default: str = "") -> str:
520
+ """click.prompt wrapper. Separate function so tests can inject a
521
+ scripted-stdin replacement without touching click internals."""
522
+ return click.prompt(question, default=default, show_default=False)
523
+
524
+
525
+ # ---------- click command registration -----------------------------------
526
+
527
+
528
+ @click.command("quickstart")
529
+ @click.option(
530
+ "--interactive",
531
+ is_flag=True,
532
+ help=(
533
+ "Walk through writing your first behavior with a guided REPL. "
534
+ "Writes a scaffolded behavior file to ./activegraph_quickstart/ "
535
+ "in the current directory; the directory persists so you can "
536
+ "keep what you wrote."
537
+ ),
538
+ )
539
+ def cmd_quickstart(interactive: bool) -> None:
540
+ """Run the bundled diligence demo, or walk through writing your first behavior.
541
+
542
+ Default mode runs the diligence pack against bundled fixtures with
543
+ no API key and no network. The run is byte-deterministic across
544
+ machines (FrozenClock + fixed run id + seeded behaviors); see the
545
+ transcript at ``examples/quickstart_session.txt`` for the locked
546
+ output shape.
547
+
548
+ ``--interactive`` walks the developer through writing a custom
549
+ behavior with prompts and explanations.
550
+ """
551
+ if interactive:
552
+ rc = run_interactive_mode()
553
+ else:
554
+ rc = run_fixture_mode()
555
+ if rc != 0:
556
+ sys.exit(rc)
@@ -0,0 +1 @@
1
+ """Core primitives. Knows nothing about runtime or behaviors."""
@@ -0,0 +1,46 @@
1
+ """Clock abstraction. CONTRACT #8: behaviors get time via ctx.clock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ class Clock:
9
+ """Real wall-clock UTC. ISO 8601 second precision, Z suffix."""
10
+
11
+ def now(self) -> str:
12
+ return (
13
+ datetime.now(tz=timezone.utc)
14
+ .isoformat(timespec="seconds")
15
+ .replace("+00:00", "Z")
16
+ )
17
+
18
+
19
+ class FrozenClock(Clock):
20
+ """Always returns the same timestamp. For tests and snapshots."""
21
+
22
+ def __init__(self, t: str = "2026-05-15T10:32:01Z") -> None:
23
+ self._t = t
24
+
25
+ def now(self) -> str:
26
+ return self._t
27
+
28
+
29
+ class TickingClock(Clock):
30
+ """Monotonically advances by `step` seconds on every call. For tests that
31
+ care about ordering but don't want wall-clock noise."""
32
+
33
+ def __init__(
34
+ self,
35
+ start: str = "2026-05-15T10:32:01Z",
36
+ step_seconds: int = 1,
37
+ ) -> None:
38
+ self._t = datetime.fromisoformat(start.replace("Z", "+00:00"))
39
+ self._step = step_seconds
40
+
41
+ def now(self) -> str:
42
+ out = self._t.isoformat(timespec="seconds").replace("+00:00", "Z")
43
+ from datetime import timedelta
44
+
45
+ self._t = self._t + timedelta(seconds=self._step)
46
+ return out
@@ -0,0 +1,33 @@
1
+ """Event records. CONTRACT #3: append-only, never modified.
2
+
3
+ Events are dataclasses, frozen at the Python level. Their `payload` dict is
4
+ not deeply frozen — by convention nothing mutates it after `emit`. The runtime
5
+ treats the event log as the source of truth (CONTRACT #2).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Optional
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Event:
16
+ id: str
17
+ type: str
18
+ payload: dict[str, Any] = field(default_factory=dict)
19
+ actor: Optional[str] = None
20
+ frame_id: Optional[str] = None
21
+ caused_by: Optional[str] = None
22
+ timestamp: str = ""
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return {
26
+ "id": self.id,
27
+ "type": self.type,
28
+ "payload": self.payload,
29
+ "actor": self.actor,
30
+ "frame_id": self.frame_id,
31
+ "caused_by": self.caused_by,
32
+ "timestamp": self.timestamp,
33
+ }