okb 1.1.0a0__py3-none-any.whl → 1.1.2__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.
okb/mcp_server.py CHANGED
@@ -4,14 +4,14 @@ MCP Server for Knowledge Base.
4
4
  Exposes semantic search to Claude Code via the Model Context Protocol.
5
5
 
6
6
  Usage:
7
- python mcp_server.py
7
+ okb serve
8
8
 
9
- Configure in Claude Code (~/.claude.json or similar):
9
+ Configure in Claude Code (see https://docs.anthropic.com/en/docs/claude-code):
10
10
  {
11
11
  "mcpServers": {
12
12
  "knowledge-base": {
13
- "command": "python",
14
- "args": ["/path/to/mcp_server.py"]
13
+ "command": "okb",
14
+ "args": ["serve"]
15
15
  }
16
16
  }
17
17
  }
@@ -313,6 +313,69 @@ class KnowledgeBase:
313
313
  """).fetchall()
314
314
  return [r["project"] for r in results]
315
315
 
316
+ def get_project_stats(self) -> list[dict]:
317
+ """Get projects with document counts for consolidation review."""
318
+ conn = self.get_connection()
319
+ results = conn.execute("""
320
+ SELECT
321
+ metadata->>'project' as project,
322
+ COUNT(*) as doc_count,
323
+ array_agg(DISTINCT source_type) as source_types
324
+ FROM documents
325
+ WHERE metadata->>'project' IS NOT NULL
326
+ GROUP BY metadata->>'project'
327
+ ORDER BY doc_count DESC, project
328
+ """).fetchall()
329
+ return [dict(r) for r in results]
330
+
331
+ def list_documents_by_project(self, project: str, limit: int = 100) -> list[dict]:
332
+ """List documents for a specific project."""
333
+ conn = self.get_connection()
334
+ rows = conn.execute(
335
+ """SELECT source_path, title, source_type FROM documents
336
+ WHERE metadata->>'project' = %s ORDER BY title LIMIT %s""",
337
+ (project, limit),
338
+ ).fetchall()
339
+ return [dict(r) for r in rows]
340
+
341
+ def rename_project(self, old_name: str, new_name: str) -> int:
342
+ """Rename a project (update all documents). Returns count of updated docs."""
343
+ conn = self.get_connection()
344
+ result = conn.execute(
345
+ """
346
+ UPDATE documents
347
+ SET metadata = jsonb_set(metadata, '{project}', %s::jsonb)
348
+ WHERE metadata->>'project' = %s
349
+ """,
350
+ (f'"{new_name}"', old_name),
351
+ )
352
+ conn.commit()
353
+ return result.rowcount
354
+
355
+ def set_document_project(self, source_path: str, project: str | None) -> bool:
356
+ """Set or clear the project for a single document."""
357
+ conn = self.get_connection()
358
+ if project:
359
+ result = conn.execute(
360
+ """
361
+ UPDATE documents
362
+ SET metadata = jsonb_set(metadata, '{project}', %s::jsonb)
363
+ WHERE source_path = %s
364
+ """,
365
+ (f'"{project}"', source_path),
366
+ )
367
+ else:
368
+ result = conn.execute(
369
+ """
370
+ UPDATE documents
371
+ SET metadata = metadata - 'project'
372
+ WHERE source_path = %s
373
+ """,
374
+ (source_path,),
375
+ )
376
+ conn.commit()
377
+ return result.rowcount > 0
378
+
316
379
  def get_document(self, source_path: str) -> dict | None:
317
380
  """Get full document content by path."""
318
381
  conn = self.get_connection()
@@ -435,10 +498,7 @@ class KnowledgeBase:
435
498
  }
436
499
 
437
500
  def delete_knowledge(self, source_path: str) -> bool:
438
- """Delete a Claude-saved knowledge entry by source path."""
439
- if not source_path.startswith("claude://"):
440
- return False
441
-
501
+ """Delete a document by source path."""
442
502
  conn = self.get_connection()
443
503
  result = conn.execute(
444
504
  "DELETE FROM documents WHERE source_path = %s RETURNING id",
@@ -840,6 +900,407 @@ def _run_rescan(
840
900
  return "\n".join(lines) if lines else "No indexed files found."
841
901
 
842
902
 
903
+ def _list_sync_sources(db_url: str, db_name: str) -> str:
904
+ """List available sync sources with status and last sync time."""
905
+ import psycopg
906
+ from psycopg.rows import dict_row
907
+
908
+ from .plugins.registry import PluginRegistry
909
+
910
+ installed = PluginRegistry.list_sources()
911
+ enabled = set(config.list_enabled_sources())
912
+
913
+ if not installed:
914
+ return "No API sync sources installed."
915
+
916
+ # Get last sync times from database
917
+ last_syncs = {}
918
+ try:
919
+ with psycopg.connect(db_url, row_factory=dict_row) as conn:
920
+ results = conn.execute(
921
+ """SELECT source_name, last_sync FROM sync_state WHERE database_name = %s""",
922
+ (db_name,),
923
+ ).fetchall()
924
+ last_syncs = {r["source_name"]: r["last_sync"] for r in results}
925
+ except Exception:
926
+ pass # Database may not be accessible
927
+
928
+ lines = ["## API Sync Sources\n"]
929
+
930
+ for name in sorted(installed):
931
+ source = PluginRegistry.get_source(name)
932
+ status = "enabled" if name in enabled else "disabled"
933
+ source_type = source.source_type if source else "unknown"
934
+
935
+ last_sync = last_syncs.get(name)
936
+ if last_sync:
937
+ last_sync_str = format_relative_time(last_sync.isoformat())
938
+ else:
939
+ last_sync_str = "never"
940
+
941
+ lines.append(f"- **{name}** ({status}) - {source_type}")
942
+ lines.append(f" Last sync: {last_sync_str}")
943
+
944
+ return "\n".join(lines)
945
+
946
+
947
+ def _enrich_document(
948
+ db_url: str,
949
+ source_path: str,
950
+ extract_todos: bool = True,
951
+ extract_entities: bool = True,
952
+ auto_create_entities: bool = False,
953
+ use_modal: bool = True,
954
+ ) -> str:
955
+ """Run enrichment on a specific document."""
956
+ from psycopg.rows import dict_row
957
+
958
+ from .llm import get_llm
959
+ from .llm.enrich import EnrichmentConfig, process_enrichment
960
+
961
+ # Check LLM is configured
962
+ if get_llm() is None:
963
+ return (
964
+ "Error: No LLM provider configured. "
965
+ "Enrichment requires an LLM. Set ANTHROPIC_API_KEY or configure llm.provider in config."
966
+ )
967
+
968
+ with psycopg.connect(db_url, row_factory=dict_row) as conn:
969
+ doc = conn.execute(
970
+ "SELECT id, source_path, title, content, source_type, metadata FROM documents WHERE source_path = %s",
971
+ (source_path,),
972
+ ).fetchone()
973
+
974
+ if not doc:
975
+ return f"Document not found: {source_path}"
976
+
977
+ enrich_config = EnrichmentConfig(
978
+ extract_todos=extract_todos,
979
+ extract_entities=extract_entities,
980
+ auto_create_todos=True,
981
+ auto_create_entities=auto_create_entities,
982
+ )
983
+
984
+ project = doc["metadata"].get("project") if doc["metadata"] else None
985
+
986
+ stats = process_enrichment(
987
+ document_id=str(doc["id"]),
988
+ source_path=doc["source_path"],
989
+ title=doc["title"],
990
+ content=doc["content"],
991
+ source_type=doc["source_type"],
992
+ db_url=db_url,
993
+ config=enrich_config,
994
+ project=project,
995
+ use_modal=use_modal,
996
+ )
997
+
998
+ lines = [f"Enriched: {source_path}"]
999
+ if stats["todos_created"]:
1000
+ lines.append(f" TODOs created: {stats['todos_created']}")
1001
+ if stats["entities_pending"]:
1002
+ lines.append(f" Entities pending review: {stats['entities_pending']}")
1003
+ if stats["entities_created"]:
1004
+ lines.append(f" Entities created: {stats['entities_created']}")
1005
+ if not any(stats.values()):
1006
+ lines.append(" No TODOs or entities extracted")
1007
+
1008
+ return "\n".join(lines)
1009
+
1010
+
1011
+ def _list_pending_entities(
1012
+ db_url: str,
1013
+ entity_type: str | None = None,
1014
+ limit: int = 20,
1015
+ ) -> str:
1016
+ """List pending entity suggestions."""
1017
+ from .llm.enrich import list_pending_entities
1018
+
1019
+ entities = list_pending_entities(db_url, entity_type=entity_type, limit=limit)
1020
+
1021
+ if not entities:
1022
+ return "No pending entity suggestions."
1023
+
1024
+ lines = ["## Pending Entities\n"]
1025
+ for e in entities:
1026
+ confidence = e.get("confidence", 0)
1027
+ confidence_str = f" ({confidence:.0%})" if confidence else ""
1028
+ lines.append(f"- **{e['entity_name']}** ({e['entity_type']}){confidence_str}")
1029
+ lines.append(f" ID: `{e['id']}`")
1030
+ if e.get("description"):
1031
+ lines.append(f" {e['description']}")
1032
+ if e.get("aliases"):
1033
+ lines.append(f" Aliases: {', '.join(e['aliases'])}")
1034
+ lines.append(f" Source: {e['source_title']}")
1035
+ lines.append("")
1036
+
1037
+ lines.append(f"\nUse `approve_entity` or `reject_entity` with the entity ID.")
1038
+ return "\n".join(lines)
1039
+
1040
+
1041
+ def _approve_entity(
1042
+ db_url: str, pending_id: str, use_modal: bool = True, run_async: bool = False
1043
+ ) -> str:
1044
+ """Approve a pending entity.
1045
+
1046
+ Args:
1047
+ db_url: Database URL
1048
+ pending_id: ID of the pending entity
1049
+ use_modal: If True, use Modal GPU for embedding; else local CPU
1050
+ run_async: If True, return immediately while embedding happens in background
1051
+ """
1052
+ from .llm.enrich import approve_entity, approve_entity_async
1053
+
1054
+ if run_async:
1055
+ future = approve_entity_async(db_url, pending_id, use_modal)
1056
+ # Return immediately - don't wait for completion
1057
+ return (
1058
+ f"Entity approval queued (ID: {pending_id}). "
1059
+ "Embedding is being generated in the background. "
1060
+ "The entity will be searchable once complete."
1061
+ )
1062
+
1063
+ source_path = approve_entity(db_url, pending_id, use_modal)
1064
+ if source_path:
1065
+ return f"Entity approved and created: `{source_path}`"
1066
+ return "Failed to approve entity. ID may be invalid or already processed."
1067
+
1068
+
1069
+ def _reject_entity(db_url: str, pending_id: str) -> str:
1070
+ """Reject a pending entity."""
1071
+ from .llm.enrich import reject_entity
1072
+
1073
+ if reject_entity(db_url, pending_id):
1074
+ return "Entity rejected."
1075
+ return "Failed to reject entity. ID may be invalid or already processed."
1076
+
1077
+
1078
+ def _analyze_knowledge_base(
1079
+ db_url: str,
1080
+ project: str | None = None,
1081
+ sample_size: int = 15,
1082
+ auto_update: bool = True,
1083
+ ) -> str:
1084
+ """Analyze the knowledge base and return formatted result."""
1085
+ from .llm import get_llm
1086
+ from .llm.analyze import analyze_database, format_analysis_result
1087
+
1088
+ # Check LLM is configured
1089
+ if get_llm() is None:
1090
+ return (
1091
+ "Error: No LLM provider configured. "
1092
+ "Analysis requires an LLM. Set ANTHROPIC_API_KEY or configure llm.provider in config."
1093
+ )
1094
+
1095
+ try:
1096
+ result = analyze_database(
1097
+ db_url=db_url,
1098
+ project=project,
1099
+ sample_size=sample_size,
1100
+ auto_update=auto_update,
1101
+ )
1102
+ return format_analysis_result(result)
1103
+ except Exception as e:
1104
+ return f"Error analyzing knowledge base: {e}"
1105
+
1106
+
1107
+ def _find_entity_duplicates(
1108
+ db_url: str,
1109
+ similarity_threshold: float = 0.85,
1110
+ entity_type: str | None = None,
1111
+ use_llm: bool = True,
1112
+ ) -> str:
1113
+ """Find duplicate entities and return formatted result."""
1114
+ from .llm.extractors.dedup import create_pending_merge, find_duplicate_entities
1115
+
1116
+ pairs = find_duplicate_entities(
1117
+ db_url,
1118
+ similarity_threshold=similarity_threshold,
1119
+ use_llm=use_llm,
1120
+ entity_type=entity_type,
1121
+ )
1122
+
1123
+ if not pairs:
1124
+ return "No potential duplicate entities found."
1125
+
1126
+ lines = ["## Potential Duplicate Entities\n"]
1127
+ for p in pairs:
1128
+ lines.append(f"- **{p.canonical_name}** ↔ **{p.duplicate_name}**")
1129
+ lines.append(f" Confidence: {p.confidence:.0%} ({p.reason})")
1130
+ lines.append(f" Types: {p.canonical_type} / {p.duplicate_type}")
1131
+
1132
+ # Create pending merge
1133
+ merge_id = create_pending_merge(db_url, p)
1134
+ if merge_id:
1135
+ lines.append(f" Pending merge ID: `{merge_id}`")
1136
+ lines.append("")
1137
+
1138
+ lines.append(f"\nFound {len(pairs)} potential duplicates.")
1139
+ lines.append("Use `approve_merge` or `reject_merge` with merge IDs to process.")
1140
+ return "\n".join(lines)
1141
+
1142
+
1143
+ def _merge_entities(db_url: str, canonical_path: str, duplicate_path: str) -> str:
1144
+ """Merge two entities and return result."""
1145
+ from psycopg.rows import dict_row
1146
+
1147
+ from .llm.extractors.dedup import execute_merge
1148
+
1149
+ with psycopg.connect(db_url, row_factory=dict_row) as conn:
1150
+ # Get entity IDs from paths
1151
+ canonical = conn.execute(
1152
+ "SELECT id, title FROM documents WHERE source_path = %s AND source_type = 'entity'",
1153
+ (canonical_path,),
1154
+ ).fetchone()
1155
+ duplicate = conn.execute(
1156
+ "SELECT id, title FROM documents WHERE source_path = %s AND source_type = 'entity'",
1157
+ (duplicate_path,),
1158
+ ).fetchone()
1159
+
1160
+ if not canonical:
1161
+ return f"Error: Canonical entity not found: {canonical_path}"
1162
+ if not duplicate:
1163
+ return f"Error: Duplicate entity not found: {duplicate_path}"
1164
+
1165
+ if execute_merge(db_url, str(canonical["id"]), str(duplicate["id"])):
1166
+ return (
1167
+ f"Merge successful:\n"
1168
+ f"- Kept: {canonical['title']} ({canonical_path})\n"
1169
+ f"- Merged: {duplicate['title']} (deleted, added as alias)"
1170
+ )
1171
+ return "Error: Merge failed."
1172
+
1173
+
1174
+ def _list_pending_merges(db_url: str, limit: int = 50) -> str:
1175
+ """List pending entity merges."""
1176
+ from .llm.extractors.dedup import list_pending_merges
1177
+
1178
+ merges = list_pending_merges(db_url, limit=limit)
1179
+
1180
+ if not merges:
1181
+ return "No pending entity merges."
1182
+
1183
+ lines = ["## Pending Entity Merges\n"]
1184
+ for m in merges:
1185
+ lines.append(f"- **{m['canonical_name']}** ← {m['duplicate_name']}")
1186
+ lines.append(f" ID: `{m['id']}`")
1187
+ lines.append(f" Confidence: {m['confidence']:.0%} ({m['reason']})")
1188
+ lines.append("")
1189
+
1190
+ lines.append(f"\n{len(merges)} pending merges.")
1191
+ lines.append("Use `approve_merge` or `reject_merge` with IDs to process.")
1192
+ return "\n".join(lines)
1193
+
1194
+
1195
+ def _approve_merge(db_url: str, merge_id: str) -> str:
1196
+ """Approve and execute a pending merge."""
1197
+ from .llm.extractors.dedup import approve_merge
1198
+
1199
+ if approve_merge(db_url, merge_id):
1200
+ return "Merge approved and executed."
1201
+ return "Error: Failed to approve merge. ID may be invalid or already processed."
1202
+
1203
+
1204
+ def _reject_merge(db_url: str, merge_id: str) -> str:
1205
+ """Reject a pending merge."""
1206
+ from .llm.extractors.dedup import reject_merge
1207
+
1208
+ if reject_merge(db_url, merge_id):
1209
+ return "Merge rejected."
1210
+ return "Error: Failed to reject merge. ID may be invalid or already processed."
1211
+
1212
+
1213
+ def _get_topic_clusters(db_url: str, limit: int = 20) -> str:
1214
+ """Get topic clusters."""
1215
+ from .llm.consolidate import get_topic_clusters
1216
+
1217
+ clusters = get_topic_clusters(db_url, limit=limit)
1218
+
1219
+ if not clusters:
1220
+ return "No topic clusters found. Run `run_consolidation` to create clusters."
1221
+
1222
+ lines = ["## Topic Clusters\n"]
1223
+ for c in clusters:
1224
+ lines.append(f"### {c['name']}")
1225
+ if c.get("description"):
1226
+ lines.append(c["description"])
1227
+ lines.append(f"Members: {c['member_count']}")
1228
+
1229
+ # Show top members
1230
+ entities = [m for m in c.get("members", []) if m.get("is_entity")]
1231
+ if entities:
1232
+ entity_names = [m["title"] for m in entities[:5]]
1233
+ lines.append(f"Entities: {', '.join(entity_names)}")
1234
+ lines.append("")
1235
+
1236
+ return "\n".join(lines)
1237
+
1238
+
1239
+ def _get_entity_relationships(
1240
+ db_url: str,
1241
+ entity_name: str | None = None,
1242
+ relationship_type: str | None = None,
1243
+ limit: int = 50,
1244
+ ) -> str:
1245
+ """Get entity relationships."""
1246
+ from .llm.consolidate import get_entity_relationships
1247
+
1248
+ relationships = get_entity_relationships(
1249
+ db_url, entity_name=entity_name, relationship_type=relationship_type, limit=limit
1250
+ )
1251
+
1252
+ if not relationships:
1253
+ if entity_name:
1254
+ return f"No relationships found involving '{entity_name}'."
1255
+ return "No entity relationships found. Run `run_consolidation` to extract relationships."
1256
+
1257
+ lines = ["## Entity Relationships\n"]
1258
+ for r in relationships:
1259
+ source = r["source"]["name"]
1260
+ target = r["target"]["name"]
1261
+ rel_type = r["type"]
1262
+ confidence = r.get("confidence", 0)
1263
+
1264
+ lines.append(f"- **{source}** → *{rel_type}* → **{target}** ({confidence:.0%})")
1265
+ if r.get("context"):
1266
+ lines.append(f" {r['context']}")
1267
+
1268
+ return "\n".join(lines)
1269
+
1270
+
1271
+ def _run_consolidation(
1272
+ db_url: str,
1273
+ detect_duplicates: bool = True,
1274
+ detect_cross_doc: bool = True,
1275
+ build_clusters: bool = True,
1276
+ extract_relationships: bool = True,
1277
+ dry_run: bool = False,
1278
+ ) -> str:
1279
+ """Run consolidation pipeline."""
1280
+ from .llm import get_llm
1281
+ from .llm.consolidate import format_consolidation_result, run_consolidation
1282
+
1283
+ # Check LLM is configured (needed for several phases)
1284
+ if get_llm() is None and (detect_cross_doc or build_clusters or extract_relationships):
1285
+ return (
1286
+ "Error: No LLM provider configured. "
1287
+ "Consolidation requires an LLM for cross-doc detection, clustering, and relationships. "
1288
+ "Set ANTHROPIC_API_KEY or configure llm.provider in config."
1289
+ )
1290
+
1291
+ result = run_consolidation(
1292
+ db_url,
1293
+ detect_duplicates=detect_duplicates,
1294
+ detect_cross_doc=detect_cross_doc,
1295
+ build_clusters=build_clusters,
1296
+ extract_relationships=extract_relationships,
1297
+ auto_merge_threshold=config.consolidation_auto_merge_threshold,
1298
+ dry_run=dry_run,
1299
+ )
1300
+
1301
+ return format_consolidation_result(result)
1302
+
1303
+
843
1304
  def build_server_instructions(db_config) -> str | None:
844
1305
  """Build server instructions from database config and LLM metadata."""
845
1306
  parts = []
@@ -992,6 +1453,78 @@ async def list_tools() -> list[Tool]:
992
1453
  "properties": {},
993
1454
  },
994
1455
  ),
1456
+ Tool(
1457
+ name="get_project_stats",
1458
+ description=(
1459
+ "Get projects with document counts. Use this to identify projects that should "
1460
+ "be consolidated (similar names, typos, etc.)."
1461
+ ),
1462
+ inputSchema={
1463
+ "type": "object",
1464
+ "properties": {},
1465
+ },
1466
+ ),
1467
+ Tool(
1468
+ name="list_documents_by_project",
1469
+ description="List all documents belonging to a specific project.",
1470
+ inputSchema={
1471
+ "type": "object",
1472
+ "properties": {
1473
+ "project": {
1474
+ "type": "string",
1475
+ "description": "Project name to list documents for",
1476
+ },
1477
+ "limit": {
1478
+ "type": "integer",
1479
+ "description": "Maximum documents to return (default: 100)",
1480
+ "default": 100,
1481
+ },
1482
+ },
1483
+ "required": ["project"],
1484
+ },
1485
+ ),
1486
+ Tool(
1487
+ name="rename_project",
1488
+ description=(
1489
+ "Rename a project, updating all documents. Use for consolidating similar "
1490
+ "project names (e.g., 'my-app' and 'MyApp' -> 'my-app'). Requires write permission."
1491
+ ),
1492
+ inputSchema={
1493
+ "type": "object",
1494
+ "properties": {
1495
+ "old_name": {
1496
+ "type": "string",
1497
+ "description": "Current project name to rename",
1498
+ },
1499
+ "new_name": {
1500
+ "type": "string",
1501
+ "description": "New project name",
1502
+ },
1503
+ },
1504
+ "required": ["old_name", "new_name"],
1505
+ },
1506
+ ),
1507
+ Tool(
1508
+ name="set_document_project",
1509
+ description=(
1510
+ "Set or clear the project for a single document. Use to fix incorrectly "
1511
+ "categorized documents. Requires write permission."
1512
+ ),
1513
+ inputSchema={
1514
+ "type": "object",
1515
+ "properties": {
1516
+ "source_path": {
1517
+ "type": "string",
1518
+ "description": "Path of the document to update",
1519
+ },
1520
+ "project": {
1521
+ "type": "string",
1522
+ "description": "New project name (omit or null to clear project)",
1523
+ },
1524
+ },
1525
+ "required": ["source_path"],
1526
+ },
1527
+ ),
995
1528
  Tool(
996
1529
  name="recent_documents",
997
1530
  description="Get recently indexed or updated documents.",
@@ -1041,8 +1574,8 @@ async def list_tools() -> list[Tool]:
1041
1574
  Tool(
1042
1575
  name="delete_knowledge",
1043
1576
  description=(
1044
- "Delete a previously saved knowledge entry by its source path. "
1045
- "Only works for Claude-saved entries (claude:// paths)."
1577
+ "Delete a document from the knowledge base by its source path. "
1578
+ "Works for any document type. Requires write permission."
1046
1579
  ),
1047
1580
  inputSchema={
1048
1581
  "type": "object",
@@ -1250,6 +1783,335 @@ async def list_tools() -> list[Tool]:
1250
1783
  },
1251
1784
  },
1252
1785
  ),
1786
+ Tool(
1787
+ name="list_sync_sources",
1788
+ description=(
1789
+ "List available API sync sources (Todoist, GitHub, Dropbox Paper, etc.) "
1790
+ "with their enabled/disabled status and last sync time. "
1791
+ "Use this to see what external data sources can be synced."
1792
+ ),
1793
+ inputSchema={
1794
+ "type": "object",
1795
+ "properties": {},
1796
+ },
1797
+ ),
1798
+ Tool(
1799
+ name="enrich_document",
1800
+ description=(
1801
+ "Run LLM enrichment on a document to extract TODOs and entities. "
1802
+ "TODOs are created as separate documents, entities go to pending review. "
1803
+ "Requires write permission."
1804
+ ),
1805
+ inputSchema={
1806
+ "type": "object",
1807
+ "properties": {
1808
+ "source_path": {
1809
+ "type": "string",
1810
+ "description": "Path of the document to enrich",
1811
+ },
1812
+ "extract_todos": {
1813
+ "type": "boolean",
1814
+ "default": True,
1815
+ "description": "Whether to extract TODOs",
1816
+ },
1817
+ "extract_entities": {
1818
+ "type": "boolean",
1819
+ "default": True,
1820
+ "description": "Whether to extract entities",
1821
+ },
1822
+ "auto_create_entities": {
1823
+ "type": "boolean",
1824
+ "default": False,
1825
+ "description": "Auto-create entities instead of pending review",
1826
+ },
1827
+ "use_local": {
1828
+ "type": "boolean",
1829
+ "default": False,
1830
+ "description": "Use local CPU embedding instead of Modal GPU",
1831
+ },
1832
+ },
1833
+ "required": ["source_path"],
1834
+ },
1835
+ ),
1836
+ Tool(
1837
+ name="list_pending_entities",
1838
+ description=(
1839
+ "List entity suggestions awaiting review. "
1840
+ "Entities are extracted from documents but need approval before becoming searchable. "
1841
+ "Use approve_entity or reject_entity to process them."
1842
+ ),
1843
+ inputSchema={
1844
+ "type": "object",
1845
+ "properties": {
1846
+ "entity_type": {
1847
+ "type": "string",
1848
+ "enum": ["person", "project", "technology", "concept", "organization"],
1849
+ "description": "Filter by entity type (optional)",
1850
+ },
1851
+ "limit": {
1852
+ "type": "integer",
1853
+ "default": 20,
1854
+ "description": "Maximum results",
1855
+ },
1856
+ },
1857
+ },
1858
+ ),
1859
+ Tool(
1860
+ name="approve_entity",
1861
+ description=(
1862
+ "Approve a pending entity, creating it as a searchable document. "
1863
+ "The entity will be linked to its source document(s). "
1864
+ "Use async=true to return immediately while embedding runs in background. "
1865
+ "Requires write permission."
1866
+ ),
1867
+ inputSchema={
1868
+ "type": "object",
1869
+ "properties": {
1870
+ "pending_id": {
1871
+ "type": "string",
1872
+ "description": "ID of the pending entity to approve",
1873
+ },
1874
+ "async": {
1875
+ "type": "boolean",
1876
+ "default": False,
1877
+ "description": (
1878
+ "If true, return immediately while embedding runs in background. "
1879
+ "Useful to avoid blocking when approving multiple entities."
1880
+ ),
1881
+ },
1882
+ "use_local": {
1883
+ "type": "boolean",
1884
+ "default": False,
1885
+ "description": "Use local CPU embedding instead of Modal GPU",
1886
+ },
1887
+ },
1888
+ "required": ["pending_id"],
1889
+ },
1890
+ ),
1891
+ Tool(
1892
+ name="reject_entity",
1893
+ description=(
1894
+ "Reject a pending entity suggestion. "
1895
+ "The entity will be marked as rejected and not shown again. "
1896
+ "Requires write permission."
1897
+ ),
1898
+ inputSchema={
1899
+ "type": "object",
1900
+ "properties": {
1901
+ "pending_id": {
1902
+ "type": "string",
1903
+ "description": "ID of the pending entity to reject",
1904
+ },
1905
+ },
1906
+ "required": ["pending_id"],
1907
+ },
1908
+ ),
1909
+ Tool(
1910
+ name="analyze_knowledge_base",
1911
+ description=(
1912
+ "Analyze the knowledge base to generate or update its description and topics. "
1913
+ "Uses entity data and document samples to understand themes and content. "
1914
+ "Results are stored in database_metadata for future sessions. "
1915
+ "Requires write permission."
1916
+ ),
1917
+ inputSchema={
1918
+ "type": "object",
1919
+ "properties": {
1920
+ "project": {
1921
+ "type": "string",
1922
+ "description": "Analyze only a specific project (optional)",
1923
+ },
1924
+ "sample_size": {
1925
+ "type": "integer",
1926
+ "description": "Number of documents to sample (default: 15)",
1927
+ "default": 15,
1928
+ },
1929
+ "auto_update": {
1930
+ "type": "boolean",
1931
+ "description": "Update database metadata with results (default: true)",
1932
+ "default": True,
1933
+ },
1934
+ },
1935
+ },
1936
+ ),
1937
+ Tool(
1938
+ name="find_entity_duplicates",
1939
+ description=(
1940
+ "Scan for potential duplicate entities using embedding similarity and LLM. "
1941
+ "Returns pairs of entities that may refer to the same thing. "
1942
+ "Use merge_entities or list_pending_merges to act on results."
1943
+ ),
1944
+ inputSchema={
1945
+ "type": "object",
1946
+ "properties": {
1947
+ "similarity_threshold": {
1948
+ "type": "number",
1949
+ "description": "Minimum similarity to consider duplicates (default: 0.85)",
1950
+ "default": 0.85,
1951
+ },
1952
+ "entity_type": {
1953
+ "type": "string",
1954
+ "enum": ["person", "project", "technology", "concept", "organization"],
1955
+ "description": "Filter to specific entity type (optional)",
1956
+ },
1957
+ "use_llm": {
1958
+ "type": "boolean",
1959
+ "description": "Use LLM for batch duplicate detection (default: true)",
1960
+ "default": True,
1961
+ },
1962
+ },
1963
+ },
1964
+ ),
1965
+ Tool(
1966
+ name="merge_entities",
1967
+ description=(
1968
+ "Merge two entities: redirect refs from duplicate to canonical, "
1969
+ "add duplicate's name as alias, delete duplicate. "
1970
+ "Requires write permission."
1971
+ ),
1972
+ inputSchema={
1973
+ "type": "object",
1974
+ "properties": {
1975
+ "canonical_path": {
1976
+ "type": "string",
1977
+ "description": "Source path of the entity to keep",
1978
+ },
1979
+ "duplicate_path": {
1980
+ "type": "string",
1981
+ "description": "Source path of the entity to merge into canonical",
1982
+ },
1983
+ },
1984
+ "required": ["canonical_path", "duplicate_path"],
1985
+ },
1986
+ ),
1987
+ Tool(
1988
+ name="list_pending_merges",
1989
+ description=(
1990
+ "List pending entity merge proposals awaiting approval. "
1991
+ "Created by find_entity_duplicates or run_consolidation."
1992
+ ),
1993
+ inputSchema={
1994
+ "type": "object",
1995
+ "properties": {
1996
+ "limit": {
1997
+ "type": "integer",
1998
+ "description": "Maximum results (default: 50)",
1999
+ "default": 50,
2000
+ },
2001
+ },
2002
+ },
2003
+ ),
2004
+ Tool(
2005
+ name="approve_merge",
2006
+ description=(
2007
+ "Approve a pending entity merge. Executes the merge: "
2008
+ "redirects refs, adds alias, deletes duplicate. "
2009
+ "Requires write permission."
2010
+ ),
2011
+ inputSchema={
2012
+ "type": "object",
2013
+ "properties": {
2014
+ "merge_id": {
2015
+ "type": "string",
2016
+ "description": "ID of the pending merge to approve",
2017
+ },
2018
+ },
2019
+ "required": ["merge_id"],
2020
+ },
2021
+ ),
2022
+ Tool(
2023
+ name="reject_merge",
2024
+ description=(
2025
+ "Reject a pending entity merge proposal. "
2026
+ "Requires write permission."
2027
+ ),
2028
+ inputSchema={
2029
+ "type": "object",
2030
+ "properties": {
2031
+ "merge_id": {
2032
+ "type": "string",
2033
+ "description": "ID of the pending merge to reject",
2034
+ },
2035
+ },
2036
+ "required": ["merge_id"],
2037
+ },
2038
+ ),
2039
+ Tool(
2040
+ name="get_topic_clusters",
2041
+ description=(
2042
+ "Get topic clusters - groups of related entities and documents. "
2043
+ "Clusters are created by run_consolidation."
2044
+ ),
2045
+ inputSchema={
2046
+ "type": "object",
2047
+ "properties": {
2048
+ "limit": {
2049
+ "type": "integer",
2050
+ "description": "Maximum clusters to return (default: 20)",
2051
+ "default": 20,
2052
+ },
2053
+ },
2054
+ },
2055
+ ),
2056
+ Tool(
2057
+ name="get_entity_relationships",
2058
+ description=(
2059
+ "Get relationships between entities (works_for, uses, belongs_to, related_to). "
2060
+ "Relationships are extracted by run_consolidation."
2061
+ ),
2062
+ inputSchema={
2063
+ "type": "object",
2064
+ "properties": {
2065
+ "entity_name": {
2066
+ "type": "string",
2067
+ "description": "Filter to relationships involving this entity (optional)",
2068
+ },
2069
+ "limit": {
2070
+ "type": "integer",
2071
+ "description": "Maximum results (default: 50)",
2072
+ "default": 50,
2073
+ },
2074
+ },
2075
+ },
2076
+ ),
2077
+ Tool(
2078
+ name="run_consolidation",
2079
+ description=(
2080
+ "Run full entity consolidation pipeline: duplicate detection, "
2081
+ "cross-document entity detection, topic clustering, relationship extraction. "
2082
+ "Creates pending proposals for review. Requires write permission."
2083
+ ),
2084
+ inputSchema={
2085
+ "type": "object",
2086
+ "properties": {
2087
+ "detect_duplicates": {
2088
+ "type": "boolean",
2089
+ "description": "Run duplicate entity detection (default: true)",
2090
+ "default": True,
2091
+ },
2092
+ "detect_cross_doc": {
2093
+ "type": "boolean",
2094
+ "description": "Run cross-document entity detection (default: true)",
2095
+ "default": True,
2096
+ },
2097
+ "build_clusters": {
2098
+ "type": "boolean",
2099
+ "description": "Build topic clusters (default: true)",
2100
+ "default": True,
2101
+ },
2102
+ "extract_relationships": {
2103
+ "type": "boolean",
2104
+ "description": "Extract entity relationships (default: true)",
2105
+ "default": True,
2106
+ },
2107
+ "dry_run": {
2108
+ "type": "boolean",
2109
+ "description": "Report what would happen without making changes",
2110
+ "default": False,
2111
+ },
2112
+ },
2113
+ },
2114
+ ),
1253
2115
  ]
1254
2116
 
1255
2117
 
@@ -1412,6 +2274,63 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
1412
2274
  ]
1413
2275
  )
1414
2276
 
2277
+ elif name == "get_project_stats":
2278
+ stats = kb.get_project_stats()
2279
+ if not stats:
2280
+ return CallToolResult(content=[TextContent(type="text", text="No projects found.")])
2281
+ output = ["## Project Statistics\n"]
2282
+ output.append("| Project | Documents | Source Types |")
2283
+ output.append("|---------|-----------|--------------|")
2284
+ for s in stats:
2285
+ types = ", ".join(s["source_types"]) if s["source_types"] else "-"
2286
+ output.append(f"| {s['project']} | {s['doc_count']} | {types} |")
2287
+ return CallToolResult(content=[TextContent(type="text", text="\n".join(output))])
2288
+
2289
+ elif name == "list_documents_by_project":
2290
+ project = arguments["project"]
2291
+ limit = arguments.get("limit", 100)
2292
+ docs = kb.list_documents_by_project(project, limit)
2293
+ if not docs:
2294
+ msg = f"No documents found for project '{project}'."
2295
+ return CallToolResult(content=[TextContent(type="text", text=msg)])
2296
+ output = [f"## Documents in '{project}' ({len(docs)} documents)\n"]
2297
+ for d in docs:
2298
+ output.append(f"- **{d['title'] or d['source_path']}** ({d['source_type']})")
2299
+ output.append(f" - `{d['source_path']}`")
2300
+ return CallToolResult(content=[TextContent(type="text", text="\n".join(output))])
2301
+
2302
+ elif name == "rename_project":
2303
+ old_name = arguments["old_name"]
2304
+ new_name = arguments["new_name"]
2305
+ if old_name == new_name:
2306
+ return CallToolResult(
2307
+ content=[TextContent(type="text", text="Old and new names are the same.")]
2308
+ )
2309
+ count = kb.rename_project(old_name, new_name)
2310
+ if count == 0:
2311
+ return CallToolResult(
2312
+ content=[TextContent(type="text", text=f"No documents found with project '{old_name}'.")]
2313
+ )
2314
+ return CallToolResult(
2315
+ content=[TextContent(type="text", text=f"Renamed project '{old_name}' to '{new_name}' ({count} documents updated).")]
2316
+ )
2317
+
2318
+ elif name == "set_document_project":
2319
+ source_path = arguments["source_path"]
2320
+ project = arguments.get("project")
2321
+ success = kb.set_document_project(source_path, project)
2322
+ if not success:
2323
+ return CallToolResult(
2324
+ content=[TextContent(type="text", text=f"Document not found: {source_path}")]
2325
+ )
2326
+ if project:
2327
+ return CallToolResult(
2328
+ content=[TextContent(type="text", text=f"Set project to '{project}' for {source_path}")]
2329
+ )
2330
+ return CallToolResult(
2331
+ content=[TextContent(type="text", text=f"Cleared project for {source_path}")]
2332
+ )
2333
+
1415
2334
  elif name == "recent_documents":
1416
2335
  docs = kb.get_recent_documents(arguments.get("limit", 10))
1417
2336
  if not docs:
@@ -1467,13 +2386,13 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
1467
2386
  deleted = kb.delete_knowledge(arguments["source_path"])
1468
2387
  if deleted:
1469
2388
  return CallToolResult(
1470
- content=[TextContent(type="text", text="Knowledge entry deleted.")]
2389
+ content=[TextContent(type="text", text="Document deleted.")]
1471
2390
  )
1472
2391
  return CallToolResult(
1473
2392
  content=[
1474
2393
  TextContent(
1475
2394
  type="text",
1476
- text="Could not delete. Entry not found or not a Claude-saved entry.",
2395
+ text="Could not delete. Document not found.",
1477
2396
  )
1478
2397
  ]
1479
2398
  )
@@ -1601,6 +2520,111 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
1601
2520
  )
1602
2521
  return CallToolResult(content=[TextContent(type="text", text=result)])
1603
2522
 
2523
+ elif name == "list_sync_sources":
2524
+ db_name = config.get_database().name
2525
+ result = _list_sync_sources(kb.db_url, db_name)
2526
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2527
+
2528
+ elif name == "enrich_document":
2529
+ result = _enrich_document(
2530
+ kb.db_url,
2531
+ source_path=arguments["source_path"],
2532
+ extract_todos=arguments.get("extract_todos", True),
2533
+ extract_entities=arguments.get("extract_entities", True),
2534
+ auto_create_entities=arguments.get("auto_create_entities", False),
2535
+ use_modal=not arguments.get("use_local", False),
2536
+ )
2537
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2538
+
2539
+ elif name == "list_pending_entities":
2540
+ result = _list_pending_entities(
2541
+ kb.db_url,
2542
+ entity_type=arguments.get("entity_type"),
2543
+ limit=arguments.get("limit", 20),
2544
+ )
2545
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2546
+
2547
+ elif name == "approve_entity":
2548
+ result = _approve_entity(
2549
+ kb.db_url,
2550
+ arguments["pending_id"],
2551
+ use_modal=not arguments.get("use_local", False),
2552
+ run_async=arguments.get("async", False),
2553
+ )
2554
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2555
+
2556
+ elif name == "reject_entity":
2557
+ result = _reject_entity(kb.db_url, arguments["pending_id"])
2558
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2559
+
2560
+ elif name == "analyze_knowledge_base":
2561
+ result = _analyze_knowledge_base(
2562
+ kb.db_url,
2563
+ project=arguments.get("project"),
2564
+ sample_size=arguments.get("sample_size", 15),
2565
+ auto_update=arguments.get("auto_update", True),
2566
+ )
2567
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2568
+
2569
+ elif name == "find_entity_duplicates":
2570
+ result = _find_entity_duplicates(
2571
+ kb.db_url,
2572
+ similarity_threshold=arguments.get("similarity_threshold", 0.85),
2573
+ entity_type=arguments.get("entity_type"),
2574
+ use_llm=arguments.get("use_llm", True),
2575
+ )
2576
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2577
+
2578
+ elif name == "merge_entities":
2579
+ result = _merge_entities(
2580
+ kb.db_url,
2581
+ canonical_path=arguments["canonical_path"],
2582
+ duplicate_path=arguments["duplicate_path"],
2583
+ )
2584
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2585
+
2586
+ elif name == "list_pending_merges":
2587
+ result = _list_pending_merges(
2588
+ kb.db_url,
2589
+ limit=arguments.get("limit", 50),
2590
+ )
2591
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2592
+
2593
+ elif name == "approve_merge":
2594
+ result = _approve_merge(kb.db_url, arguments["merge_id"])
2595
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2596
+
2597
+ elif name == "reject_merge":
2598
+ result = _reject_merge(kb.db_url, arguments["merge_id"])
2599
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2600
+
2601
+ elif name == "get_topic_clusters":
2602
+ result = _get_topic_clusters(
2603
+ kb.db_url,
2604
+ limit=arguments.get("limit", 20),
2605
+ )
2606
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2607
+
2608
+ elif name == "get_entity_relationships":
2609
+ result = _get_entity_relationships(
2610
+ kb.db_url,
2611
+ entity_name=arguments.get("entity_name"),
2612
+ relationship_type=arguments.get("relationship_type"),
2613
+ limit=arguments.get("limit", 50),
2614
+ )
2615
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2616
+
2617
+ elif name == "run_consolidation":
2618
+ result = _run_consolidation(
2619
+ kb.db_url,
2620
+ detect_duplicates=arguments.get("detect_duplicates", True),
2621
+ detect_cross_doc=arguments.get("detect_cross_doc", True),
2622
+ build_clusters=arguments.get("build_clusters", True),
2623
+ extract_relationships=arguments.get("extract_relationships", True),
2624
+ dry_run=arguments.get("dry_run", False),
2625
+ )
2626
+ return CallToolResult(content=[TextContent(type="text", text=result)])
2627
+
1604
2628
  else:
1605
2629
  return CallToolResult(content=[TextContent(type="text", text=f"Unknown tool: {name}")])
1606
2630