know-do-graph 0.1.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 (63) hide show
  1. agents/__init__.py +0 -0
  2. agents/extraction_agent/__init__.py +0 -0
  3. agents/extraction_agent/agent.py +170 -0
  4. agents/graph_agent/__init__.py +5 -0
  5. agents/graph_agent/agent.py +373 -0
  6. agents/graph_agent/tools.py +2106 -0
  7. agents/maintenance_agent/__init__.py +0 -0
  8. agents/maintenance_agent/agent.py +283 -0
  9. agents/orchestrator/__init__.py +0 -0
  10. agents/orchestrator/agent.py +217 -0
  11. agents/review_agent/__init__.py +0 -0
  12. agents/review_agent/agent.py +188 -0
  13. agents/review_agent/tools.py +472 -0
  14. api/__init__.py +0 -0
  15. api/main.py +136 -0
  16. api/routes/__init__.py +0 -0
  17. api/routes/agent.py +81 -0
  18. api/routes/entries.py +411 -0
  19. api/routes/graph.py +132 -0
  20. api/routes/mem.py +179 -0
  21. api/routes/remote.py +815 -0
  22. api/routes/remote_sync.py +230 -0
  23. api/routes/retrieve.py +88 -0
  24. core/__init__.py +0 -0
  25. core/app_state.py +9 -0
  26. core/events.py +84 -0
  27. core/extraction/__init__.py +0 -0
  28. core/extraction/wikilink_parser.py +48 -0
  29. core/graph/__init__.py +0 -0
  30. core/graph/graph.py +204 -0
  31. core/memory/__init__.py +0 -0
  32. core/memory/memgraph.py +458 -0
  33. core/resources/starter.db +0 -0
  34. core/retrieval/__init__.py +0 -0
  35. core/retrieval/embedder.py +122 -0
  36. core/retrieval/fusion.py +52 -0
  37. core/retrieval/progressive.py +399 -0
  38. core/retrieval/retrieval.py +346 -0
  39. core/retrieval/vector_store.py +91 -0
  40. core/schemas/__init__.py +0 -0
  41. core/schemas/edge.py +46 -0
  42. core/schemas/entry.py +388 -0
  43. core/storage/__init__.py +0 -0
  44. core/storage/database.py +104 -0
  45. core/storage/models.py +66 -0
  46. core/storage/repository.py +243 -0
  47. core/sync/__init__.py +20 -0
  48. core/sync/autolink.py +301 -0
  49. core/sync/db_merge.py +297 -0
  50. core/sync/db_watcher.py +84 -0
  51. core/sync/remote_sync.py +345 -0
  52. examples/__init__.py +0 -0
  53. examples/example_entries.py +206 -0
  54. examples/pymatgen_interface_examples.py +811 -0
  55. frontend/dist/assets/index-BLfo7ZZu.css +1 -0
  56. frontend/dist/assets/index-G-mYbZ9R.js +83 -0
  57. frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
  58. frontend/dist/index.html +92 -0
  59. know_do_graph-0.1.0.dist-info/METADATA +765 -0
  60. know_do_graph-0.1.0.dist-info/RECORD +63 -0
  61. know_do_graph-0.1.0.dist-info/WHEEL +4 -0
  62. know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
  63. main.py +944 -0
main.py ADDED
@@ -0,0 +1,944 @@
1
+ """Know-Do Graph — CLI entry point.
2
+
3
+ Usage
4
+ -----
5
+ python main.py --help
6
+ python main.py entry add "My Entry" --content "..." --type tool
7
+ python main.py entry list
8
+ python main.py entry show <id-or-slug>
9
+ python main.py entry search <query>
10
+ python main.py extract file path/to/notes/
11
+ python main.py graph stats
12
+ python main.py graph neighbors <entry-id>
13
+ python main.py mem add "remembered something"
14
+ python main.py agent chat # interactive REPL
15
+ python main.py agent run "task" # one-shot task
16
+ python main.py serve
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import typer
22
+ from pathlib import Path
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+
26
+ # Load .env so OPENAI_API_KEY / OPENAI_API_BASE are available without manual export
27
+ try:
28
+ from dotenv import load_dotenv
29
+ load_dotenv()
30
+ except ImportError:
31
+ pass # python-dotenv not installed; rely on shell environment
32
+
33
+ app = typer.Typer(
34
+ name="know-do-graph",
35
+ help="Wiki-native executable knowledge graph — CLI",
36
+ no_args_is_help=True,
37
+ )
38
+ console = Console()
39
+
40
+
41
+ def _init() -> None:
42
+ from core.storage.database import init_db
43
+
44
+ init_db()
45
+
46
+
47
+ @app.command("init")
48
+ def initialize(
49
+ starter: bool = typer.Option(
50
+ False,
51
+ "--starter",
52
+ help="Initialize from the packaged starter database instead of an empty database.",
53
+ ),
54
+ force: bool = typer.Option(
55
+ False,
56
+ "--force",
57
+ help="Overwrite an existing database. Only valid with --starter.",
58
+ ),
59
+ ) -> None:
60
+ """Initialize the configured working database."""
61
+ from core.storage.database import DB_PATH, init_db, install_starter_database
62
+
63
+ if force and not starter:
64
+ raise typer.BadParameter("--force can only be used with --starter")
65
+
66
+ if starter:
67
+ try:
68
+ install_starter_database(force=force)
69
+ except FileExistsError:
70
+ console.print(
71
+ f"[yellow]Database already exists:[/yellow] {DB_PATH}\n"
72
+ "Use [cyan]--force[/cyan] to replace it with the starter database."
73
+ )
74
+ raise typer.Exit(1)
75
+ except FileNotFoundError as exc:
76
+ console.print(f"[red]Starter database unavailable:[/red] {exc}")
77
+ raise typer.Exit(1)
78
+ console.print(f"[green]Starter database installed:[/green] {DB_PATH}")
79
+ return
80
+
81
+ existed = DB_PATH.exists()
82
+ init_db()
83
+ if existed:
84
+ console.print(f"[yellow]Database already exists:[/yellow] {DB_PATH}")
85
+ else:
86
+ console.print(f"[green]Empty database created:[/green] {DB_PATH}")
87
+
88
+
89
+ def _rebuild_graph() -> None:
90
+ from core import app_state
91
+ from core.sync.db_watcher import reload_graph_from_db
92
+
93
+ reload_graph_from_db(app_state.graph)
94
+
95
+
96
+ # ===========================================================================
97
+ # entry sub-commands
98
+ # ===========================================================================
99
+
100
+ entry_app = typer.Typer(help="Manage knowledge entries", no_args_is_help=True)
101
+ app.add_typer(entry_app, name="entry")
102
+
103
+
104
+ @entry_app.command("add")
105
+ def entry_add(
106
+ title: str = typer.Argument(..., help="Entry title"),
107
+ content: str = typer.Option("", "--content", "-c", help="Entry body (wiki text)"),
108
+ entry_type: str = typer.Option("generic", "--type", "-t", help="Entry type"),
109
+ tags: str = typer.Option("", "--tags", help="Comma-separated tags"),
110
+ source: str = typer.Option(None, "--source", "-s", help="Source provenance URL/path"),
111
+ ) -> None:
112
+ """Add a new knowledge entry."""
113
+ _init()
114
+ from core import app_state
115
+ from core.schemas.entry import Entry, EntryMetadata, EntryType
116
+ from core.storage.database import SessionLocal
117
+ from core.storage.repository import EntryRepository
118
+
119
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
120
+ entry = Entry(
121
+ title=title,
122
+ content=content,
123
+ entry_type=EntryType(entry_type),
124
+ tags=tag_list,
125
+ metadata=EntryMetadata(source_provenance=source),
126
+ )
127
+ with SessionLocal() as db:
128
+ saved = EntryRepository(db).create(entry)
129
+ app_state.graph.add_entry(saved)
130
+ console.print(f"[green]Created:[/green] {saved.title} [dim]({saved.id})[/dim]")
131
+
132
+
133
+ @entry_app.command("list")
134
+ def entry_list(
135
+ limit: int = typer.Option(20, "--limit", "-n"),
136
+ ) -> None:
137
+ """List knowledge entries."""
138
+ _init()
139
+ from core.storage.database import SessionLocal
140
+ from core.storage.repository import EntryRepository
141
+
142
+ with SessionLocal() as db:
143
+ entries = EntryRepository(db).get_all()
144
+
145
+ table = Table("Short ID", "Title", "Type", "Tags", "Refs", show_header=True)
146
+ for e in entries[:limit]:
147
+ table.add_row(
148
+ e.id[:8],
149
+ e.title,
150
+ e.entry_type.value,
151
+ ", ".join(e.tags),
152
+ str(len(e.internal_refs)),
153
+ )
154
+ console.print(table)
155
+ if len(entries) > limit:
156
+ console.print(f"[dim]… {len(entries) - limit} more entries not shown[/dim]")
157
+
158
+
159
+ @entry_app.command("show")
160
+ def entry_show(
161
+ identifier: str = typer.Argument(..., help="Entry ID or slug"),
162
+ ) -> None:
163
+ """Show full details of an entry."""
164
+ _init()
165
+ from core import app_state
166
+ from core.retrieval.retrieval import RetrievalEngine
167
+ from core.storage.database import SessionLocal
168
+
169
+ with SessionLocal() as db:
170
+ engine = RetrievalEngine(db, app_state.graph)
171
+ entry = engine.get_entry_by_id(identifier) or engine.get_entry_by_slug(identifier)
172
+
173
+ if not entry:
174
+ console.print(f"[red]Not found:[/red] {identifier}")
175
+ raise typer.Exit(1)
176
+
177
+ console.rule(f"[bold]{entry.title}[/bold]")
178
+ console.print(f"[dim]ID :[/dim] {entry.id}")
179
+ console.print(f"[dim]Slug :[/dim] {entry.slug}")
180
+ console.print(f"[dim]Type :[/dim] {entry.entry_type.value}")
181
+ console.print(f"[dim]Tags :[/dim] {', '.join(entry.tags) or '—'}")
182
+ console.print(f"[dim]Refs :[/dim] {', '.join(entry.internal_refs) or '—'}")
183
+ console.print(
184
+ f"[dim]Source :[/dim] {entry.metadata.source_provenance or '—'}"
185
+ )
186
+ console.print(
187
+ f"[dim]Status :[/dim] {entry.metadata.refinement_status.value}"
188
+ )
189
+ console.rule()
190
+ console.print(entry.content)
191
+
192
+
193
+ @entry_app.command("search")
194
+ def entry_search(
195
+ query: str = typer.Argument(...),
196
+ limit: int = typer.Option(10, "--limit", "-n"),
197
+ ) -> None:
198
+ """Full-text search across entries."""
199
+ _init()
200
+ from core import app_state
201
+ from core.retrieval.retrieval import RetrievalEngine
202
+ from core.storage.database import SessionLocal
203
+
204
+ with SessionLocal() as db:
205
+ engine = RetrievalEngine(db, app_state.graph)
206
+ results = engine.search_entries(query=query, limit=limit)
207
+
208
+ table = Table("Short ID", "Title", "Type", "Tags")
209
+ for e in results:
210
+ table.add_row(e.id[:8], e.title, e.entry_type.value, ", ".join(e.tags))
211
+ console.print(table)
212
+
213
+
214
+ @entry_app.command("delete")
215
+ def entry_delete(
216
+ entry_id: str = typer.Argument(..., help="Entry ID"),
217
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
218
+ ) -> None:
219
+ """Delete an entry."""
220
+ if not yes:
221
+ typer.confirm(f"Delete entry {entry_id}?", abort=True)
222
+ _init()
223
+ from core import app_state
224
+ from core.storage.database import SessionLocal
225
+ from core.storage.repository import EntryRepository
226
+
227
+ with SessionLocal() as db:
228
+ deleted = EntryRepository(db).delete(entry_id)
229
+ if deleted:
230
+ app_state.graph.remove_entry(entry_id)
231
+ console.print(f"[green]Deleted:[/green] {entry_id}")
232
+ else:
233
+ console.print(f"[red]Not found:[/red] {entry_id}")
234
+ raise typer.Exit(1)
235
+
236
+
237
+ # ===========================================================================
238
+ # extract sub-commands
239
+ # ===========================================================================
240
+
241
+ extract_app = typer.Typer(help="Extract entries from files or directories", no_args_is_help=True)
242
+ app.add_typer(extract_app, name="extract")
243
+
244
+
245
+ @extract_app.command("file")
246
+ def extract_file(
247
+ path: str = typer.Argument(..., help="File or directory to extract from"),
248
+ entry_type: str = typer.Option("generic", "--type", "-t"),
249
+ tags: str = typer.Option("", "--tags"),
250
+ resolve: bool = typer.Option(
251
+ True, "--resolve/--no-resolve", help="Resolve [[wikilinks]] after extraction"
252
+ ),
253
+ ) -> None:
254
+ """Extract entries from a file or directory of text/markdown files."""
255
+ _init()
256
+ from pathlib import Path as P
257
+
258
+ from core import app_state
259
+ from core.schemas.entry import EntryType
260
+ from agents.extraction_agent.agent import ExtractionAgent
261
+
262
+ agent = ExtractionAgent(app_state.graph)
263
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
264
+ target = P(path)
265
+
266
+ with console.status(f"Extracting from [bold]{target}[/bold]…"):
267
+ if target.is_dir():
268
+ extracted = agent.extract_from_directory(
269
+ target, entry_type=EntryType(entry_type), tags=tag_list
270
+ )
271
+ else:
272
+ extracted = [
273
+ agent.extract_from_file(target, EntryType(entry_type), tags=tag_list)
274
+ ]
275
+
276
+ for e in extracted:
277
+ console.print(f" [green]+[/green] {e.title}")
278
+
279
+ if resolve:
280
+ count = agent.resolve_wikilinks()
281
+ console.print(f"[blue]Resolved {count} wikilink edge(s)[/blue]")
282
+
283
+
284
+ # ===========================================================================
285
+ # graph sub-commands
286
+ # ===========================================================================
287
+
288
+ graph_app = typer.Typer(help="Graph inspection and traversal", no_args_is_help=True)
289
+ app.add_typer(graph_app, name="graph")
290
+
291
+
292
+ @graph_app.command("stats")
293
+ def graph_stats() -> None:
294
+ """Print graph statistics."""
295
+ _init()
296
+ _rebuild_graph()
297
+ from core import app_state
298
+
299
+ s = app_state.graph.stats()
300
+ console.print(f"Nodes : [bold]{s['nodes']}[/bold]")
301
+ console.print(f"Edges : [bold]{s['edges']}[/bold]")
302
+ console.print(f"Is DAG: [bold]{s['is_dag']}[/bold]")
303
+
304
+
305
+ @graph_app.command("neighbors")
306
+ def graph_neighbors(
307
+ entry_id: str = typer.Argument(...),
308
+ depth: int = typer.Option(1, "--depth", "-d"),
309
+ ) -> None:
310
+ """Show entries related to *entry_id*."""
311
+ _init()
312
+ _rebuild_graph()
313
+ from core import app_state
314
+ from core.retrieval.retrieval import RetrievalEngine
315
+ from core.storage.database import SessionLocal
316
+
317
+ with SessionLocal() as db:
318
+ engine = RetrievalEngine(db, app_state.graph)
319
+ related = engine.get_related_entries(entry_id, depth=depth)
320
+
321
+ table = Table("Short ID", "Title", "Type")
322
+ for e in related:
323
+ table.add_row(e.id[:8], e.title, e.entry_type.value)
324
+ console.print(table)
325
+
326
+
327
+ @graph_app.command("export")
328
+ def graph_export(
329
+ output: str = typer.Option("data/nodes", "--output", "-o", help="Output directory"),
330
+ ) -> None:
331
+ """Export all entries to YAML files."""
332
+ _init()
333
+ from pathlib import Path as P
334
+
335
+ from core import app_state
336
+ from agents.maintenance_agent.agent import MaintenanceAgent
337
+
338
+ agent = MaintenanceAgent(app_state.graph)
339
+ count = agent.export_to_yaml(P(output))
340
+ console.print(f"[green]Exported {count} entries to {output}[/green]")
341
+
342
+
343
+ # ===========================================================================
344
+ # mem sub-commands
345
+ # ===========================================================================
346
+
347
+ mem_app = typer.Typer(help="Manage Mem-Graph session traces", no_args_is_help=True)
348
+ app.add_typer(mem_app, name="mem")
349
+
350
+
351
+ @mem_app.command("add")
352
+ def mem_add(
353
+ content: str = typer.Argument(...),
354
+ session: str = typer.Option("default", "--session"),
355
+ tags: str = typer.Option("", "--tags"),
356
+ success: bool = typer.Option(None, "--success/--failure"),
357
+ ) -> None:
358
+ """Record a memory trace."""
359
+ from core.memory.memgraph import MemGraph
360
+
361
+ mg = MemGraph(session)
362
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
363
+ entry = mg.add(content, tags=tag_list, success=success)
364
+ console.print(f"[green]Recorded:[/green] {entry.id[:8]}")
365
+
366
+
367
+ @mem_app.command("list")
368
+ def mem_list(
369
+ session: str = typer.Option("default", "--session"),
370
+ ) -> None:
371
+ """List memory traces for a session."""
372
+ from core.memory.memgraph import MemGraph
373
+
374
+ mg = MemGraph(session)
375
+ table = Table("Short ID", "Content", "Tags", "Success", "Promoted")
376
+ for e in mg.list():
377
+ table.add_row(
378
+ e.id[:8],
379
+ e.content[:60] + ("…" if len(e.content) > 60 else ""),
380
+ ", ".join(e.tags),
381
+ str(e.success) if e.success is not None else "—",
382
+ "yes" if e.promoted else "no",
383
+ )
384
+ console.print(table)
385
+
386
+
387
+ @mem_app.command("promote")
388
+ def mem_promote(
389
+ mem_id: str = typer.Argument(..., help="Memory trace ID"),
390
+ session: str = typer.Option("default", "--session"),
391
+ entry_type: str = typer.Option("memory", "--type", "-t"),
392
+ ) -> None:
393
+ """Promote a Mem-Graph trace into a full Know-Do Graph entry."""
394
+ _init()
395
+ from core import app_state
396
+ from core.schemas.entry import EntryType
397
+ from agents.maintenance_agent.agent import MaintenanceAgent
398
+
399
+ agent = MaintenanceAgent(app_state.graph)
400
+ entry = agent.promote_mem_entry(mem_id, session_id=session, entry_type=EntryType(entry_type))
401
+ if entry:
402
+ console.print(f"[green]Promoted to entry:[/green] {entry.title} [dim]({entry.id})[/dim]")
403
+ else:
404
+ console.print(f"[red]Memory trace not found:[/red] {mem_id}")
405
+ raise typer.Exit(1)
406
+
407
+
408
+ # ===========================================================================
409
+ # agent sub-commands
410
+ # ===========================================================================
411
+
412
+ agent_app = typer.Typer(help="LLM-driven graph management agent", no_args_is_help=True)
413
+ app.add_typer(agent_app, name="agent")
414
+
415
+
416
+ @agent_app.command("chat")
417
+ def agent_chat(
418
+ message: str = typer.Argument(None, help="Single message (omit for interactive REPL)"),
419
+ model: str = typer.Option(None, "--model", "-m", help="Override model (e.g. openai/glm-5.1)"),
420
+ session: bool = typer.Option(True, "--session/--no-session", help="Keep conversation history"),
421
+ ) -> None:
422
+ """Chat with the graph agent. Omit MESSAGE to start an interactive session."""
423
+ import os
424
+
425
+ _init()
426
+ _rebuild_graph()
427
+
428
+ if "OPENAI_API_KEY" not in os.environ:
429
+ console.print("[red]OPENAI_API_KEY is not set.[/red] Add it to your .env or environment.")
430
+ raise typer.Exit(1)
431
+
432
+ import json as _json
433
+
434
+ from core import app_state
435
+ from agents.graph_agent.agent import GraphAgent
436
+
437
+ def _step_handler(event: str, data: dict) -> None:
438
+ if event == "thinking":
439
+ console.print(f"[dim]↻ iteration {data['iteration']} — calling model…[/dim]")
440
+ elif event == "tool_call":
441
+ args_preview = _json.dumps(data["args"], default=str, ensure_ascii=False)
442
+ if len(args_preview) > 160:
443
+ args_preview = args_preview[:160] + "…"
444
+ console.print(f" [cyan]→ {data['name']}[/cyan] [dim]{args_preview}[/dim]")
445
+ elif event == "tool_result":
446
+ result_preview = _json.dumps(data["result"], default=str, ensure_ascii=False)
447
+ if len(result_preview) > 240:
448
+ result_preview = result_preview[:240] + "…"
449
+ console.print(f" [yellow]← {data['name']}[/yellow] [dim]{result_preview}[/dim]")
450
+
451
+ graph_agent = GraphAgent(app_state.graph, model=model, on_step=_step_handler)
452
+
453
+ def _run(msg: str) -> None:
454
+ reply = graph_agent.chat(msg)
455
+ console.rule("[dim]Agent[/dim]")
456
+ console.print(reply)
457
+ console.rule()
458
+
459
+ if message:
460
+ _run(message)
461
+ return
462
+
463
+ # Interactive REPL
464
+ from prompt_toolkit import PromptSession as _PS
465
+ _session = _PS()
466
+ console.print("[bold]Know-Do Graph Agent[/bold] (type [dim]exit[/dim] or [dim]quit[/dim] to stop, [dim]reset[/dim] to clear history)")
467
+ while True:
468
+ try:
469
+ user_input = _session.prompt("You: ")
470
+ except (EOFError, KeyboardInterrupt):
471
+ console.print("\n[dim]Goodbye.[/dim]")
472
+ break
473
+ if user_input.strip().lower() in {"exit", "quit"}:
474
+ console.print("[dim]Goodbye.[/dim]")
475
+ break
476
+ if user_input.strip().lower() == "reset":
477
+ graph_agent.reset()
478
+ console.print("[dim]Conversation history cleared.[/dim]")
479
+ continue
480
+ if not user_input.strip():
481
+ continue
482
+ _run(user_input)
483
+
484
+
485
+ @agent_app.command("run")
486
+ def agent_run(
487
+ task: str = typer.Argument(..., help="Natural-language task for the agent to perform"),
488
+ model: str = typer.Option(None, "--model", "-m"),
489
+ ) -> None:
490
+ """Run a one-shot agentic task and print the result."""
491
+ import os
492
+
493
+ _init()
494
+ _rebuild_graph()
495
+
496
+ if "OPENAI_API_KEY" not in os.environ:
497
+ console.print("[red]OPENAI_API_KEY is not set.[/red]")
498
+ raise typer.Exit(1)
499
+
500
+ from core import app_state
501
+ from agents.graph_agent.agent import GraphAgent
502
+
503
+ graph_agent = GraphAgent(app_state.graph, model=model)
504
+ with console.status("[bold green]Agent running…[/bold green]"):
505
+ reply = graph_agent.chat(task)
506
+ console.rule("[dim]Agent result[/dim]")
507
+ console.print(reply)
508
+ console.rule()
509
+
510
+
511
+ # ===========================================================================
512
+ # review sub-commands
513
+ # ===========================================================================
514
+
515
+ review_app = typer.Typer(help="ReviewAgent — audit and clean the graph", no_args_is_help=True)
516
+ app.add_typer(review_app, name="review")
517
+
518
+
519
+ def _make_step_handler():
520
+ import json as _json
521
+
522
+ def _step_handler(event: str, data: dict) -> None:
523
+ if event == "thinking":
524
+ console.print(f"[dim]↻ iteration {data['iteration']}…[/dim]")
525
+ elif event == "orchestrator_thinking":
526
+ console.print(f"[dim bold magenta]◈ orchestrator iteration {data['iteration']}…[/dim bold magenta]")
527
+ elif event == "route":
528
+ console.print(f"[bold magenta]→ routing to:[/bold magenta] [cyan]{data['agent']}[/cyan] [dim]{data.get('args', {})}[/dim]")
529
+ elif event == "tool_call":
530
+ args_preview = _json.dumps(data["args"], default=str, ensure_ascii=False)
531
+ if len(args_preview) > 160:
532
+ args_preview = args_preview[:160] + "…"
533
+ console.print(f" [cyan]→ {data['name']}[/cyan] [dim]{args_preview}[/dim]")
534
+ elif event == "tool_result":
535
+ result_preview = _json.dumps(data["result"], default=str, ensure_ascii=False)
536
+ if len(result_preview) > 240:
537
+ result_preview = result_preview[:240] + "…"
538
+ console.print(f" [yellow]← {data['name']}[/yellow] [dim]{result_preview}[/dim]")
539
+
540
+ return _step_handler
541
+
542
+
543
+ @review_app.command("run")
544
+ def review_run(
545
+ instructions: str = typer.Argument("", help="Optional focus for this review session"),
546
+ batch_size: int = typer.Option(5, "--batch", "-b", help="Nodes to review per session"),
547
+ model: str = typer.Option(None, "--model", "-m"),
548
+ ) -> None:
549
+ """Run one review session (sample nodes, inspect, fix, mark reviewed)."""
550
+ import os
551
+
552
+ _init()
553
+ _rebuild_graph()
554
+
555
+ if "OPENAI_API_KEY" not in os.environ:
556
+ console.print("[red]OPENAI_API_KEY is not set.[/red]")
557
+ raise typer.Exit(1)
558
+
559
+ from core import app_state
560
+ from agents.review_agent.agent import ReviewAgent
561
+
562
+ agent = ReviewAgent(app_state.graph, model=model, batch_size=batch_size, on_step=_make_step_handler())
563
+ console.print(f"[bold]ReviewAgent[/bold] batch={batch_size}" + (f" focus: {instructions}" if instructions else ""))
564
+ reply = agent.run_review(instructions=instructions)
565
+ console.rule("[dim]Review summary[/dim]")
566
+ console.print(reply)
567
+ console.rule()
568
+
569
+
570
+ @review_app.command("chat")
571
+ def review_chat(
572
+ message: str = typer.Argument(None, help="Single message (omit for interactive REPL)"),
573
+ model: str = typer.Option(None, "--model", "-m"),
574
+ ) -> None:
575
+ """Chat interactively with the ReviewAgent."""
576
+ import os
577
+
578
+ _init()
579
+ _rebuild_graph()
580
+
581
+ if "OPENAI_API_KEY" not in os.environ:
582
+ console.print("[red]OPENAI_API_KEY is not set.[/red]")
583
+ raise typer.Exit(1)
584
+
585
+ from core import app_state
586
+ from agents.review_agent.agent import ReviewAgent
587
+
588
+ agent = ReviewAgent(app_state.graph, model=model, on_step=_make_step_handler())
589
+
590
+ def _run(msg: str) -> None:
591
+ reply = agent.chat(msg)
592
+ console.rule("[dim]ReviewAgent[/dim]")
593
+ console.print(reply)
594
+ console.rule()
595
+
596
+ if message:
597
+ _run(message)
598
+ return
599
+
600
+ from prompt_toolkit import PromptSession as _PS
601
+ _session = _PS()
602
+ console.print("[bold]Know-Do Graph ReviewAgent[/bold] (type [dim]exit[/dim] to stop)")
603
+ while True:
604
+ try:
605
+ user_input = _session.prompt("You: ")
606
+ except (EOFError, KeyboardInterrupt):
607
+ console.print("\n[dim]Goodbye.[/dim]")
608
+ break
609
+ if user_input.strip().lower() in {"exit", "quit"}:
610
+ console.print("[dim]Goodbye.[/dim]")
611
+ break
612
+ if not user_input.strip():
613
+ continue
614
+ _run(user_input)
615
+
616
+
617
+ @review_app.command("stats")
618
+ def review_stats() -> None:
619
+ """Show review coverage statistics across all nodes."""
620
+ _init()
621
+ from core.storage.database import SessionLocal
622
+ from core.storage.models import EntryModel
623
+ import json as _json
624
+
625
+ with SessionLocal() as db:
626
+ rows = db.query(EntryModel).all()
627
+
628
+ total = len(rows)
629
+ reviewed = 0
630
+ unreviewed_titles = []
631
+ table = Table("Title", "Type", "Reviews", "Modifications", "Last Reviewed")
632
+ for row in rows:
633
+ meta = _json.loads(row.metadata_json or "{}")
634
+ rc = meta.get("review_count", 0)
635
+ mc = meta.get("modify_count", 0)
636
+ lr = meta.get("last_reviewed_at") or "—"
637
+ if isinstance(lr, str) and len(lr) > 19:
638
+ lr = lr[:19]
639
+ if rc > 0:
640
+ reviewed += 1
641
+ else:
642
+ unreviewed_titles.append(row.title)
643
+ table.add_row(row.title, row.entry_type, str(rc), str(mc), lr)
644
+
645
+ console.print(table)
646
+ console.print(f"\n[bold]Coverage:[/bold] {reviewed}/{total} nodes reviewed "
647
+ f"([green]{100*reviewed//total if total else 0}%[/green])")
648
+
649
+
650
+ # ===========================================================================
651
+ # orchestrate command
652
+ # ===========================================================================
653
+
654
+ orchestrate_app = typer.Typer(help="Orchestrator — routes tasks to the right agent", no_args_is_help=True)
655
+ app.add_typer(orchestrate_app, name="orchestrate")
656
+
657
+
658
+ @orchestrate_app.command("chat")
659
+ def orchestrate_chat(
660
+ message: str = typer.Argument(None, help="Single message (omit for interactive REPL)"),
661
+ model: str = typer.Option(None, "--model", "-m"),
662
+ ) -> None:
663
+ """Route a request through the orchestrator to the appropriate agent(s)."""
664
+ import os
665
+
666
+ _init()
667
+ _rebuild_graph()
668
+
669
+ if "OPENAI_API_KEY" not in os.environ:
670
+ console.print("[red]OPENAI_API_KEY is not set.[/red]")
671
+ raise typer.Exit(1)
672
+
673
+ from core import app_state
674
+ from agents.orchestrator.agent import OrchestratorAgent
675
+
676
+ orchestrator = OrchestratorAgent(app_state.graph, model=model, on_step=_make_step_handler())
677
+
678
+ def _run(msg: str) -> None:
679
+ reply = orchestrator.chat(msg)
680
+ console.rule("[dim]Result[/dim]")
681
+ console.print(reply)
682
+ console.rule()
683
+
684
+ if message:
685
+ _run(message)
686
+ return
687
+
688
+ from prompt_toolkit import PromptSession as _PS
689
+ _session = _PS()
690
+ console.print(
691
+ "[bold]Know-Do Graph Orchestrator[/bold] "
692
+ "(type [dim]exit[/dim] to stop, [dim]reset[/dim] to clear history)"
693
+ )
694
+ while True:
695
+ try:
696
+ user_input = _session.prompt("You: ")
697
+ except (EOFError, KeyboardInterrupt):
698
+ console.print("\n[dim]Goodbye.[/dim]")
699
+ break
700
+ if user_input.strip().lower() in {"exit", "quit"}:
701
+ console.print("[dim]Goodbye.[/dim]")
702
+ break
703
+ if user_input.strip().lower() == "reset":
704
+ orchestrator.reset()
705
+ console.print("[dim]History cleared.[/dim]")
706
+ continue
707
+ if not user_input.strip():
708
+ continue
709
+ _run(user_input)
710
+
711
+
712
+ @orchestrate_app.command("run")
713
+ def orchestrate_run(
714
+ task: str = typer.Argument(..., help="Natural-language task"),
715
+ model: str = typer.Option(None, "--model", "-m"),
716
+ ) -> None:
717
+ """Run a one-shot task through the orchestrator."""
718
+ import os
719
+
720
+ _init()
721
+ _rebuild_graph()
722
+
723
+ if "OPENAI_API_KEY" not in os.environ:
724
+ console.print("[red]OPENAI_API_KEY is not set.[/red]")
725
+ raise typer.Exit(1)
726
+
727
+ from core import app_state
728
+ from agents.orchestrator.agent import OrchestratorAgent
729
+
730
+ orchestrator = OrchestratorAgent(app_state.graph, model=model, on_step=_make_step_handler())
731
+ reply = orchestrator.chat(task)
732
+ console.rule("[dim]Result[/dim]")
733
+ console.print(reply)
734
+ console.rule()
735
+
736
+
737
+ # ===========================================================================
738
+ # embeddings sub-commands
739
+ # ===========================================================================
740
+
741
+ embeddings_app = typer.Typer(help="Manage vector embeddings for hybrid retrieval", no_args_is_help=True)
742
+ app.add_typer(embeddings_app, name="embeddings")
743
+
744
+
745
+ @embeddings_app.command("backfill")
746
+ def embeddings_backfill(
747
+ batch: int = typer.Option(32, "--batch", "-b", help="Embedding batch size"),
748
+ force: bool = typer.Option(False, "--force", help="Re-embed even if hash matches"),
749
+ ) -> None:
750
+ """Compute and store embeddings for every entry that lacks one (or all, with --force)."""
751
+ _init()
752
+ from core.retrieval import vector_store
753
+ from core.retrieval.embedder import build_embedding_text, get_default_embedder, text_hash
754
+ from core.storage.database import SessionLocal
755
+ from core.storage.models import EntryModel
756
+
757
+ embedder = get_default_embedder()
758
+ if not embedder.available:
759
+ console.print(
760
+ "[red]No embedder available.[/red] Install with: "
761
+ "[cyan]pip install 'know-do-graph[embeddings]'[/cyan]"
762
+ )
763
+ raise typer.Exit(1)
764
+
765
+ with SessionLocal() as db:
766
+ rows = db.query(EntryModel).all()
767
+ pending: list[tuple[EntryModel, str, str]] = []
768
+ for row in rows:
769
+ d = row.to_dict()
770
+ text_in = build_embedding_text(
771
+ title=d["title"], aliases=d["aliases"], tags=d["tags"], content=d["content"]
772
+ )
773
+ new_hash = text_hash(text_in)
774
+ if not force and row.embedding_hash == new_hash:
775
+ continue
776
+ pending.append((row, text_in, new_hash))
777
+
778
+ total = len(pending)
779
+ console.print(f"[bold]{total}[/bold] entries to embed (of {len(rows)} total).")
780
+ if total == 0:
781
+ return
782
+
783
+ done = 0
784
+ for i in range(0, total, batch):
785
+ chunk = pending[i:i + batch]
786
+ vecs = embedder.embed([t for _, t, _ in chunk])
787
+ for (row, _t, h), vec in zip(chunk, vecs):
788
+ if not vec:
789
+ continue
790
+ if vector_store.upsert(db, row.id, vec):
791
+ row.embedding_hash = h
792
+ done += 1
793
+ db.commit()
794
+ console.print(f" [dim]…{min(i + len(chunk), total)}/{total}[/dim]")
795
+
796
+ index_count = vector_store.count(db)
797
+ console.print(
798
+ f"[green]Embedded {done} entries.[/green] "
799
+ f"Vector index now holds {index_count if index_count is not None else '?'} rows."
800
+ )
801
+
802
+
803
+ # ===========================================================================
804
+ # db (merge / dedup across multiple SQLite snapshots)
805
+ # ===========================================================================
806
+
807
+ db_app = typer.Typer(help="Cross-environment DB maintenance (merge, dedup)", no_args_is_help=True)
808
+ app.add_typer(db_app, name="db")
809
+
810
+
811
+ @db_app.command("merge")
812
+ def db_merge(
813
+ other: Path = typer.Argument(..., help="Path to the other SQLite DB to merge into this one"),
814
+ prefer: str = typer.Option(
815
+ "newer", "--prefer",
816
+ help="Conflict policy on id collision: newer | local | remote",
817
+ ),
818
+ no_resolve: bool = typer.Option(False, "--no-resolve", help="Skip wikilink re-resolution"),
819
+ notify: bool = typer.Option(
820
+ False, "--notify",
821
+ help="POST /graph/reload to a running server after merge (KDG_API_URL or --api-url)",
822
+ ),
823
+ api_url: str = typer.Option(
824
+ None, "--api-url", help="Base URL of the running API (default: $KDG_API_URL)",
825
+ ),
826
+ ) -> None:
827
+ """Additively merge entries+edges from another know-do-graph SQLite file.
828
+
829
+ UUID ids make union safe; slug collisions are auto-suffixed by the writer.
830
+ Run [bold]python main.py db dedup[/bold] afterwards to consolidate duplicates.
831
+ """
832
+ _init()
833
+ from core.sync.db_merge import merge_database
834
+
835
+ report = merge_database(other, prefer=prefer, resolve_wikilinks=not no_resolve)
836
+
837
+ console.print(f"\n[bold green]Merge complete[/bold green] (from [cyan]{other}[/cyan])")
838
+ console.print(f" entries inserted={report.entries_inserted} updated={report.entries_updated} skipped={report.entries_skipped}")
839
+ console.print(f" edges inserted={report.edges_inserted} skipped={report.edges_skipped}")
840
+ console.print(f" wikilinks_resolved={report.wikilinks_resolved}")
841
+ if report.slug_renames:
842
+ console.print(f" [yellow]slug renames ({len(report.slug_renames)}):[/yellow]")
843
+ for old, new in report.slug_renames[:10]:
844
+ console.print(f" {old} \u2192 {new}")
845
+ if len(report.slug_renames) > 10:
846
+ console.print(f" \u2026 and {len(report.slug_renames) - 10} more")
847
+
848
+ if notify:
849
+ import os, httpx
850
+ url = (api_url or os.environ.get("KDG_API_URL") or "http://127.0.0.1:8000").rstrip("/")
851
+ try:
852
+ r = httpx.post(f"{url}/graph/reload", timeout=10.0)
853
+ r.raise_for_status()
854
+ console.print(f" [dim]reloaded running server at {url}: {r.json()}[/dim]")
855
+ except Exception as exc:
856
+ console.print(f" [red]server reload failed:[/red] {exc}")
857
+
858
+
859
+ @db_app.command("dedup")
860
+ def db_dedup(
861
+ apply: bool = typer.Option(False, "--apply", help="Actually merge (default: dry-run report)"),
862
+ similar: float = typer.Option(
863
+ 0.0, "--similar",
864
+ help="Also list embedding-similar pairs with cosine \u2265 THRESHOLD (e.g. 0.92). "
865
+ "Similar pairs are reported only \u2014 review before merging.",
866
+ ),
867
+ ) -> None:
868
+ """Consolidate exact-duplicate entries; report near-duplicates by embedding similarity."""
869
+ _init()
870
+ from core.sync.db_merge import dedup_exact, find_similar_groups
871
+
872
+ report = dedup_exact(dry_run=not apply)
873
+ mode = "APPLIED" if apply else "DRY-RUN"
874
+ console.print(f"\n[bold]{mode} exact dedup:[/bold] {report.exact_groups} duplicate groups, {len(report.candidates)} pairs")
875
+ for c in report.candidates[:20]:
876
+ marker = "[green]merged[/green]" if apply else "[yellow]would merge[/yellow]"
877
+ console.print(f" {marker} {c['duplicate_slug']} \u2192 {c['primary_slug']} ({c['reason']})")
878
+ if len(report.candidates) > 20:
879
+ console.print(f" \u2026 and {len(report.candidates) - 20} more")
880
+ if apply:
881
+ console.print(f" [bold green]merged_pairs={report.merged_pairs}[/bold green]")
882
+
883
+ if similar > 0:
884
+ sims = find_similar_groups(threshold=similar)
885
+ console.print(f"\n[bold]Near-duplicate candidates[/bold] (cosine \u2265 {similar}): {len(sims)} pairs")
886
+ for s in sims[:30]:
887
+ console.print(f" sim={s['similarity']:.3f} {s['a_id'][:8]}\u2026 \u2194 {s['b_id'][:8]}\u2026")
888
+ if len(sims) > 30:
889
+ console.print(f" \u2026 and {len(sims) - 30} more")
890
+ console.print(
891
+ "[dim]Review and merge with[/dim] "
892
+ "[cyan]python main.py agent run \"merge_entries(primary_id=\u2026, duplicate_id=\u2026)\"[/cyan]"
893
+ )
894
+
895
+
896
+ @db_app.command("reload")
897
+ def db_reload(
898
+ api_url: str = typer.Option(None, "--api-url", help="Default: $KDG_API_URL or http://127.0.0.1:8000"),
899
+ ) -> None:
900
+ """Tell a running API server to rebuild its in-memory graph from the DB."""
901
+ import os, httpx
902
+ url = (api_url or os.environ.get("KDG_API_URL") or "http://127.0.0.1:8000").rstrip("/")
903
+ try:
904
+ r = httpx.post(f"{url}/graph/reload", timeout=10.0)
905
+ r.raise_for_status()
906
+ console.print(f"[green]reloaded:[/green] {r.json()}")
907
+ except Exception as exc:
908
+ console.print(f"[red]reload failed:[/red] {exc}")
909
+ raise typer.Exit(1)
910
+
911
+
912
+ # ===========================================================================
913
+ # serve
914
+ # ===========================================================================
915
+
916
+ @app.command("serve")
917
+ def serve(
918
+ host: str = typer.Option("127.0.0.1", "--host"),
919
+ port: int = typer.Option(8000, "--port"),
920
+ reload: bool = typer.Option(False, "--reload"),
921
+ ) -> None:
922
+ """Start the FastAPI HTTP server."""
923
+ import uvicorn
924
+
925
+ base = f"http://{host}:{port}"
926
+ console.print(f"\n[bold green]Know-Do Graph API[/bold green] → [link={base}]{base}[/link]")
927
+ console.print(f" [cyan]Graph UI [/cyan] → [link={base}/ui]{base}/ui[/link]")
928
+ console.print(f" [cyan]Swagger [/cyan] → [link={base}/docs]{base}/docs[/link]\n")
929
+
930
+ # `timeout_graceful_shutdown` ensures Ctrl+C doesn't hang on long-lived
931
+ # SSE connections (Windows in particular). Combined with the per-second
932
+ # disconnect-check in /graph/events, shutdown completes in <2 s.
933
+ config = uvicorn.Config(
934
+ "api.main:app",
935
+ host=host,
936
+ port=port,
937
+ reload=reload,
938
+ timeout_graceful_shutdown=2,
939
+ )
940
+ uvicorn.Server(config).run()
941
+
942
+
943
+ if __name__ == "__main__":
944
+ app()