agent-kg 0.7.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.
agent_kg/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ # Copyright (c) 2026 Eric G. Suchanek, PhD. All rights reserved.
2
+ # SPDX-License-Identifier: Elastic-2.0
3
+
4
+ """agent_kg — Conversational memory as a live, queryable knowledge graph.
5
+
6
+ AgentKG gives AI agents persistent, semantically searchable memory that
7
+ survives across sessions and defeats context rot via KG Context Pruning.
8
+
9
+ Quick start::
10
+
11
+ from agent_kg import AgentKG
12
+
13
+ kg = AgentKG(repo_path="/path/to/repo", person_id="alice")
14
+ kg.ingest("We decided to use OAuth for authentication.", role="user")
15
+ kg.ingest("Understood. I'll use OAuth.", role="assistant")
16
+
17
+ ctx = kg.assemble_context("authentication strategy", budget=2000)
18
+ print(ctx)
19
+
20
+ kg.close()
21
+
22
+ Architecture:
23
+ - Conversation tree: Turn, Topic, Intent, Entity, Task, Summary nodes
24
+ - UserProfile tree: Preference, Style, Interest, Expertise, Commitment nodes
25
+ - Storage: SQLite (topology) + LanceDB (embeddings)
26
+ - KG Context Pruning: LLM-compresses old turns into Summary nodes
27
+ - MCP server: Exposes all tools to Claude Code and other MCP clients
28
+ """
29
+
30
+ from agent_kg import assemble, consolidate, prune, query, snapshots
31
+ from agent_kg.graph import AgentKG
32
+ from agent_kg.index import ConversationIndex
33
+ from agent_kg.schema import (
34
+ Edge,
35
+ EdgeRelation,
36
+ IntentCategory,
37
+ Node,
38
+ NodeKind,
39
+ PruneReport,
40
+ TaskStatus,
41
+ )
42
+ from agent_kg.session import Session
43
+ from agent_kg.store import AgentKGStore
44
+ from agent_kg.summarize import Summarizer, SummarizerConfig
45
+ from agent_kg.user_profile import UserProfileStore
46
+
47
+ __version__ = "0.7.0"
48
+ __author__ = "Eric G. Suchanek, PhD"
49
+
50
+ __all__ = [
51
+ "AgentKG",
52
+ "AgentKGStore",
53
+ "ConversationIndex",
54
+ "UserProfileStore",
55
+ "Session",
56
+ "Summarizer",
57
+ "SummarizerConfig",
58
+ "Node",
59
+ "Edge",
60
+ "NodeKind",
61
+ "EdgeRelation",
62
+ "TaskStatus",
63
+ "IntentCategory",
64
+ "PruneReport",
65
+ "assemble",
66
+ "consolidate",
67
+ "prune",
68
+ "query",
69
+ "snapshots",
70
+ ]
agent_kg/app.py ADDED
@@ -0,0 +1,543 @@
1
+ # Copyright (c) 2026 Eric G. Suchanek, PhD. All rights reserved.
2
+ # SPDX-License-Identifier: Elastic-2.0
3
+
4
+ """app.py — AgentKG Streamlit Explorer
5
+
6
+ Interactive visualizer for the AgentKG conversation and UserProfile trees.
7
+
8
+ Tabs:
9
+ Agent Graph — pyvis interactive graph of the conversation tree
10
+ Profile Graph — pyvis interactive graph of the UserProfile tree
11
+ Query — hybrid semantic search + context assembly
12
+ Stats — node/edge counts, session list, profile breakdown
13
+
14
+ Run with:
15
+ agentkg viz --serve
16
+ # or directly:
17
+ streamlit run src/agent_kg/app.py -- --repo . --person egs
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import getpass
24
+ import sqlite3
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ import streamlit as st
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Page config (must be first Streamlit call)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ st.set_page_config(
35
+ page_title="AgentKG Explorer",
36
+ page_icon="🧠",
37
+ layout="wide",
38
+ initial_sidebar_state="expanded",
39
+ )
40
+
41
+ st.markdown(
42
+ """
43
+ <style>
44
+ .stTabs [data-baseweb="tab-list"] { gap: 12px; }
45
+ .stTabs [data-baseweb="tab"] { font-size: 1rem; padding: 6px 18px; }
46
+ .hit-card {
47
+ background: #1e1e2e;
48
+ border-left: 4px solid #4A90D9;
49
+ border-radius: 6px;
50
+ padding: 10px 14px;
51
+ margin-bottom: 8px;
52
+ font-family: monospace;
53
+ font-size: 0.85rem;
54
+ }
55
+ </style>
56
+ """,
57
+ unsafe_allow_html=True,
58
+ )
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Kind colours (matches viz.py)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _KIND_COLOR: dict[str, str] = {
65
+ "turn": "#4A90D9",
66
+ "topic": "#27AE60",
67
+ "entity": "#E67E22",
68
+ "intent": "#8E44AD",
69
+ "task": "#E74C3C",
70
+ "summary": "#16A085",
71
+ "preference": "#F39C12",
72
+ "commitment": "#C0392B",
73
+ "expertise": "#2980B9",
74
+ "interest": "#27AE60",
75
+ "style": "#8E44AD",
76
+ "context": "#7F8C8D",
77
+ }
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Argument parsing — Streamlit passes args after "--"
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _parse_args() -> argparse.Namespace:
85
+ p = argparse.ArgumentParser(add_help=False)
86
+ p.add_argument("--repo", default=".")
87
+ p.add_argument("--person", default=getpass.getuser())
88
+ args, _ = p.parse_known_args(sys.argv[1:])
89
+ return args
90
+
91
+
92
+ _args = _parse_args()
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Session-state initialisation
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _init_state() -> None:
100
+ defaults = {
101
+ "query_results": None,
102
+ "query_context": None,
103
+ "last_query": "",
104
+ }
105
+ for k, v in defaults.items():
106
+ if k not in st.session_state:
107
+ st.session_state[k] = v
108
+
109
+
110
+ _init_state()
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Sidebar
114
+ # ---------------------------------------------------------------------------
115
+
116
+ with st.sidebar:
117
+ if Path("docs/logo.png").exists():
118
+ st.image("docs/logo.png", use_container_width=True)
119
+ st.title("AgentKG Explorer")
120
+ st.caption("Conversational memory as a knowledge graph")
121
+ st.divider()
122
+
123
+ repo = st.text_input("Repo root", value=_args.repo)
124
+ person = st.text_input("Person ID", value=_args.person)
125
+
126
+ agent_db = Path(repo) / ".agentkg" / "graph.sqlite"
127
+ profile_db = Path.home() / ".kgrag" / "profiles" / person / "userprofile.sqlite"
128
+
129
+ st.divider()
130
+ st.caption(f"**Agent DB:** `{agent_db}`")
131
+ st.caption(f" exists: {'✓' if agent_db.exists() else '✗'}")
132
+ st.caption(f"**Profile DB:** `{profile_db}`")
133
+ st.caption(f" exists: {'✓' if profile_db.exists() else '✗'}")
134
+
135
+ st.divider()
136
+ st.subheader("Query settings")
137
+ k = st.slider("Top-K results", 1, 30, 8)
138
+ budget = st.slider("Context budget (tokens)", 500, 8000, 4000, step=500)
139
+ include_profile = st.checkbox(
140
+ "Include profile nodes",
141
+ value=False,
142
+ help="Also search UserProfile for preferences, commitments, style",
143
+ )
144
+ kind_filter = st.selectbox(
145
+ "Kind filter",
146
+ ["(all)", "turn", "topic", "entity", "intent", "task", "summary"],
147
+ index=0,
148
+ )
149
+
150
+ st.divider()
151
+ physics = st.toggle("Physics simulation", value=True)
152
+ max_nodes = st.slider("Max nodes (agent graph)", 50, 500, 200, step=50)
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Data loaders
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ @st.cache_data(show_spinner="Loading agent graph…")
161
+ def _load_agent(db_path: str, _max_nodes: int):
162
+ p = Path(db_path)
163
+ if not p.exists():
164
+ return [], [], []
165
+ db = sqlite3.connect(str(p))
166
+ db.row_factory = sqlite3.Row
167
+ nodes = [
168
+ dict(r)
169
+ for r in db.execute(
170
+ "SELECT * FROM nodes ORDER BY created_at DESC LIMIT ?", (_max_nodes,)
171
+ ).fetchall()
172
+ ]
173
+ node_ids = {n["id"] for n in nodes}
174
+ edges = [
175
+ dict(r)
176
+ for r in db.execute("SELECT * FROM edges").fetchall()
177
+ if r["source_id"] in node_ids and r["target_id"] in node_ids
178
+ ]
179
+ sessions = [
180
+ dict(r) for r in db.execute("SELECT * FROM sessions ORDER BY start_time DESC").fetchall()
181
+ ]
182
+ db.close()
183
+ return nodes, edges, sessions
184
+
185
+
186
+ @st.cache_data(show_spinner="Loading user profile…")
187
+ def _load_profile(db_path: str):
188
+ p = Path(db_path)
189
+ if not p.exists():
190
+ return []
191
+ db = sqlite3.connect(str(p))
192
+ db.row_factory = sqlite3.Row
193
+ rows = [
194
+ dict(r)
195
+ for r in db.execute("SELECT * FROM profile_nodes ORDER BY kind, confidence DESC").fetchall()
196
+ ]
197
+ db.close()
198
+ return rows
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # pyvis HTML builders
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def _agent_html(nodes, edges, physics_on: bool) -> str:
207
+ from agent_kg.viz import (
208
+ _KIND_COLOR as VIZ_COLOR,
209
+ )
210
+ from agent_kg.viz import (
211
+ _KIND_SHAPE,
212
+ _REL_COLOR,
213
+ _node_tooltip,
214
+ _require_pyvis,
215
+ )
216
+
217
+ _require_pyvis()
218
+ import tempfile
219
+
220
+ from pyvis.network import Network
221
+
222
+ net = Network(height="650px", width="100%", bgcolor="#0d1117", font_color="#e0e0e0")
223
+ net.set_options(
224
+ '{"physics":{"enabled":' + str(physics_on).lower() + ',"stabilization":{"iterations":150}},'
225
+ '"interaction":{"hover":true,"tooltipDelay":100}}'
226
+ )
227
+ for n in nodes:
228
+ kind = n["kind"]
229
+ label = (n.get("label") or n.get("text") or kind)[:30]
230
+ net.add_node(
231
+ n["id"],
232
+ label=label,
233
+ color=VIZ_COLOR.get(kind, "#888"),
234
+ shape=_KIND_SHAPE.get(kind, "dot"),
235
+ size=20 if kind == "turn" else 10,
236
+ title=_node_tooltip(kind, label, n.get("text") or "", n.get("role") or ""),
237
+ )
238
+ for e in edges:
239
+ net.add_edge(
240
+ e["source_id"],
241
+ e["target_id"],
242
+ title=e["relation"],
243
+ color=_REL_COLOR.get(e["relation"], "#555"),
244
+ arrows="to",
245
+ )
246
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as f:
247
+ tmp = Path(f.name)
248
+ net.save_graph(str(tmp))
249
+ html = tmp.read_text(encoding="utf-8")
250
+ tmp.unlink(missing_ok=True)
251
+ return html
252
+
253
+
254
+ def _profile_html(rows: list[dict]) -> str:
255
+ from itertools import groupby
256
+
257
+ from agent_kg.viz import (
258
+ _KIND_COLOR as VIZ_COLOR,
259
+ )
260
+ from agent_kg.viz import (
261
+ _KIND_SHAPE,
262
+ _node_tooltip,
263
+ _require_pyvis,
264
+ )
265
+
266
+ _require_pyvis()
267
+ import tempfile
268
+
269
+ from pyvis.network import Network
270
+
271
+ net = Network(height="600px", width="100%", bgcolor="#0d1117", font_color="#e0e0e0")
272
+ net.set_options(
273
+ '{"physics":{"enabled":true,"stabilization":{"iterations":100}},'
274
+ '"interaction":{"hover":true,"tooltipDelay":100}}'
275
+ )
276
+ net.add_node(
277
+ "__profile__",
278
+ label="UserProfile",
279
+ color="#ffffff",
280
+ shape="box",
281
+ size=30,
282
+ title="<b>UserProfile root</b>",
283
+ )
284
+ for kind, group in groupby(rows, key=lambda r: r["kind"]):
285
+ color = VIZ_COLOR.get(kind, "#888")
286
+ hub = f"__kind__{kind}"
287
+ net.add_node(hub, label=kind, color=color, shape="box", size=18)
288
+ net.add_edge("__profile__", hub, color="#444", arrows="to")
289
+ for node in group:
290
+ conf = int((node["confidence"] or 1.0) * 100)
291
+ label = (node["label"] or "")[:35]
292
+ net.add_node(
293
+ node["id"],
294
+ label=label,
295
+ color=color,
296
+ shape=_KIND_SHAPE.get(kind, "dot"),
297
+ size=10,
298
+ title=_node_tooltip(kind, label, node.get("text") or "", f"confidence: {conf}%"),
299
+ )
300
+ net.add_edge(hub, node["id"], color=color + "88", arrows="to")
301
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as f:
302
+ tmp = Path(f.name)
303
+ net.save_graph(str(tmp))
304
+ html = tmp.read_text(encoding="utf-8")
305
+ tmp.unlink(missing_ok=True)
306
+ return html
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Query helpers
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ def _kind_badge(kind: str) -> str:
315
+ color = _KIND_COLOR.get(kind, "#888")
316
+ return (
317
+ f'<span style="background:{color};color:#fff;border-radius:4px;'
318
+ f'padding:2px 8px;font-size:11px;font-weight:bold;font-family:monospace;">'
319
+ f"{kind}</span>"
320
+ )
321
+
322
+
323
+ def _render_hit(hit: dict, idx: int) -> None:
324
+ kind = hit.get("kind", "")
325
+ score = hit.get("score", 0.0)
326
+ text = hit.get("text") or hit.get("label") or ""
327
+ label = hit.get("label") or ""
328
+ role = hit.get("role") or hit.get("source") or ""
329
+ source_tag = f" · <i>{role}</i>" if role else ""
330
+ preview = text[:300].replace("<", "&lt;").replace(">", "&gt;")
331
+ if len(text) > 300:
332
+ preview += "…"
333
+ color = _KIND_COLOR.get(kind, "#888")
334
+
335
+ st.markdown(
336
+ f"""
337
+ <div style="background:#1e1e2e;border-left:4px solid {color};
338
+ border-radius:6px;padding:10px 14px;margin-bottom:8px;">
339
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
340
+ <span>{_kind_badge(kind)}{source_tag}</span>
341
+ <span style="font-family:monospace;font-size:12px;color:#888;">
342
+ score: <b style="color:#e0e0e0;">{score:.3f}</b>
343
+ &nbsp;#{idx + 1}
344
+ </span>
345
+ </div>
346
+ {
347
+ '<div style="font-size:12px;color:#aaa;font-family:monospace;margin-bottom:2px;">'
348
+ + label[:80]
349
+ + "</div>"
350
+ if label and label != text[: len(label)]
351
+ else ""
352
+ }
353
+ <div style="font-size:13px;color:#c9d1d9;
354
+ white-space:pre-wrap;word-break:break-word;">{preview}</div>
355
+ </div>
356
+ """,
357
+ unsafe_allow_html=True,
358
+ )
359
+
360
+
361
+ def _run_query(
362
+ db_path: str,
363
+ profile_path: str,
364
+ q: str,
365
+ k: int,
366
+ budget: int,
367
+ include_profile: bool,
368
+ kind_filter: str | None,
369
+ ) -> tuple[list[dict], str]:
370
+ """Execute query and context assembly; return (hits, context_md)."""
371
+ from agent_kg.assemble import assemble_context
372
+ from agent_kg.query import query as kg_query
373
+ from agent_kg.store import AgentKGStore
374
+
375
+ agentkg_dir = Path(db_path).parent
376
+ store = AgentKGStore(agentkg_dir / "graph.sqlite", agentkg_dir / "lancedb")
377
+ hits = kg_query(store, q, k=k, kind_filter=kind_filter)
378
+
379
+ if include_profile:
380
+ from agent_kg.user_profile import UserProfileStore
381
+
382
+ profile = UserProfileStore(Path(profile_path).parent)
383
+ profile_hits = profile.search(q, k=k)
384
+ hits = hits + profile_hits
385
+
386
+ hits.sort(key=lambda h: h.get("score", 0.0), reverse=True)
387
+
388
+ context_md = assemble_context(store, q, budget=budget)
389
+ return hits, context_md
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Main UI — load data
394
+ # ---------------------------------------------------------------------------
395
+
396
+ nodes, edges, sessions = _load_agent(str(agent_db), max_nodes)
397
+ profile_rows = _load_profile(str(profile_db))
398
+
399
+ tab_agent, tab_profile, tab_query, tab_stats = st.tabs(
400
+ ["Agent Graph", "Profile Graph", "Query", "Stats"]
401
+ )
402
+
403
+ # ── Agent Graph ──────────────────────────────────────────────────────────────
404
+ with tab_agent:
405
+ st.subheader(f"Conversation Graph — {repo}")
406
+ if not nodes:
407
+ st.info(f"No agent graph at `{agent_db}`. Run `agentkg ingest` to populate it.")
408
+ else:
409
+ try:
410
+ html = _agent_html(nodes, edges, physics)
411
+ st.components.v1.html(html, height=670, scrolling=False)
412
+ st.caption(f"{len(nodes)} nodes · {len(edges)} edges (capped at {max_nodes})")
413
+ except ImportError as exc:
414
+ st.error(str(exc))
415
+
416
+ # ── Profile Graph ─────────────────────────────────────────────────────────────
417
+ with tab_profile:
418
+ st.subheader(f"UserProfile — {person}")
419
+ if not profile_rows:
420
+ st.info(
421
+ f"No profile at `{profile_db}`. Run `agentkg onboard --person {person}` to build one."
422
+ )
423
+ else:
424
+ try:
425
+ html = _profile_html(profile_rows)
426
+ st.components.v1.html(html, height=620, scrolling=False)
427
+ st.caption(f"{len(profile_rows)} profile nodes")
428
+ except ImportError as exc:
429
+ st.error(str(exc))
430
+
431
+ # ── Query ─────────────────────────────────────────────────────────────────────
432
+ with tab_query:
433
+ st.subheader("Hybrid Semantic Query")
434
+
435
+ if not agent_db.exists():
436
+ st.warning(f"No agent graph at `{agent_db}`. Run `agentkg ingest` first.")
437
+ else:
438
+ q_col, btn_col = st.columns([5, 1])
439
+ with q_col:
440
+ query_text = st.text_input(
441
+ "Query",
442
+ placeholder="e.g. authentication strategy",
443
+ label_visibility="collapsed",
444
+ key="query_input",
445
+ )
446
+ with btn_col:
447
+ run_btn = st.button("▶ Run", type="primary", use_container_width=True)
448
+
449
+ if run_btn and query_text.strip():
450
+ with st.spinner("Searching…"):
451
+ try:
452
+ kf = kind_filter if kind_filter != "(all)" else None
453
+ hits, context_md = _run_query(
454
+ str(agent_db),
455
+ str(profile_db),
456
+ query_text.strip(),
457
+ k=k,
458
+ budget=budget,
459
+ include_profile=include_profile,
460
+ kind_filter=kf,
461
+ )
462
+ st.session_state.query_results = hits
463
+ st.session_state.query_context = context_md
464
+ st.session_state.last_query = query_text.strip()
465
+ except Exception as exc:
466
+ st.error(f"Query failed: {exc}")
467
+
468
+ results = st.session_state.query_results
469
+ context_md = st.session_state.query_context
470
+
471
+ if results is None:
472
+ st.info("Enter a query above and click **▶ Run**.")
473
+ elif not results:
474
+ st.warning("No results found.")
475
+ else:
476
+ last_q = st.session_state.last_query
477
+ res_tab, ctx_tab = st.tabs([f"Results ({len(results)})", "Assembled Context"])
478
+
479
+ with res_tab:
480
+ st.caption(
481
+ f"Query: **{last_q}** · k={k}"
482
+ + (" · +profile" if include_profile else "")
483
+ + (f" · kind={kind_filter}" if kind_filter != "(all)" else "")
484
+ )
485
+ for i, hit in enumerate(results):
486
+ _render_hit(hit, i)
487
+
488
+ with ctx_tab:
489
+ st.caption(f"Token budget: ~{budget} · query: **{last_q}**")
490
+ st.download_button(
491
+ "⬇ Download context",
492
+ data=context_md,
493
+ file_name="agentkg_context.md",
494
+ mime="text/markdown",
495
+ )
496
+ st.markdown(context_md)
497
+
498
+ # ── Stats ─────────────────────────────────────────────────────────────────────
499
+ with tab_stats:
500
+ col1, col2 = st.columns(2)
501
+
502
+ with col1:
503
+ st.subheader("Agent Graph")
504
+ if nodes:
505
+ from collections import Counter
506
+
507
+ kind_counts = Counter(n["kind"] for n in nodes)
508
+ st.dataframe(
509
+ [{"kind": k, "count": v} for k, v in sorted(kind_counts.items())],
510
+ use_container_width=True,
511
+ hide_index=True,
512
+ )
513
+ st.caption(f"**Sessions:** {len(sessions)}")
514
+ if sessions:
515
+ st.dataframe(
516
+ [
517
+ {
518
+ "id": s["id"][:8],
519
+ "start": (s["start_time"] or "")[:16],
520
+ "turns": s["turn_count"],
521
+ "prune passes": s["pruning_passes"],
522
+ }
523
+ for s in sessions
524
+ ],
525
+ use_container_width=True,
526
+ hide_index=True,
527
+ )
528
+ else:
529
+ st.info("No agent graph loaded.")
530
+
531
+ with col2:
532
+ st.subheader("UserProfile")
533
+ if profile_rows:
534
+ from collections import Counter
535
+
536
+ kind_counts = Counter(r["kind"] for r in profile_rows)
537
+ st.dataframe(
538
+ [{"kind": k, "count": v} for k, v in sorted(kind_counts.items())],
539
+ use_container_width=True,
540
+ hide_index=True,
541
+ )
542
+ else:
543
+ st.info("No profile loaded.")