agentgraph-server 0.5.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 (49) hide show
  1. agentgraph/__init__.py +1 -0
  2. agentgraph/auth/__init__.py +0 -0
  3. agentgraph/auth/credentials.py +224 -0
  4. agentgraph/backends/__init__.py +50 -0
  5. agentgraph/backends/sqlite/__init__.py +1 -0
  6. agentgraph/backends/sqlite/backend.py +1471 -0
  7. agentgraph/backends/sqlite/vector.py +142 -0
  8. agentgraph/cli.py +721 -0
  9. agentgraph/cli_query.py +519 -0
  10. agentgraph/config.py +90 -0
  11. agentgraph/connectors/__init__.py +0 -0
  12. agentgraph/connectors/base.py +455 -0
  13. agentgraph/connectors/registry.py +78 -0
  14. agentgraph/connectors/status.py +244 -0
  15. agentgraph/core/__init__.py +0 -0
  16. agentgraph/core/context.py +26 -0
  17. agentgraph/core/runtime.py +36 -0
  18. agentgraph/core/storage.py +240 -0
  19. agentgraph/graph/__init__.py +1 -0
  20. agentgraph/graph/bookmark.py +87 -0
  21. agentgraph/graph/delete.py +17 -0
  22. agentgraph/graph/download.py +35 -0
  23. agentgraph/graph/embeddings.py +58 -0
  24. agentgraph/graph/fetch.py +53 -0
  25. agentgraph/graph/gc.py +26 -0
  26. agentgraph/graph/link.py +63 -0
  27. agentgraph/graph/person.py +40 -0
  28. agentgraph/graph/query.py +244 -0
  29. agentgraph/graph/upsert.py +49 -0
  30. agentgraph/logging.py +78 -0
  31. agentgraph/mcp/__init__.py +0 -0
  32. agentgraph/mcp/server.py +811 -0
  33. agentgraph/perf.py +43 -0
  34. agentgraph/server/__init__.py +0 -0
  35. agentgraph/server/app.py +133 -0
  36. agentgraph/server/cli_api.py +708 -0
  37. agentgraph/server/dwell.py +79 -0
  38. agentgraph/server/graph_api.py +46 -0
  39. agentgraph/server/router.py +47 -0
  40. agentgraph/server/sync.py +247 -0
  41. agentgraph/skills.py +93 -0
  42. agentgraph_server-0.5.0.data/data/.agents/skills/graph/SKILL.md +159 -0
  43. agentgraph_server-0.5.0.data/data/.agents/skills/slack-auth/SKILL.md +92 -0
  44. agentgraph_server-0.5.0.dist-info/METADATA +286 -0
  45. agentgraph_server-0.5.0.dist-info/RECORD +49 -0
  46. agentgraph_server-0.5.0.dist-info/WHEEL +5 -0
  47. agentgraph_server-0.5.0.dist-info/entry_points.txt +2 -0
  48. agentgraph_server-0.5.0.dist-info/licenses/LICENSE +21 -0
  49. agentgraph_server-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,519 @@
1
+ """CLI query commands backed by the running AgentGraph server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, NoReturn, cast
7
+
8
+ import httpx
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from agentgraph.config import get_settings
13
+
14
+ console = Console()
15
+ GET_TIMEOUT = httpx.Timeout(10, connect=0.5)
16
+ POST_TIMEOUT = httpx.Timeout(30, connect=0.5)
17
+ FETCH_TIMEOUT = httpx.Timeout(15 * 60, connect=0.5)
18
+
19
+
20
+ def _server_base() -> str:
21
+ s = get_settings()
22
+ return f"http://{s.server_host}:{s.server_port}/api/cli"
23
+
24
+
25
+ def _server_unavailable(exc: Exception) -> NoReturn:
26
+ console.print(
27
+ f"[red]AgentGraph server is not available at {_server_base()}.[/red]\n"
28
+ "Start it with: [bold]agentgraph serve[/bold]",
29
+ )
30
+ raise SystemExit(1) from exc
31
+
32
+
33
+ def _get(path: str, params: dict[str, Any] | None = None) -> Any:
34
+ """GET from the server's CLI API and return parsed JSON."""
35
+ try:
36
+ resp = httpx.get(
37
+ f"{_server_base()}{path}",
38
+ params=params,
39
+ timeout=GET_TIMEOUT,
40
+ )
41
+ resp.raise_for_status()
42
+ return resp.json()
43
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
44
+ _server_unavailable(exc)
45
+
46
+
47
+ def _post(
48
+ path: str,
49
+ params: dict[str, Any] | None = None,
50
+ *,
51
+ timeout: httpx.Timeout = POST_TIMEOUT,
52
+ ) -> Any:
53
+ """POST to the server's CLI API and return parsed JSON."""
54
+ try:
55
+ resp = httpx.post(
56
+ f"{_server_base()}{path}",
57
+ params=params,
58
+ timeout=timeout,
59
+ )
60
+ resp.raise_for_status()
61
+ return resp.json()
62
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
63
+ _server_unavailable(exc)
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # search
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def cmd_search(
71
+ query: str,
72
+ entity_types: list[str],
73
+ limit: int,
74
+ min_score: float,
75
+ as_json: bool,
76
+ platform: str | None = None,
77
+ ) -> None:
78
+ params: dict[str, Any] = {"q": query, "limit": limit, "min_score": min_score}
79
+ if entity_types:
80
+ params["entity_type"] = entity_types
81
+ if platform:
82
+ params["platform"] = platform
83
+
84
+ results = _get("/search", params)
85
+
86
+ if as_json:
87
+ console.print_json(json.dumps(results, default=str))
88
+ return
89
+
90
+ if not results:
91
+ console.print("[dim]No results.[/dim]")
92
+ return
93
+
94
+ table = Table(title=f'Search: "{query}"', show_lines=True)
95
+ table.add_column("ID", style="dim", no_wrap=True, max_width=8)
96
+ table.add_column("Type")
97
+ table.add_column("Platform")
98
+ table.add_column("Title / Content", ratio=1)
99
+ table.add_column("Score", justify="right")
100
+
101
+ for r in results:
102
+ snippet = (r.get("title") or r.get("content") or "")[:120]
103
+ score = f"{r['score']:.4f}" if r.get("score") else "—"
104
+ table.add_row(str(r["id"])[:8], r["entity_type"], r["platform"], snippet, score)
105
+
106
+ console.print(table)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # get
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def _is_stub(entity: dict[str, Any]) -> bool:
114
+ """Return True if the entity has no meaningful content (stub / unfetched)."""
115
+ return not entity.get("title") and not entity.get("content")
116
+
117
+
118
+ def _print_field(label: str, value: object) -> None:
119
+ console.print(f"\n[bold]{label}:[/bold] ", end="")
120
+ console.print(str(value), markup=False, highlight=False)
121
+
122
+
123
+ def _print_entity(entity: dict[str, Any]) -> None:
124
+ """Render an entity in the same detail format used by ``agentgraph get``."""
125
+ console.print(f"[bold]{entity['entity_type']}[/bold] — {entity['platform']}")
126
+ console.print(f"[dim]{entity['id']}[/dim]")
127
+ if entity.get("title"):
128
+ _print_field("Title", entity["title"])
129
+ if entity.get("content"):
130
+ console.print("\n[bold]Content:[/bold]")
131
+ console.print(str(entity["content"]), markup=False, highlight=False)
132
+ if entity.get("metadata"):
133
+ _print_field("Metadata", entity["metadata"])
134
+
135
+
136
+ def cmd_get(entity_id: str, as_json: bool, resolve: bool = False) -> None:
137
+ from agentgraph.graph.query import is_http_url
138
+
139
+ target_is_url = is_http_url(entity_id)
140
+ try:
141
+ entity = (
142
+ _get("/entity-by-url", {"url": entity_id})
143
+ if target_is_url
144
+ else _get(f"/entity/{entity_id}")
145
+ )
146
+ except httpx.HTTPStatusError as exc:
147
+ if exc.response.status_code != 404:
148
+ raise
149
+ entity = None
150
+ if entity is None:
151
+ console.print(f"[red]Entity {entity_id!r} not found.[/red]")
152
+ return
153
+
154
+ if resolve and _is_stub(entity):
155
+ console.print("[dim]Stub entity — fetching from source…[/dim]")
156
+ _post("/fetch-entity", params={"entity_id": entity["id"]})
157
+ refreshed = _get(f"/entity/{entity['id']}")
158
+ if refreshed is not None:
159
+ entity = refreshed
160
+
161
+ if as_json:
162
+ console.print_json(json.dumps(entity, default=str))
163
+ return
164
+
165
+ _print_entity(entity)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # edges
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def cmd_edges(
173
+ entity_id: str, edge_type: str | None, direction: str, as_json: bool
174
+ ) -> None:
175
+ params: dict[str, Any] = {"direction": direction}
176
+ if edge_type:
177
+ params["edge_type"] = edge_type
178
+
179
+ try:
180
+ edges = _get(f"/edges/{entity_id}", params)
181
+ except httpx.HTTPStatusError as exc:
182
+ if exc.response.status_code != 404:
183
+ raise
184
+ console.print(f"[red]Entity {entity_id!r} not found.[/red]")
185
+ return
186
+
187
+ if as_json:
188
+ console.print_json(json.dumps(edges, default=str))
189
+ return
190
+
191
+ if not edges:
192
+ console.print("[dim]No edges found.[/dim]")
193
+ return
194
+
195
+ table = Table(title=f"Edges for {entity_id[:8]}…", show_lines=True)
196
+ table.add_column("Type")
197
+ table.add_column("Direction")
198
+ table.add_column("Other end")
199
+ table.add_column("Platform")
200
+
201
+ for e in edges:
202
+ if e.get("source_entity_id") == entity_id:
203
+ direction_label = "→ out"
204
+ other = e.get("target_ref") or e.get("target_entity_id") or "?"
205
+ else:
206
+ direction_label = "← in"
207
+ other = e.get("source_ref") or e.get("source_entity_id") or "?"
208
+ table.add_row(e["edge_type"], direction_label, str(other), e.get("platform") or "")
209
+
210
+ console.print(table)
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # traverse
215
+ # ---------------------------------------------------------------------------
216
+
217
+ def cmd_traverse(entity_id: str, max_depth: int, as_json: bool, resolve: bool = False) -> None:
218
+ try:
219
+ result = _get(f"/traverse/{entity_id}", {"depth": max_depth})
220
+ except httpx.HTTPStatusError as exc:
221
+ if exc.response.status_code != 404:
222
+ raise
223
+ console.print(f"[red]Entity {entity_id!r} not found.[/red]")
224
+ return
225
+
226
+ if resolve and result:
227
+ stubs = [n for n in result.get("nodes", []) if _is_stub(n)]
228
+ if stubs:
229
+ console.print(f"[dim]Resolving {len(stubs)} stub node(s)…[/dim]")
230
+ for stub in stubs:
231
+ _post("/fetch-entity", params={"entity_id": stub["id"]})
232
+ refreshed = _get(f"/traverse/{entity_id}", {"depth": max_depth})
233
+ if refreshed is not None:
234
+ result = refreshed
235
+
236
+ if as_json:
237
+ console.print_json(json.dumps(result, default=str))
238
+ return
239
+
240
+ nodes = result.get("nodes", [])
241
+ edges = result.get("edges", [])
242
+ console.print(f"[bold]Traversal:[/bold] {len(nodes)} nodes, {len(edges)} edges")
243
+
244
+ table = Table(title="Nodes", show_lines=True)
245
+ table.add_column("ID", style="dim", max_width=8)
246
+ table.add_column("Type")
247
+ table.add_column("Platform")
248
+ table.add_column("Title")
249
+
250
+ for n in nodes:
251
+ stub_marker = " [dim](stub)[/dim]" if _is_stub(n) else ""
252
+ table.add_row(str(n["id"])[:8], n["entity_type"], n["platform"], (n.get("title") or "") + stub_marker)
253
+
254
+ console.print(table)
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # query
259
+ # ---------------------------------------------------------------------------
260
+
261
+ def cmd_fetch(platform: str, resource_id: str, as_json: bool) -> None:
262
+ try:
263
+ result = _post(
264
+ "/fetch",
265
+ params={"platform": platform, "resource_id": resource_id},
266
+ timeout=FETCH_TIMEOUT,
267
+ )
268
+ except httpx.HTTPStatusError as exc:
269
+ try:
270
+ detail = exc.response.json().get("detail", str(exc))
271
+ except Exception:
272
+ detail = str(exc)
273
+ console.print(f"[red]{detail}[/red]")
274
+ return
275
+ if as_json:
276
+ console.print_json(json.dumps(result, default=str))
277
+ return
278
+
279
+ console.print(
280
+ f"[green]Fetched:[/green] {result['entities']} entities, "
281
+ f"{result['persons']} persons, {result['edges']} edges"
282
+ )
283
+
284
+
285
+ def cmd_fetch_entity(entity_id: str, as_json: bool) -> None:
286
+ try:
287
+ result = _post(
288
+ "/fetch-entity",
289
+ params={"entity_id": entity_id},
290
+ timeout=FETCH_TIMEOUT,
291
+ )
292
+ except httpx.HTTPStatusError as exc:
293
+ try:
294
+ detail = exc.response.json().get("detail", str(exc))
295
+ except Exception:
296
+ detail = str(exc)
297
+ console.print(f"[red]{detail}[/red]")
298
+ return
299
+ if as_json:
300
+ console.print_json(json.dumps(result, default=str))
301
+ return
302
+
303
+ console.print(
304
+ f"[green]Fetched:[/green] {result['entities']} entities, "
305
+ f"{result['persons']} persons, {result['edges']} edges"
306
+ )
307
+
308
+
309
+ def cmd_download(entity_id: str, output_path: str | None, as_json: bool) -> None:
310
+ try:
311
+ params: dict[str, Any] = {"entity_id": entity_id}
312
+ if output_path is not None:
313
+ params["output_path"] = output_path
314
+ result = _post("/download", params=params)
315
+ except httpx.HTTPStatusError as exc:
316
+ try:
317
+ detail = exc.response.json().get("detail", str(exc))
318
+ except Exception:
319
+ detail = str(exc)
320
+ console.print(f"[red]{detail}[/red]")
321
+ return
322
+
323
+ if as_json:
324
+ console.print_json(json.dumps(result, default=str))
325
+ return
326
+
327
+ console.print(
328
+ f"[green]Downloaded:[/green] {result['filename']} "
329
+ f"({result['bytes']} bytes) → {result['path']}"
330
+ )
331
+
332
+
333
+ def cmd_bookmark(target: str, bookmarked: bool, as_json: bool) -> None:
334
+ try:
335
+ result = _post("/bookmark", params={"target": target, "bookmarked": bookmarked})
336
+ except httpx.HTTPStatusError as exc:
337
+ try:
338
+ detail = exc.response.json().get("detail", str(exc))
339
+ except Exception:
340
+ detail = str(exc)
341
+ console.print(f"[red]{detail}[/red]")
342
+ return
343
+
344
+ if as_json:
345
+ console.print_json(json.dumps(result, default=str))
346
+ return
347
+
348
+ label = result.get("title") or result.get("platform_entity_id") or result["id"]
349
+ action = "Bookmarked" if bookmarked else "Bookmark removed"
350
+ console.print(f"[green]{action}:[/green] {label} [{result['id'][:8]}]")
351
+
352
+
353
+ def cmd_delete(target: str, as_json: bool) -> None:
354
+ try:
355
+ result = _post("/delete", params={"target": target})
356
+ except httpx.HTTPStatusError as exc:
357
+ try:
358
+ detail = exc.response.json().get("detail", str(exc))
359
+ except Exception:
360
+ detail = str(exc)
361
+ console.print(f"[red]{detail}[/red]")
362
+ return
363
+
364
+ if as_json:
365
+ console.print_json(json.dumps(result, default=str))
366
+ return
367
+
368
+ entity = result["entity"]
369
+ label = entity.get("title") or entity.get("platform_entity_id") or entity["id"]
370
+ console.print(f"[green]Deleted:[/green] {label} [{entity['id'][:8]}]")
371
+
372
+
373
+ def cmd_unify_persons(
374
+ primary_entity_id: str,
375
+ duplicate_entity_ids: list[str],
376
+ as_json: bool,
377
+ ) -> None:
378
+ try:
379
+ result = _post(
380
+ "/unify-persons",
381
+ params={"primary": primary_entity_id, "duplicate": duplicate_entity_ids},
382
+ )
383
+ except httpx.HTTPStatusError as exc:
384
+ try:
385
+ detail = exc.response.json().get("detail", str(exc))
386
+ except Exception:
387
+ detail = str(exc)
388
+ console.print(f"[red]{detail}[/red]")
389
+ return
390
+ if as_json:
391
+ console.print_json(json.dumps(result, default=str))
392
+ return
393
+
394
+ console.print(
395
+ f"[green]Unified:[/green] {result['merged_count']} duplicate person(s). "
396
+ "Canonical person:"
397
+ )
398
+ _print_entity(result["primary"])
399
+
400
+
401
+ def cmd_query(
402
+ entity_type: str,
403
+ filters: dict[str, str],
404
+ limit: int,
405
+ order_by: str,
406
+ since: str | None,
407
+ authored_by_me: bool,
408
+ as_json: bool,
409
+ has_attachments: bool = False,
410
+ ) -> None:
411
+ params: dict[str, Any] = {
412
+ "entity_type": entity_type,
413
+ "limit": limit,
414
+ "order_by": order_by,
415
+ "mine": authored_by_me,
416
+ }
417
+ if since:
418
+ params["since"] = since
419
+ if filters:
420
+ params["filter"] = [f"{k}={v}" for k, v in filters.items()]
421
+ if has_attachments:
422
+ params["has_attachments"] = True
423
+
424
+ results = _get("/query", params)
425
+
426
+ if as_json:
427
+ console.print_json(json.dumps(results, default=str))
428
+ return
429
+
430
+ if not results:
431
+ console.print("[dim]No results.[/dim]")
432
+ return
433
+
434
+ table = Table(title=f"Query: {entity_type}", show_lines=True)
435
+ table.add_column("ID", style="dim", max_width=8)
436
+ table.add_column("Platform")
437
+ table.add_column("Title / Content", ratio=1)
438
+
439
+ for r in results:
440
+ snippet = (r.get("title") or r.get("content") or "")[:120]
441
+ table.add_row(str(r["id"])[:8], r["platform"], snippet)
442
+
443
+ console.print(table)
444
+
445
+
446
+ def cmd_poll(source: str | None, as_json: bool) -> None:
447
+ params: dict[str, Any] = {}
448
+ if source:
449
+ params["source"] = source
450
+ try:
451
+ result = _post("/poll", params=params)
452
+ except httpx.HTTPStatusError as exc:
453
+ try:
454
+ detail = exc.response.json().get("detail", str(exc))
455
+ except Exception:
456
+ detail = str(exc)
457
+ console.print(f"[red]{detail}[/red]")
458
+ return
459
+ if as_json:
460
+ console.print_json(json.dumps(result, default=str))
461
+ return
462
+
463
+ polled: list[str] = result.get("polled", [])
464
+ already_running: list[str] = result.get("already_running", [])
465
+ skipped: list[dict[str, str | None]] = result.get("skipped", [])
466
+ if polled:
467
+ console.print(f"[green]Queued poll:[/green] {', '.join(polled)}")
468
+ if already_running:
469
+ console.print(f"[yellow]Already running:[/yellow] {', '.join(already_running)}")
470
+ for item in skipped:
471
+ source_name = item.get("source") or "unknown"
472
+ reason = item.get("reason") or "not available"
473
+ console.print(f"[yellow]Skipped:[/yellow] {source_name} — {reason}")
474
+ if not polled and not already_running and not skipped:
475
+ console.print("[dim]No connectors polled (none matched or none have poll_interval set).[/dim]")
476
+
477
+
478
+ def queue_connector_poll(source: str) -> dict[str, str | None]:
479
+ """Queue one connector through the server and normalize its schedule result."""
480
+ try:
481
+ result = cast(dict[str, Any], _post("/poll", params={"source": source}))
482
+ except httpx.HTTPStatusError as exc:
483
+ try:
484
+ detail = str(exc.response.json().get("detail", str(exc)))
485
+ except Exception:
486
+ detail = str(exc)
487
+ raise ValueError(detail) from exc
488
+
489
+ if source in cast(list[str], result.get("polled", [])):
490
+ return {"source": source, "status": "queued", "reason": None}
491
+ if source in cast(list[str], result.get("already_running", [])):
492
+ return {"source": source, "status": "already_running", "reason": None}
493
+
494
+ skipped = cast(list[dict[str, str | None]], result.get("skipped", []))
495
+ reason = next(
496
+ (item.get("reason") for item in skipped if item.get("source") == source),
497
+ "poll was not queued",
498
+ )
499
+ return {"source": source, "status": "skipped", "reason": reason}
500
+
501
+
502
+ def cmd_ingest(source: str, as_json: bool) -> None:
503
+ try:
504
+ result = _post("/ingest", params={"source": source})
505
+ except httpx.HTTPStatusError as exc:
506
+ try:
507
+ detail = exc.response.json().get("detail", str(exc))
508
+ except Exception:
509
+ detail = str(exc)
510
+ console.print(f"[red]{detail}[/red]")
511
+ return
512
+ if as_json:
513
+ console.print_json(json.dumps(result, default=str))
514
+ return
515
+
516
+ console.print(
517
+ f"[green]Ingest started[/green] for [bold]{result.get('source')}[/bold] — "
518
+ "progress in server logs (agentgraph serve)"
519
+ )
agentgraph/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """Application configuration loaded from environment variables and the config directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from pydantic import Field
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ def _default_config_dir() -> Path:
13
+ raw = os.environ.get("AGENTGRAPH_CONFIG_DIR")
14
+ return Path(raw).expanduser() if raw else Path.home() / ".agentgraph"
15
+
16
+
17
+ CONFIG_DIR = _default_config_dir()
18
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
19
+ CONFIG_YAML_FILE = CONFIG_DIR / "config.yaml"
20
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
21
+ DEFAULT_SQLITE_PATH = str(CONFIG_DIR / "agentgraph.db")
22
+
23
+
24
+ class Settings(BaseSettings):
25
+ model_config = SettingsConfigDict(
26
+ env_prefix="AGENTGRAPH_",
27
+ # User-level config is loaded first;
28
+ # project-local .env takes precedence.
29
+ env_file=[str(CONFIG_DIR / ".env"), ".env"],
30
+ env_file_encoding="utf-8",
31
+ extra="ignore",
32
+ )
33
+
34
+ # Backend selection
35
+ backend: str = Field(
36
+ default="sqlite",
37
+ description="Persistence backend: 'sqlite' | any installed plugin",
38
+ )
39
+ backend_sqlite_path: str = Field(
40
+ default=DEFAULT_SQLITE_PATH,
41
+ description="Path to SQLite database file (only used when backend='sqlite')",
42
+ )
43
+ backend_sqlite_vector_mode: str = Field(
44
+ default="sqlite-vec",
45
+ description="SQLite vector search mode: 'sqlite-vec' | 'numpy' | 'bm25-only'",
46
+ )
47
+
48
+ # Server
49
+ server_host: str = Field(default="127.0.0.1")
50
+ server_port: int = Field(default=8765)
51
+
52
+ # Dwell detection
53
+ dwell_threshold_seconds: int = Field(
54
+ default=3,
55
+ description="Seconds a focus event must persist without a blur before triggering a fetch",
56
+ )
57
+ dwell_poll_interval_seconds: float = Field(
58
+ default=1.0,
59
+ description="How often the dwell evaluator scans for mature focus events",
60
+ )
61
+
62
+ # Knowledge graph
63
+ retention_days: int = Field(
64
+ default=90,
65
+ description="Days since last_accessed before an entity is garbage collected",
66
+ )
67
+ embedding_model: str = Field(
68
+ default="BAAI/bge-small-en-v1.5",
69
+ description="FastEmbed model name for content embeddings",
70
+ )
71
+ embedding_dimensions: int = Field(default=384)
72
+
73
+ # Connectors
74
+ slack_workspace_id: str | None = Field(
75
+ default=None,
76
+ description="Slack workspace ID (e.g. T01ABC123) to observe; others are ignored",
77
+ )
78
+ # Logging
79
+ log_level: str = Field(default="INFO")
80
+ log_file: Path = Field(default=Path("/tmp/agentgraph.log"))
81
+
82
+
83
+ _settings: Settings | None = None
84
+
85
+
86
+ def get_settings() -> Settings:
87
+ global _settings
88
+ if _settings is None:
89
+ _settings = Settings()
90
+ return _settings
File without changes