reactifact 0.6.0__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 (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
reactifact/session.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from .checkpoints import KVBackend
4
+ from .context import Context
5
+ from .resources import RuntimeResources
6
+
7
+
8
+ class Session:
9
+ """Named session: an isolated Context bound to a key-value store.
10
+
11
+ Saves the full state (commit chain + working tree + head),
12
+ so after a restart the session resumes from the last commit.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ session_id: str,
18
+ context: Context,
19
+ store: SessionStore,
20
+ loaded: bool,
21
+ ):
22
+ self.session_id = session_id
23
+ self.context = context
24
+ self._store = store
25
+ self.loaded = loaded
26
+
27
+ async def save(self) -> None:
28
+ await self._store.save_session(self.session_id, self.context)
29
+
30
+ async def delete(self) -> None:
31
+ await self._store.delete_session(self.session_id)
32
+
33
+
34
+ class SessionStore:
35
+ """Session store on top of KVBackend (session_id → Context)."""
36
+
37
+ def __init__(self, backend: KVBackend):
38
+ self.backend = backend
39
+
40
+ async def save_session(self, session_id: str, context: Context) -> None:
41
+ await context.to_kv(self.backend, session_id)
42
+
43
+ async def load_session(
44
+ self,
45
+ session_id: str,
46
+ resources: RuntimeResources | None = None,
47
+ ) -> Context | None:
48
+ context = await Context.from_kv(self.backend, session_id)
49
+ if context is None:
50
+ return None
51
+ context.resources = resources or RuntimeResources()
52
+ return context
53
+
54
+ async def has_session(self, session_id: str) -> bool:
55
+ return await self.backend.get(session_id) is not None
56
+
57
+ async def list_sessions(self) -> list[str]:
58
+ # Branch keys are the BranchStore's namespace — keep them out of sessions.
59
+ keys = await self.backend.keys()
60
+ return [key for key in keys if not key.startswith("branch:")]
61
+
62
+ async def delete_session(self, session_id: str) -> None:
63
+ await self.backend.delete(session_id)
64
+
65
+ async def open(
66
+ self,
67
+ session_id: str,
68
+ resources: RuntimeResources | None = None,
69
+ ) -> Session:
70
+ """Opens a session: loads an existing one or creates an empty one."""
71
+ context = await self.load_session(session_id, resources)
72
+ loaded = context is not None
73
+ if context is None:
74
+ context = Context(resources=resources or RuntimeResources())
75
+ return Session(session_id, context, self, loaded=loaded)
reactifact/sources.py ADDED
@@ -0,0 +1,498 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import csv
5
+ import hashlib
6
+ import html as _html
7
+ import re
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class SourceRef(BaseModel):
17
+ """A reference to an external object that can be saved as an artifact.
18
+
19
+ May be a search result: then score/title/excerpt are filled in
20
+ (retrieval as a source capability, §8). Materialization is implemented
21
+ through `Source.resolve`.
22
+ """
23
+
24
+ source_id: str
25
+ locator: str
26
+ metadata: dict[str, Any] = Field(default_factory=dict)
27
+ score: float | None = None
28
+ title: str = ""
29
+ excerpt: str = ""
30
+ query_id: str = ""
31
+
32
+ def stable_id(self) -> str:
33
+ raw = f"{self.source_id}:{self.locator}"
34
+ return hashlib.sha1(raw.encode()).hexdigest()
35
+
36
+
37
+ class Source(ABC):
38
+ def __init__(self, source_id: str):
39
+ self.source_id = source_id
40
+ # Sorting hint for aggregating search: preferred sources
41
+ # (e.g., vector RAG) are polled first, the rest fill in.
42
+ self.preferred: bool = False
43
+
44
+ def search(self, query: str, limit: int = 10) -> list[SourceRef]:
45
+ """Finds references to relevant content (by default — cannot do it).
46
+
47
+ The agent must not know the search mechanics: vector/keywords/SQL/CQL
48
+ — that is the source's choice (§8). Sources without search simply
49
+ return an empty list and stay available via resolve.
50
+ """
51
+ return []
52
+
53
+ async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
54
+ """Asynchronous search (embeddings, API). Default — synchronous `search`.
55
+
56
+ Vector sources cannot compute the query embedding synchronously,
57
+ so the aggregator (`ScoutSources`) uses `asearch`.
58
+ """
59
+ return self.search(query, limit)
60
+
61
+ @abstractmethod
62
+ async def resolve(self, ref: SourceRef) -> Any:
63
+ """Resolves a reference into materialized data (e.g., text, structure)."""
64
+ ...
65
+
66
+
67
+ def _token_overlap_score(text: str, query: str) -> float:
68
+ """Core neutral keyword matcher (§67), without linguistic morphology.
69
+
70
+ Exact term overlap (unicode alphanumeric words). Language normalization
71
+ (stemming, synonyms) is the app's concern: its scorer is passed
72
+ to `FileSystemSource(scorer=...)` or `CSVSource(scorer=...)`.
73
+ """
74
+ words = set(re.findall(r"\w{2,}", text.casefold()))
75
+ query_terms = [
76
+ t for t in re.findall(r"\w{2,}", query.casefold()) if not t.isdigit()
77
+ ]
78
+ if not query_terms:
79
+ return 0.0
80
+ hits = sum(1 for term in set(query_terms) if term in words)
81
+ return hits / len(query_terms)
82
+
83
+
84
+ class FileSystemSource(Source):
85
+ """Filesystem source with keyword search (deterministic path, §67).
86
+
87
+ Search is pure lexical over .md/.txt content; no embedders.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ root: str,
93
+ source_id: str = "filesystem",
94
+ extensions: tuple[str, ...] = (".md", ".txt"),
95
+ scorer: Callable[[str, str], float] | None = None,
96
+ ):
97
+ super().__init__(source_id=source_id)
98
+ self.root = Path(root)
99
+ self.extensions = extensions
100
+ self.scorer = scorer or _token_overlap_score
101
+
102
+ def search(self, query: str, limit: int = 10) -> list[SourceRef]:
103
+ if not self.root.exists():
104
+ return []
105
+ results: list[SourceRef] = []
106
+ for path in sorted(self.root.rglob("*")):
107
+ if not path.is_file() or path.suffix not in self.extensions:
108
+ continue
109
+ try:
110
+ content = path.read_text(encoding="utf-8")
111
+ except OSError:
112
+ continue
113
+ score = self.scorer(content, query)
114
+ if score <= 0:
115
+ continue
116
+ locator = str(path.relative_to(self.root))
117
+ excerpt = " ".join(content.split())[:200]
118
+ results.append(
119
+ SourceRef(
120
+ source_id=self.source_id,
121
+ locator=locator,
122
+ score=round(score, 3),
123
+ title=path.name,
124
+ excerpt=excerpt,
125
+ )
126
+ )
127
+ results.sort(key=lambda r: r.score or 0.0, reverse=True)
128
+ return results[:limit]
129
+
130
+ async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
131
+ """Offloads the directory scan to a worker thread (§ reactifact.checkpoints
132
+ has the same pattern): `search()` walks and reads every matching file
133
+ under `root` synchronously, which would otherwise stall the runtime's
134
+ event loop — and every other agent running concurrently with it — for
135
+ as long as the scan takes."""
136
+ return await asyncio.to_thread(self.search, query, limit)
137
+
138
+ async def resolve(self, ref: SourceRef) -> str:
139
+ full_path = self.root / ref.locator
140
+ if not full_path.exists():
141
+ raise FileNotFoundError(f"File not found: {full_path}")
142
+ return await asyncio.to_thread(full_path.read_text, encoding="utf-8")
143
+
144
+
145
+ def _chunk_text(text: str, size: int) -> list[str]:
146
+ """Splits text into chunks by paragraphs, gluing up to `size` characters."""
147
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
148
+ chunks: list[str] = []
149
+ buf = ""
150
+ for paragraph in paragraphs:
151
+ if len(buf) + len(paragraph) + 1 <= size:
152
+ buf = f"{buf}\n{paragraph}".strip()
153
+ else:
154
+ if buf:
155
+ chunks.append(buf)
156
+ buf = paragraph
157
+ if buf:
158
+ chunks.append(buf)
159
+ return chunks
160
+
161
+
162
+ def _cosine(a: list[float], b: list[float]) -> float:
163
+ if not a or not b or len(a) != len(b):
164
+ return 0.0
165
+ dot: float = sum(x * y for x, y in zip(a, b, strict=False))
166
+ na: float = sum(x * x for x in a) ** 0.5
167
+ nb: float = sum(y * y for y in b) ** 0.5
168
+ if na == 0 or nb == 0:
169
+ return 0.0
170
+ return dot / (na * nb)
171
+
172
+
173
+ class EmbeddingSource(Source):
174
+ """Vector source (RAG): embeddings are the search strategy (§8, §9).
175
+
176
+ Indexes files (chunks + vectors) lazily on the first search; `asearch`
177
+ embeds the query and ranks by cosine similarity. Since embedding is
178
+ async, synchronous `search` is not supported — the aggregator must
179
+ call `asearch`. Semantic search is preferred over keyword sources,
180
+ hence `preferred=True` (scout polls it first).
181
+
182
+ The index is built once and kept forever — a file changed under `root`
183
+ after the first search stays invisible until you call `invalidate()`.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ root: str,
189
+ source_id: str = "rag",
190
+ embedder: Any | None = None,
191
+ extensions: tuple[str, ...] = (".md", ".txt"),
192
+ chunk_size: int = 1200,
193
+ score_threshold: float = 0.15,
194
+ ):
195
+ super().__init__(source_id=source_id)
196
+ if embedder is None:
197
+ raise ValueError("EmbeddingSource requires an embedder")
198
+ self.preferred = True # RAG is polled first (RAG-first)
199
+ self.root = Path(root)
200
+ self.embedder = embedder
201
+ self.extensions = extensions
202
+ self.chunk_size = chunk_size
203
+ self.score_threshold = score_threshold
204
+ self._index: list[tuple[str, str, list[float]]] | None = None
205
+
206
+ def search(self, query: str, limit: int = 10) -> list[SourceRef]:
207
+ raise NotImplementedError(
208
+ "EmbeddingSource requires async asearch (embedding is async)"
209
+ )
210
+
211
+ def _scan_chunks_sync(self) -> list[tuple[str, str]]:
212
+ if not self.root.exists():
213
+ return []
214
+ chunks: list[tuple[str, str]] = []
215
+ for path in sorted(self.root.rglob("*")):
216
+ if not path.is_file() or path.suffix not in self.extensions:
217
+ continue
218
+ try:
219
+ content = path.read_text(encoding="utf-8")
220
+ except OSError:
221
+ continue
222
+ for chunk in _chunk_text(content, self.chunk_size):
223
+ chunks.append((str(path.relative_to(self.root)), chunk))
224
+ return chunks
225
+
226
+ async def _ensure_index(self) -> None:
227
+ if self._index is not None:
228
+ return
229
+ # Directory walk + file reads are blocking — run them off the event
230
+ # loop (same reasoning as FileSystemSource.asearch); the embed() call
231
+ # below is presumably already async-native I/O, so it stays direct.
232
+ chunks = await asyncio.to_thread(self._scan_chunks_sync)
233
+ if not chunks:
234
+ self._index = []
235
+ return
236
+ vectors = await self.embedder.embed([text for _, text in chunks])
237
+ self._index = [
238
+ (loc, text, vec) for (loc, text), vec in zip(chunks, vectors, strict=False)
239
+ ]
240
+
241
+ def invalidate(self) -> None:
242
+ """Drops the cached index so the next search rebuilds it from disk.
243
+
244
+ `_ensure_index()` only ever builds the index once, lazily, on the
245
+ first search (see the class docstring) — nothing detects that files
246
+ under `root` changed since. Call this yourself (e.g. after a known
247
+ write, or on a periodic timer) to pick up changes.
248
+ """
249
+ self._index = None
250
+
251
+ async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
252
+ await self._ensure_index()
253
+ if not self._index:
254
+ return []
255
+ qvec = (await self.embedder.embed([query]))[0]
256
+ best: dict[str, tuple[float, str]] = {}
257
+ for loc, text, vec in self._index:
258
+ score = _cosine(qvec, vec)
259
+ if score < self.score_threshold:
260
+ continue
261
+ if loc not in best or score > best[loc][0]:
262
+ best[loc] = (score, text)
263
+ ranked = sorted(best.items(), key=lambda kv: kv[1][0], reverse=True)
264
+ return [
265
+ SourceRef(
266
+ source_id=self.source_id,
267
+ locator=loc,
268
+ score=round(score, 3),
269
+ title=Path(loc).name,
270
+ excerpt=text[:200],
271
+ )
272
+ for loc, (score, text) in ranked[:limit]
273
+ ]
274
+
275
+ async def resolve(self, ref: SourceRef) -> str:
276
+ full_path = self.root / ref.locator
277
+ if not full_path.exists():
278
+ raise FileNotFoundError(f"File not found: {full_path}")
279
+ return await asyncio.to_thread(full_path.read_text, encoding="utf-8")
280
+
281
+
282
+ class CSVSource(Source):
283
+ """CSV source: structured data, not text (§29).
284
+
285
+ Search (`search`) is keywords over the file name, headers and first
286
+ rows; `resolve` returns `{"columns": [...], "rows": [[...]]}` —
287
+ the structure is preserved (schema + rows), the agent computes over it
288
+ deterministically (§67). Refs are marked `metadata.structured` so the
289
+ materializer knows: it is a table, not text (§64).
290
+
291
+ Matching is exact term overlap by default (no stemming/plurals — see
292
+ `_token_overlap_score`); pass `scorer=` (same signature as
293
+ `FileSystemSource`) for language-aware matching, e.g. headers like
294
+ `cost_usd` won't match a query for "costs" without one.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ root: str,
300
+ source_id: str = "csv",
301
+ extensions: tuple[str, ...] = (".csv",),
302
+ scorer: Callable[[str, str], float] | None = None,
303
+ ):
304
+ super().__init__(source_id=source_id)
305
+ self.root = Path(root)
306
+ self.extensions = extensions
307
+ self.scorer = scorer or _token_overlap_score
308
+
309
+ @staticmethod
310
+ def _read(path: Path) -> list[list[str]]:
311
+ with path.open(newline="", encoding="utf-8") as fh:
312
+ return [[(cell or "").strip() for cell in row] for row in csv.reader(fh)]
313
+
314
+ def search(self, query: str, limit: int = 10) -> list[SourceRef]:
315
+ if not self.root.exists():
316
+ return []
317
+ results: list[SourceRef] = []
318
+ for path in sorted(self.root.rglob("*")):
319
+ if not path.is_file() or path.suffix not in self.extensions:
320
+ continue
321
+ try:
322
+ rows = self._read(path)
323
+ except (OSError, csv.Error):
324
+ continue
325
+ if not rows:
326
+ continue
327
+ header = rows[0]
328
+ sample = rows[1 : 1 + _CSV_SEARCH_SAMPLE]
329
+ flat = (
330
+ " ".join(header)
331
+ + " "
332
+ + " ".join(" ".join(cell for cell in row) for row in sample)
333
+ )
334
+ score = self.scorer(f"{path.name} {flat}", query)
335
+ if score <= 0:
336
+ continue
337
+ excerpt = " | ".join(sample[0]) if sample else " | ".join(header)
338
+ results.append(
339
+ SourceRef(
340
+ source_id=self.source_id,
341
+ locator=str(path.relative_to(self.root)),
342
+ metadata={"structured": True},
343
+ score=round(score, 3),
344
+ title=path.name,
345
+ excerpt=excerpt[:200],
346
+ )
347
+ )
348
+ results.sort(key=lambda r: r.score or 0.0, reverse=True)
349
+ return results[:limit]
350
+
351
+ async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
352
+ """Offloads the directory scan to a worker thread — see
353
+ `FileSystemSource.asearch` for why."""
354
+ return await asyncio.to_thread(self.search, query, limit)
355
+
356
+ async def resolve(self, ref: SourceRef) -> dict[str, Any]:
357
+ full_path = self.root / ref.locator
358
+ if not full_path.exists():
359
+ raise FileNotFoundError(f"File not found: {full_path}")
360
+ rows = await asyncio.to_thread(self._read, full_path)
361
+ if not rows:
362
+ return {"columns": [], "rows": []}
363
+ return {"columns": rows[0], "rows": rows[1:]}
364
+
365
+
366
+ _CSV_SEARCH_SAMPLE = 5
367
+
368
+
369
+ def _html_to_text(html: str) -> str:
370
+ """Strips scripts/styles/boilerplate/tags from an HTML document, then
371
+ collapses whitespace into readable paragraphs.
372
+
373
+ Best-effort and dependency-free (regex, not a DOM parser) — good enough
374
+ for typical page markup, where real sites (Wikipedia, docs sites) use
375
+ `<nav>`/`<header>`/`<footer>`/`<aside>` as flat, non-nested landmarks; a
376
+ same-tag region nested inside itself may not fully strip. Without this,
377
+ boilerplate ("Jump to content", cookie banners, site nav) can outrank
378
+ real content in the naive `[:200]`-style offline fallback excerpt.
379
+ """
380
+ without_comments = re.sub(r"<!--.*?-->", " ", html, flags=re.S)
381
+ without_boilerplate = re.sub(
382
+ r"<(script|style|noscript|nav|header|footer|aside)[^>]*>.*?</\1>",
383
+ " ",
384
+ without_comments,
385
+ flags=re.S | re.I,
386
+ )
387
+ without_tags = re.sub(r"<[^>]+>", " ", without_boilerplate)
388
+ text = _html.unescape(without_tags)
389
+ paragraphs = [p.strip() for p in re.split(r"\s*\n\s*", text) if p.strip()]
390
+ return "\n".join(paragraphs).strip()
391
+
392
+
393
+ class WebSource(Source):
394
+ """HTTP source (web pages) with lazy fetching (§6, §32).
395
+
396
+ Register URLs up front (`add_url`) — nothing is fetched until a search or
397
+ resolve actually needs the page. `asearch` lazily fetches the registered
398
+ pages, ranks them by keyword overlap, and returns `SourceRef`s whose
399
+ `locator` is the URL. `resolve` downloads the page and returns its text.
400
+ This keeps "go somewhere for the data" a Source capability, not core agent
401
+ logic. `transport` is injectable for hermetic tests (MockTransport).
402
+ """
403
+
404
+ DEFAULT_UA = "reactifact-agent (+https://github.com/bzdvdn/reactifact)"
405
+
406
+ def __init__(
407
+ self,
408
+ urls: list[str] | None = None,
409
+ source_id: str = "web",
410
+ timeout: float = 15.0,
411
+ transport: Any | None = None,
412
+ user_agent: str = DEFAULT_UA,
413
+ ):
414
+ super().__init__(source_id=source_id)
415
+ self._urls: list[tuple[str, str]] = []
416
+ self._cache: dict[str, tuple[str, str]] = {} # url -> (title, text)
417
+ self._timeout = timeout
418
+ self._transport = transport
419
+ self._headers = {"User-Agent": user_agent, "Accept": "text/html,*/*"}
420
+ self._client: Any | None = None
421
+ if urls:
422
+ for url in urls:
423
+ self.add_url(url)
424
+
425
+ def add_url(self, url: str, title: str = "") -> None:
426
+ """Registers a URL to consider; the page is fetched lazily."""
427
+ if not any(u == url for u, _ in self._urls):
428
+ self._urls.append((url, title))
429
+
430
+ @property
431
+ def urls(self) -> list[tuple[str, str]]:
432
+ """Registered (url, title) pairs — never fetched by just listing them."""
433
+ return list(self._urls)
434
+
435
+ def _get_client(self) -> Any:
436
+ if self._client is None:
437
+ import httpx
438
+
439
+ self._client = httpx.AsyncClient(
440
+ timeout=self._timeout, transport=self._transport, headers=self._headers
441
+ )
442
+ return self._client
443
+
444
+ async def _fetch(self, url: str) -> str:
445
+ response = await self._get_client().get(url)
446
+ response.raise_for_status()
447
+ return str(response.text)
448
+
449
+ async def _ensure(self, url: str, title: str = "") -> tuple[str, str]:
450
+ """Fetches and caches (title, text) for a URL if it is not cached yet."""
451
+ if url not in self._cache:
452
+ html_text = await self._fetch(url)
453
+ page_title = title
454
+ if not page_title:
455
+ match = re.search(r"<title[^>]*>(.*?)</title>", html_text, re.S | re.I)
456
+ page_title = match.group(1).strip() if match else url
457
+ self._cache[url] = (
458
+ page_title,
459
+ _html_to_text(html_text),
460
+ )
461
+ return self._cache[url]
462
+
463
+ def search(self, query: str, limit: int = 10) -> list[SourceRef]:
464
+ """Keyword search over already-cached pages (§8 keyword, run on demand).
465
+
466
+ Pages that have not been fetched yet are invisible here — call
467
+ `asearch` for a live pass over the registered URLs.
468
+ """
469
+ results: list[SourceRef] = []
470
+ for url, title in self._urls:
471
+ entry = self._cache.get(url)
472
+ if entry is None:
473
+ continue
474
+ page_title, text = entry
475
+ score = _token_overlap_score(text, query)
476
+ if score <= 0:
477
+ continue
478
+ results.append(
479
+ SourceRef(
480
+ source_id=self.source_id,
481
+ locator=url,
482
+ score=round(score, 3),
483
+ title=page_title or title,
484
+ excerpt=text[:220],
485
+ )
486
+ )
487
+ results.sort(key=lambda r: r.score or 0.0, reverse=True)
488
+ return results[:limit]
489
+
490
+ async def asearch(self, query: str, limit: int = 10) -> list[SourceRef]:
491
+ """Lazily fetches all registered URLs, then ranks them by relevance."""
492
+ if self._urls:
493
+ await asyncio.gather(*(self._ensure(url, t) for url, t in self._urls))
494
+ return self.search(query, limit)
495
+
496
+ async def resolve(self, ref: SourceRef) -> str:
497
+ _, text = await self._ensure(ref.locator, ref.title)
498
+ return text
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ProgressEvent:
11
+ """Progress event that agents publish into the stream (aggregate statuses).
12
+
13
+ Candidate kinds: "status" (Thinking…, Searching in …, Found N…, Composing answer…).
14
+ The app renders them in the chat; `data` holds the details (source, count, …).
15
+ """
16
+
17
+ kind: str
18
+ message: str = ""
19
+ data: dict[str, Any] = field(default_factory=dict)
20
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ return {
24
+ "kind": self.kind,
25
+ "message": self.message,
26
+ "data": self.data,
27
+ "timestamp": self.timestamp.isoformat(),
28
+ }
29
+
30
+
31
+ QueueEvent = asyncio.Queue[ProgressEvent]
32
+
33
+
34
+ class EventHub:
35
+ """Broadcaster of agent status events into a single- or multi-stream.
36
+
37
+ Publishing without subscribers is a no-op, so ordinary (non-streaming) runtime
38
+ runs pay nothing for announce.
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ self._subscribers: set[QueueEvent] = set()
43
+
44
+ @property
45
+ def has_subscribers(self) -> bool:
46
+ return bool(self._subscribers)
47
+
48
+ def subscribe(self) -> QueueEvent:
49
+ queue: QueueEvent = asyncio.Queue()
50
+ self._subscribers.add(queue)
51
+ return queue
52
+
53
+ def unsubscribe(self, queue: QueueEvent) -> None:
54
+ self._subscribers.discard(queue)
55
+
56
+ def publish(self, event: ProgressEvent) -> None:
57
+ for queue in self._subscribers:
58
+ queue.put_nowait(event)