okb 1.0.0__py3-none-any.whl → 1.1.0a0__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/cli.py +126 -0
- okb/http_server.py +45 -0
- okb/mcp_server.py +243 -0
- okb/plugins/sources/__init__.py +2 -1
- okb/plugins/sources/dropbox_paper.py +44 -9
- okb/plugins/sources/todoist.py +254 -0
- {okb-1.0.0.dist-info → okb-1.1.0a0.dist-info}/METADATA +39 -65
- {okb-1.0.0.dist-info → okb-1.1.0a0.dist-info}/RECORD +10 -9
- {okb-1.0.0.dist-info → okb-1.1.0a0.dist-info}/entry_points.txt +1 -0
- {okb-1.0.0.dist-info → okb-1.1.0a0.dist-info}/WHEEL +0 -0
okb/cli.py
CHANGED
|
@@ -968,6 +968,132 @@ def sync_list():
|
|
|
968
968
|
click.echo(f" {name}")
|
|
969
969
|
|
|
970
970
|
|
|
971
|
+
@sync.command("list-projects")
|
|
972
|
+
@click.argument("source")
|
|
973
|
+
def sync_list_projects(source: str):
|
|
974
|
+
"""List projects from an API source (for finding project IDs).
|
|
975
|
+
|
|
976
|
+
Example: okb sync list-projects todoist
|
|
977
|
+
"""
|
|
978
|
+
from .plugins.registry import PluginRegistry
|
|
979
|
+
|
|
980
|
+
# Get the plugin
|
|
981
|
+
source_obj = PluginRegistry.get_source(source)
|
|
982
|
+
if source_obj is None:
|
|
983
|
+
click.echo(f"Error: Source '{source}' not found.", err=True)
|
|
984
|
+
click.echo(f"Installed sources: {', '.join(PluginRegistry.list_sources())}")
|
|
985
|
+
sys.exit(1)
|
|
986
|
+
|
|
987
|
+
# Check if source supports list_projects
|
|
988
|
+
if not hasattr(source_obj, "list_projects"):
|
|
989
|
+
click.echo(f"Error: Source '{source}' does not support listing projects.", err=True)
|
|
990
|
+
sys.exit(1)
|
|
991
|
+
|
|
992
|
+
# Get and resolve config
|
|
993
|
+
source_cfg = config.get_source_config(source)
|
|
994
|
+
if source_cfg is None:
|
|
995
|
+
click.echo(f"Error: Source '{source}' not configured.", err=True)
|
|
996
|
+
click.echo("Add it to your config file under plugins.sources")
|
|
997
|
+
sys.exit(1)
|
|
998
|
+
|
|
999
|
+
try:
|
|
1000
|
+
source_obj.configure(source_cfg)
|
|
1001
|
+
except Exception as e:
|
|
1002
|
+
click.echo(f"Error configuring '{source}': {e}", err=True)
|
|
1003
|
+
sys.exit(1)
|
|
1004
|
+
|
|
1005
|
+
try:
|
|
1006
|
+
projects = source_obj.list_projects()
|
|
1007
|
+
if projects:
|
|
1008
|
+
click.echo(f"Projects in {source}:")
|
|
1009
|
+
for project_id, name in projects:
|
|
1010
|
+
click.echo(f" {project_id}: {name}")
|
|
1011
|
+
else:
|
|
1012
|
+
click.echo("No projects found.")
|
|
1013
|
+
except Exception as e:
|
|
1014
|
+
click.echo(f"Error listing projects: {e}", err=True)
|
|
1015
|
+
sys.exit(1)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@sync.command("auth")
|
|
1019
|
+
@click.argument("source")
|
|
1020
|
+
def sync_auth(source: str):
|
|
1021
|
+
"""Authenticate with an API source (get tokens).
|
|
1022
|
+
|
|
1023
|
+
Currently supports: dropbox-paper
|
|
1024
|
+
|
|
1025
|
+
Example: okb sync auth dropbox-paper
|
|
1026
|
+
"""
|
|
1027
|
+
if source == "dropbox-paper":
|
|
1028
|
+
_auth_dropbox()
|
|
1029
|
+
else:
|
|
1030
|
+
click.echo(f"Error: Authentication helper not available for '{source}'", err=True)
|
|
1031
|
+
click.echo("Supported: dropbox-paper")
|
|
1032
|
+
sys.exit(1)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _auth_dropbox():
|
|
1036
|
+
"""Interactive OAuth flow for Dropbox."""
|
|
1037
|
+
try:
|
|
1038
|
+
import dropbox
|
|
1039
|
+
from dropbox import DropboxOAuth2FlowNoRedirect
|
|
1040
|
+
except ImportError:
|
|
1041
|
+
click.echo("Error: dropbox package not installed", err=True)
|
|
1042
|
+
click.echo("Install with: pip install dropbox", err=True)
|
|
1043
|
+
sys.exit(1)
|
|
1044
|
+
|
|
1045
|
+
click.echo("Dropbox OAuth Setup")
|
|
1046
|
+
click.echo("=" * 50)
|
|
1047
|
+
click.echo("")
|
|
1048
|
+
click.echo("You'll need your Dropbox app credentials.")
|
|
1049
|
+
click.echo("Get them at: https://www.dropbox.com/developers/apps")
|
|
1050
|
+
click.echo("")
|
|
1051
|
+
|
|
1052
|
+
app_key = click.prompt("App key")
|
|
1053
|
+
app_secret = click.prompt("App secret")
|
|
1054
|
+
|
|
1055
|
+
# Start OAuth flow
|
|
1056
|
+
auth_flow = DropboxOAuth2FlowNoRedirect(
|
|
1057
|
+
app_key,
|
|
1058
|
+
app_secret,
|
|
1059
|
+
token_access_type="offline", # This gives us a refresh token
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
authorize_url = auth_flow.start()
|
|
1063
|
+
click.echo("")
|
|
1064
|
+
click.echo("1. Go to this URL in your browser:")
|
|
1065
|
+
click.echo(f" {authorize_url}")
|
|
1066
|
+
click.echo("")
|
|
1067
|
+
click.echo("2. Click 'Allow' to authorize the app")
|
|
1068
|
+
click.echo("3. Copy the authorization code")
|
|
1069
|
+
click.echo("")
|
|
1070
|
+
|
|
1071
|
+
auth_code = click.prompt("Enter the authorization code")
|
|
1072
|
+
|
|
1073
|
+
try:
|
|
1074
|
+
oauth_result = auth_flow.finish(auth_code.strip())
|
|
1075
|
+
except Exception as e:
|
|
1076
|
+
click.echo(f"Error: Failed to get tokens - {e}", err=True)
|
|
1077
|
+
sys.exit(1)
|
|
1078
|
+
|
|
1079
|
+
click.echo("")
|
|
1080
|
+
click.echo("Success! Add these to your environment or config:")
|
|
1081
|
+
click.echo("")
|
|
1082
|
+
click.echo(f"DROPBOX_APP_KEY={app_key}")
|
|
1083
|
+
click.echo(f"DROPBOX_APP_SECRET={app_secret}")
|
|
1084
|
+
click.echo(f"DROPBOX_REFRESH_TOKEN={oauth_result.refresh_token}")
|
|
1085
|
+
click.echo("")
|
|
1086
|
+
click.echo("Config example (~/.config/okb/config.yaml):")
|
|
1087
|
+
click.echo("")
|
|
1088
|
+
click.echo("plugins:")
|
|
1089
|
+
click.echo(" sources:")
|
|
1090
|
+
click.echo(" dropbox-paper:")
|
|
1091
|
+
click.echo(" enabled: true")
|
|
1092
|
+
click.echo(" app_key: ${DROPBOX_APP_KEY}")
|
|
1093
|
+
click.echo(" app_secret: ${DROPBOX_APP_SECRET}")
|
|
1094
|
+
click.echo(" refresh_token: ${DROPBOX_REFRESH_TOKEN}")
|
|
1095
|
+
|
|
1096
|
+
|
|
971
1097
|
@sync.command("status")
|
|
972
1098
|
@click.argument("source", required=False)
|
|
973
1099
|
@click.option("--db", "database", default=None, help="Database to check")
|
okb/http_server.py
CHANGED
|
@@ -49,6 +49,8 @@ WRITE_TOOLS = frozenset(
|
|
|
49
49
|
"delete_knowledge",
|
|
50
50
|
"set_database_description",
|
|
51
51
|
"add_todo",
|
|
52
|
+
"trigger_sync",
|
|
53
|
+
"trigger_rescan",
|
|
52
54
|
}
|
|
53
55
|
)
|
|
54
56
|
|
|
@@ -349,6 +351,49 @@ class HTTPMCPServer:
|
|
|
349
351
|
content=[TextContent(type="text", text="No fields provided to update.")]
|
|
350
352
|
)
|
|
351
353
|
|
|
354
|
+
elif name == "add_todo":
|
|
355
|
+
result = kb.save_todo(
|
|
356
|
+
title=arguments["title"],
|
|
357
|
+
content=arguments.get("content"),
|
|
358
|
+
due_date=arguments.get("due_date"),
|
|
359
|
+
priority=arguments.get("priority"),
|
|
360
|
+
project=arguments.get("project"),
|
|
361
|
+
tags=arguments.get("tags"),
|
|
362
|
+
)
|
|
363
|
+
parts = [
|
|
364
|
+
"TODO created:",
|
|
365
|
+
f"- Title: {result['title']}",
|
|
366
|
+
f"- Path: `{result['source_path']}`",
|
|
367
|
+
]
|
|
368
|
+
if result.get("priority"):
|
|
369
|
+
parts.append(f"- Priority: P{result['priority']}")
|
|
370
|
+
if result.get("due_date"):
|
|
371
|
+
parts.append(f"- Due: {result['due_date']}")
|
|
372
|
+
return CallToolResult(content=[TextContent(type="text", text="\n".join(parts))])
|
|
373
|
+
|
|
374
|
+
elif name == "trigger_sync":
|
|
375
|
+
from .mcp_server import _run_sync
|
|
376
|
+
|
|
377
|
+
# Get the db_url from the knowledge base
|
|
378
|
+
result = _run_sync(
|
|
379
|
+
kb.db_url,
|
|
380
|
+
sources=arguments.get("sources", []),
|
|
381
|
+
sync_all=arguments.get("all", False),
|
|
382
|
+
full=arguments.get("full", False),
|
|
383
|
+
doc_ids=arguments.get("doc_ids"),
|
|
384
|
+
)
|
|
385
|
+
return CallToolResult(content=[TextContent(type="text", text=result)])
|
|
386
|
+
|
|
387
|
+
elif name == "trigger_rescan":
|
|
388
|
+
from .mcp_server import _run_rescan
|
|
389
|
+
|
|
390
|
+
result = _run_rescan(
|
|
391
|
+
kb.db_url,
|
|
392
|
+
dry_run=arguments.get("dry_run", False),
|
|
393
|
+
delete_missing=arguments.get("delete_missing", False),
|
|
394
|
+
)
|
|
395
|
+
return CallToolResult(content=[TextContent(type="text", text=result)])
|
|
396
|
+
|
|
352
397
|
else:
|
|
353
398
|
return CallToolResult(
|
|
354
399
|
content=[TextContent(type="text", text=f"Unknown tool: {name}")]
|
okb/mcp_server.py
CHANGED
|
@@ -676,6 +676,170 @@ class KnowledgeBase:
|
|
|
676
676
|
return [dict(r) for r in results]
|
|
677
677
|
|
|
678
678
|
|
|
679
|
+
def _get_sync_state(conn, source_name: str, db_name: str):
|
|
680
|
+
"""Get sync state from database."""
|
|
681
|
+
from .plugins.base import SyncState
|
|
682
|
+
|
|
683
|
+
result = conn.execute(
|
|
684
|
+
"""SELECT last_sync, cursor, extra FROM sync_state
|
|
685
|
+
WHERE source_name = %s AND database_name = %s""",
|
|
686
|
+
(source_name, db_name),
|
|
687
|
+
).fetchone()
|
|
688
|
+
|
|
689
|
+
if result:
|
|
690
|
+
return SyncState(
|
|
691
|
+
last_sync=result["last_sync"],
|
|
692
|
+
cursor=result["cursor"],
|
|
693
|
+
extra=result["extra"] or {},
|
|
694
|
+
)
|
|
695
|
+
return None
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _save_sync_state(conn, source_name: str, db_name: str, state):
|
|
699
|
+
"""Save sync state to database."""
|
|
700
|
+
import json
|
|
701
|
+
|
|
702
|
+
conn.execute(
|
|
703
|
+
"""INSERT INTO sync_state (source_name, database_name, last_sync, cursor, extra, updated_at)
|
|
704
|
+
VALUES (%s, %s, %s, %s, %s, NOW())
|
|
705
|
+
ON CONFLICT (source_name, database_name)
|
|
706
|
+
DO UPDATE SET last_sync = EXCLUDED.last_sync,
|
|
707
|
+
cursor = EXCLUDED.cursor,
|
|
708
|
+
extra = EXCLUDED.extra,
|
|
709
|
+
updated_at = NOW()""",
|
|
710
|
+
(source_name, db_name, state.last_sync, state.cursor, json.dumps(state.extra)),
|
|
711
|
+
)
|
|
712
|
+
conn.commit()
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _run_sync(
|
|
716
|
+
db_url: str,
|
|
717
|
+
sources: list[str],
|
|
718
|
+
sync_all: bool = False,
|
|
719
|
+
full: bool = False,
|
|
720
|
+
doc_ids: list[str] | None = None,
|
|
721
|
+
) -> str:
|
|
722
|
+
"""Run sync for specified sources and return formatted result."""
|
|
723
|
+
from psycopg.rows import dict_row
|
|
724
|
+
|
|
725
|
+
from .ingest import Ingester
|
|
726
|
+
from .plugins.registry import PluginRegistry
|
|
727
|
+
|
|
728
|
+
# Determine which sources to sync
|
|
729
|
+
if sync_all:
|
|
730
|
+
source_names = config.list_enabled_sources()
|
|
731
|
+
elif sources:
|
|
732
|
+
source_names = list(sources)
|
|
733
|
+
else:
|
|
734
|
+
# Return list of available sources
|
|
735
|
+
installed = PluginRegistry.list_sources()
|
|
736
|
+
configured = config.list_enabled_sources()
|
|
737
|
+
lines = ["Available API sources:"]
|
|
738
|
+
for name in installed:
|
|
739
|
+
status = "enabled" if name in configured else "disabled"
|
|
740
|
+
lines.append(f" - {name} ({status})")
|
|
741
|
+
if not installed:
|
|
742
|
+
lines.append(" (none installed)")
|
|
743
|
+
return "\n".join(lines)
|
|
744
|
+
|
|
745
|
+
if not source_names:
|
|
746
|
+
return "No sources to sync."
|
|
747
|
+
|
|
748
|
+
# Get database name from URL for sync state
|
|
749
|
+
db_name = config.get_database().name
|
|
750
|
+
|
|
751
|
+
results = []
|
|
752
|
+
ingester = Ingester(db_url, use_modal=True)
|
|
753
|
+
|
|
754
|
+
with psycopg.connect(db_url, row_factory=dict_row) as conn:
|
|
755
|
+
for source_name in source_names:
|
|
756
|
+
# Get the plugin
|
|
757
|
+
source = PluginRegistry.get_source(source_name)
|
|
758
|
+
if source is None:
|
|
759
|
+
results.append(f"{source_name}: not found")
|
|
760
|
+
continue
|
|
761
|
+
|
|
762
|
+
# Get and resolve config
|
|
763
|
+
source_cfg = config.get_source_config(source_name)
|
|
764
|
+
if source_cfg is None:
|
|
765
|
+
results.append(f"{source_name}: not configured or disabled")
|
|
766
|
+
continue
|
|
767
|
+
|
|
768
|
+
# Inject doc_ids if provided (for sources that support it)
|
|
769
|
+
if doc_ids:
|
|
770
|
+
source_cfg = {**source_cfg, "doc_ids": doc_ids}
|
|
771
|
+
|
|
772
|
+
try:
|
|
773
|
+
source.configure(source_cfg)
|
|
774
|
+
except Exception as e:
|
|
775
|
+
results.append(f"{source_name}: config error - {e}")
|
|
776
|
+
continue
|
|
777
|
+
|
|
778
|
+
# Get sync state (unless full)
|
|
779
|
+
state = None if full else _get_sync_state(conn, source_name, db_name)
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
documents, new_state = source.fetch(state)
|
|
783
|
+
except Exception as e:
|
|
784
|
+
results.append(f"{source_name}: fetch error - {e}")
|
|
785
|
+
continue
|
|
786
|
+
|
|
787
|
+
if documents:
|
|
788
|
+
ingester.ingest_documents(documents)
|
|
789
|
+
results.append(f"{source_name}: synced {len(documents)} documents")
|
|
790
|
+
else:
|
|
791
|
+
results.append(f"{source_name}: no new documents")
|
|
792
|
+
|
|
793
|
+
# Save state
|
|
794
|
+
_save_sync_state(conn, source_name, db_name, new_state)
|
|
795
|
+
|
|
796
|
+
return "\n".join(results)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _run_rescan(
|
|
800
|
+
db_url: str,
|
|
801
|
+
dry_run: bool = False,
|
|
802
|
+
delete_missing: bool = False,
|
|
803
|
+
) -> str:
|
|
804
|
+
"""Run rescan and return formatted result."""
|
|
805
|
+
from .rescan import Rescanner
|
|
806
|
+
|
|
807
|
+
rescanner = Rescanner(db_url, use_modal=True)
|
|
808
|
+
result = rescanner.rescan(dry_run=dry_run, delete_missing=delete_missing, verbose=False)
|
|
809
|
+
|
|
810
|
+
lines = []
|
|
811
|
+
if dry_run:
|
|
812
|
+
lines.append("(dry run - no changes made)")
|
|
813
|
+
|
|
814
|
+
if result.updated:
|
|
815
|
+
lines.append(f"Updated: {len(result.updated)} files")
|
|
816
|
+
for path in result.updated[:5]: # Show first 5
|
|
817
|
+
lines.append(f" - {path}")
|
|
818
|
+
if len(result.updated) > 5:
|
|
819
|
+
lines.append(f" ... and {len(result.updated) - 5} more")
|
|
820
|
+
|
|
821
|
+
if result.deleted:
|
|
822
|
+
lines.append(f"Deleted: {len(result.deleted)} files")
|
|
823
|
+
|
|
824
|
+
if result.missing:
|
|
825
|
+
lines.append(f"Missing (not deleted): {len(result.missing)} files")
|
|
826
|
+
for path in result.missing[:5]:
|
|
827
|
+
lines.append(f" - {path}")
|
|
828
|
+
if len(result.missing) > 5:
|
|
829
|
+
lines.append(f" ... and {len(result.missing) - 5} more")
|
|
830
|
+
|
|
831
|
+
lines.append(f"Unchanged: {result.unchanged} files")
|
|
832
|
+
|
|
833
|
+
if result.errors:
|
|
834
|
+
lines.append(f"Errors: {len(result.errors)}")
|
|
835
|
+
for path, error in result.errors[:3]:
|
|
836
|
+
lines.append(f" - {path}: {error}")
|
|
837
|
+
if len(result.errors) > 3:
|
|
838
|
+
lines.append(f" ... and {len(result.errors) - 3} more")
|
|
839
|
+
|
|
840
|
+
return "\n".join(lines) if lines else "No indexed files found."
|
|
841
|
+
|
|
842
|
+
|
|
679
843
|
def build_server_instructions(db_config) -> str | None:
|
|
680
844
|
"""Build server instructions from database config and LLM metadata."""
|
|
681
845
|
parts = []
|
|
@@ -1025,6 +1189,67 @@ async def list_tools() -> list[Tool]:
|
|
|
1025
1189
|
"required": ["title"],
|
|
1026
1190
|
},
|
|
1027
1191
|
),
|
|
1192
|
+
Tool(
|
|
1193
|
+
name="trigger_sync",
|
|
1194
|
+
description=(
|
|
1195
|
+
"Trigger sync of API sources (Todoist, GitHub, Dropbox Paper, etc.). "
|
|
1196
|
+
"Fetches new/updated content from external services. Requires write permission."
|
|
1197
|
+
),
|
|
1198
|
+
inputSchema={
|
|
1199
|
+
"type": "object",
|
|
1200
|
+
"properties": {
|
|
1201
|
+
"sources": {
|
|
1202
|
+
"type": "array",
|
|
1203
|
+
"items": {"type": "string"},
|
|
1204
|
+
"description": (
|
|
1205
|
+
"List of source names to sync (e.g., ['todoist', 'github']). "
|
|
1206
|
+
"If empty and 'all' is false, returns list of available sources."
|
|
1207
|
+
),
|
|
1208
|
+
},
|
|
1209
|
+
"all": {
|
|
1210
|
+
"type": "boolean",
|
|
1211
|
+
"default": False,
|
|
1212
|
+
"description": "Sync all enabled sources",
|
|
1213
|
+
},
|
|
1214
|
+
"full": {
|
|
1215
|
+
"type": "boolean",
|
|
1216
|
+
"default": False,
|
|
1217
|
+
"description": "Ignore incremental state and do full resync",
|
|
1218
|
+
},
|
|
1219
|
+
"doc_ids": {
|
|
1220
|
+
"type": "array",
|
|
1221
|
+
"items": {"type": "string"},
|
|
1222
|
+
"description": (
|
|
1223
|
+
"Specific document IDs to sync (for dropbox-paper). "
|
|
1224
|
+
"If provided, only these documents are synced."
|
|
1225
|
+
),
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
},
|
|
1229
|
+
),
|
|
1230
|
+
Tool(
|
|
1231
|
+
name="trigger_rescan",
|
|
1232
|
+
description=(
|
|
1233
|
+
"Check indexed files for changes and re-ingest stale ones. "
|
|
1234
|
+
"Compares stored modification times with current filesystem. "
|
|
1235
|
+
"Requires write permission."
|
|
1236
|
+
),
|
|
1237
|
+
inputSchema={
|
|
1238
|
+
"type": "object",
|
|
1239
|
+
"properties": {
|
|
1240
|
+
"dry_run": {
|
|
1241
|
+
"type": "boolean",
|
|
1242
|
+
"default": False,
|
|
1243
|
+
"description": "Only report what would change, don't actually re-ingest",
|
|
1244
|
+
},
|
|
1245
|
+
"delete_missing": {
|
|
1246
|
+
"type": "boolean",
|
|
1247
|
+
"default": False,
|
|
1248
|
+
"description": "Remove documents for files that no longer exist",
|
|
1249
|
+
},
|
|
1250
|
+
},
|
|
1251
|
+
},
|
|
1252
|
+
),
|
|
1028
1253
|
]
|
|
1029
1254
|
|
|
1030
1255
|
|
|
@@ -1358,6 +1583,24 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
|
|
|
1358
1583
|
parts.append(f"- Due: {result['due_date']}")
|
|
1359
1584
|
return CallToolResult(content=[TextContent(type="text", text="\n".join(parts))])
|
|
1360
1585
|
|
|
1586
|
+
elif name == "trigger_sync":
|
|
1587
|
+
result = _run_sync(
|
|
1588
|
+
kb.db_url,
|
|
1589
|
+
sources=arguments.get("sources", []),
|
|
1590
|
+
sync_all=arguments.get("all", False),
|
|
1591
|
+
full=arguments.get("full", False),
|
|
1592
|
+
doc_ids=arguments.get("doc_ids"),
|
|
1593
|
+
)
|
|
1594
|
+
return CallToolResult(content=[TextContent(type="text", text=result)])
|
|
1595
|
+
|
|
1596
|
+
elif name == "trigger_rescan":
|
|
1597
|
+
result = _run_rescan(
|
|
1598
|
+
kb.db_url,
|
|
1599
|
+
dry_run=arguments.get("dry_run", False),
|
|
1600
|
+
delete_missing=arguments.get("delete_missing", False),
|
|
1601
|
+
)
|
|
1602
|
+
return CallToolResult(content=[TextContent(type="text", text=result)])
|
|
1603
|
+
|
|
1361
1604
|
else:
|
|
1362
1605
|
return CallToolResult(content=[TextContent(type="text", text=f"Unknown tool: {name}")])
|
|
1363
1606
|
|
okb/plugins/sources/__init__.py
CHANGED
|
@@ -16,17 +16,27 @@ class DropboxPaperSource:
|
|
|
16
16
|
|
|
17
17
|
Syncs Paper documents as markdown for searchable knowledge base entries.
|
|
18
18
|
|
|
19
|
-
Config example:
|
|
19
|
+
Config example (refresh token - recommended):
|
|
20
20
|
plugins:
|
|
21
21
|
sources:
|
|
22
22
|
dropbox-paper:
|
|
23
23
|
enabled: true
|
|
24
|
-
|
|
24
|
+
app_key: ${DROPBOX_APP_KEY}
|
|
25
|
+
app_secret: ${DROPBOX_APP_SECRET}
|
|
26
|
+
refresh_token: ${DROPBOX_REFRESH_TOKEN}
|
|
25
27
|
folders: [/] # Optional: filter to specific folder paths
|
|
26
28
|
|
|
29
|
+
Config example (access token - short-lived):
|
|
30
|
+
plugins:
|
|
31
|
+
sources:
|
|
32
|
+
dropbox-paper:
|
|
33
|
+
enabled: true
|
|
34
|
+
token: ${DROPBOX_TOKEN} # Expires after ~4 hours
|
|
35
|
+
|
|
27
36
|
Usage:
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
okb sync run dropbox-paper
|
|
38
|
+
okb sync run dropbox-paper --full # Ignore incremental state
|
|
39
|
+
okb sync run dropbox-paper --doc <doc_id> # Sync specific document
|
|
30
40
|
"""
|
|
31
41
|
|
|
32
42
|
name = "dropbox-paper"
|
|
@@ -38,18 +48,43 @@ class DropboxPaperSource:
|
|
|
38
48
|
self._doc_ids: list[str] | None = None
|
|
39
49
|
|
|
40
50
|
def configure(self, config: dict) -> None:
|
|
41
|
-
"""Initialize Dropbox client with OAuth token.
|
|
51
|
+
"""Initialize Dropbox client with OAuth token or refresh token.
|
|
52
|
+
|
|
53
|
+
Supports two authentication modes:
|
|
54
|
+
1. Access token only (short-lived, will expire):
|
|
55
|
+
token: <access_token>
|
|
56
|
+
|
|
57
|
+
2. Refresh token (recommended, auto-refreshes):
|
|
58
|
+
app_key: <app_key>
|
|
59
|
+
app_secret: <app_secret>
|
|
60
|
+
refresh_token: <refresh_token>
|
|
42
61
|
|
|
43
62
|
Args:
|
|
44
|
-
config: Source configuration containing
|
|
63
|
+
config: Source configuration containing auth credentials and optional 'folders'/'doc_ids'
|
|
45
64
|
"""
|
|
46
65
|
import dropbox
|
|
47
66
|
|
|
67
|
+
app_key = config.get("app_key")
|
|
68
|
+
app_secret = config.get("app_secret")
|
|
69
|
+
refresh_token = config.get("refresh_token")
|
|
48
70
|
token = config.get("token")
|
|
49
|
-
if not token:
|
|
50
|
-
raise ValueError("dropbox-paper source requires 'token' in config")
|
|
51
71
|
|
|
52
|
-
|
|
72
|
+
if app_key and app_secret and refresh_token:
|
|
73
|
+
# Use refresh token - will auto-refresh access tokens
|
|
74
|
+
self._client = dropbox.Dropbox(
|
|
75
|
+
app_key=app_key,
|
|
76
|
+
app_secret=app_secret,
|
|
77
|
+
oauth2_refresh_token=refresh_token,
|
|
78
|
+
)
|
|
79
|
+
elif token:
|
|
80
|
+
# Legacy: direct access token (will expire)
|
|
81
|
+
self._client = dropbox.Dropbox(token)
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"dropbox-paper source requires either 'token' or "
|
|
85
|
+
"'app_key'/'app_secret'/'refresh_token' in config"
|
|
86
|
+
)
|
|
87
|
+
|
|
53
88
|
self._folders = config.get("folders")
|
|
54
89
|
self._doc_ids = config.get("doc_ids") # Specific doc IDs from CLI
|
|
55
90
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Todoist API source for syncing tasks into OKB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from okb.ingest import Document
|
|
11
|
+
from okb.plugins.base import SyncState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TodoistSource:
|
|
15
|
+
"""API source for Todoist tasks.
|
|
16
|
+
|
|
17
|
+
Syncs active and optionally completed tasks for semantic search and actionable item queries.
|
|
18
|
+
|
|
19
|
+
Config example:
|
|
20
|
+
plugins:
|
|
21
|
+
sources:
|
|
22
|
+
todoist:
|
|
23
|
+
enabled: true
|
|
24
|
+
token: ${TODOIST_TOKEN}
|
|
25
|
+
include_completed: false # Include recently completed tasks
|
|
26
|
+
completed_days: 30 # Days of completed tasks to sync
|
|
27
|
+
include_comments: false # Include task comments (expensive)
|
|
28
|
+
project_filter: [] # Optional: list of project IDs to sync
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
okb sync run todoist
|
|
32
|
+
okb sync run todoist --full # Full resync
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name = "todoist"
|
|
36
|
+
source_type = "todoist-task"
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._client = None
|
|
40
|
+
self._include_completed = False
|
|
41
|
+
self._completed_days = 30
|
|
42
|
+
self._include_comments = False
|
|
43
|
+
self._project_filter: list[str] | None = None
|
|
44
|
+
self._projects: dict[str, str] = {} # id -> name
|
|
45
|
+
|
|
46
|
+
def configure(self, config: dict) -> None:
|
|
47
|
+
"""Initialize Todoist client with API token.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config: Source configuration containing 'token' and optional settings
|
|
51
|
+
"""
|
|
52
|
+
from todoist_api_python.api import TodoistAPI
|
|
53
|
+
|
|
54
|
+
token = config.get("token")
|
|
55
|
+
if not token:
|
|
56
|
+
raise ValueError("todoist source requires 'token' in config")
|
|
57
|
+
|
|
58
|
+
self._client = TodoistAPI(token)
|
|
59
|
+
self._include_completed = config.get("include_completed", False)
|
|
60
|
+
self._completed_days = config.get("completed_days", 30)
|
|
61
|
+
self._include_comments = config.get("include_comments", False)
|
|
62
|
+
self._project_filter = config.get("project_filter")
|
|
63
|
+
|
|
64
|
+
def fetch(self, state: SyncState | None = None) -> tuple[list[Document], SyncState]:
|
|
65
|
+
"""Fetch tasks from Todoist.
|
|
66
|
+
|
|
67
|
+
Active tasks are always fully synced (API has no "modified since" filter).
|
|
68
|
+
Completed tasks use state.last_sync for incremental fetching.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
state: Previous sync state for incremental updates, or None for full sync
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tuple of (list of documents, new sync state)
|
|
75
|
+
"""
|
|
76
|
+
from okb.plugins.base import SyncState as SyncStateClass
|
|
77
|
+
|
|
78
|
+
if self._client is None:
|
|
79
|
+
raise RuntimeError("Source not configured. Call configure() first.")
|
|
80
|
+
|
|
81
|
+
documents: list[Document] = []
|
|
82
|
+
|
|
83
|
+
print("Fetching Todoist tasks...", file=sys.stderr)
|
|
84
|
+
|
|
85
|
+
# Load projects for name lookup
|
|
86
|
+
self._load_projects()
|
|
87
|
+
|
|
88
|
+
# Fetch active tasks
|
|
89
|
+
active_docs = self._fetch_active_tasks()
|
|
90
|
+
documents.extend(active_docs)
|
|
91
|
+
print(f" Synced {len(active_docs)} active tasks", file=sys.stderr)
|
|
92
|
+
|
|
93
|
+
# Fetch completed tasks if enabled
|
|
94
|
+
if self._include_completed:
|
|
95
|
+
since = state.last_sync if state and state.last_sync else None
|
|
96
|
+
completed_docs = self._fetch_completed_tasks(since)
|
|
97
|
+
documents.extend(completed_docs)
|
|
98
|
+
print(f" Synced {len(completed_docs)} completed tasks", file=sys.stderr)
|
|
99
|
+
|
|
100
|
+
new_state = SyncStateClass(last_sync=datetime.now(UTC))
|
|
101
|
+
return documents, new_state
|
|
102
|
+
|
|
103
|
+
def _load_projects(self) -> None:
|
|
104
|
+
"""Load projects for name lookup."""
|
|
105
|
+
try:
|
|
106
|
+
self._projects = {}
|
|
107
|
+
for project_batch in self._client.get_projects():
|
|
108
|
+
for p in project_batch:
|
|
109
|
+
self._projects[p.id] = p.name
|
|
110
|
+
except Exception as e:
|
|
111
|
+
print(f" Warning: Could not load projects: {e}", file=sys.stderr)
|
|
112
|
+
self._projects = {}
|
|
113
|
+
|
|
114
|
+
def _fetch_active_tasks(self) -> list[Document]:
|
|
115
|
+
"""Fetch all active tasks."""
|
|
116
|
+
documents = []
|
|
117
|
+
|
|
118
|
+
for task_batch in self._client.get_tasks():
|
|
119
|
+
for task in task_batch:
|
|
120
|
+
# Apply project filter if configured
|
|
121
|
+
if self._project_filter and task.project_id not in self._project_filter:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
doc = self._task_to_document(task, is_completed=False)
|
|
125
|
+
if doc:
|
|
126
|
+
documents.append(doc)
|
|
127
|
+
|
|
128
|
+
return documents
|
|
129
|
+
|
|
130
|
+
def _fetch_completed_tasks(self, since: datetime | None) -> list[Document]:
|
|
131
|
+
"""Fetch completed tasks within the configured window."""
|
|
132
|
+
documents = []
|
|
133
|
+
|
|
134
|
+
# Determine date range
|
|
135
|
+
until = datetime.now(UTC)
|
|
136
|
+
if since:
|
|
137
|
+
start = since
|
|
138
|
+
else:
|
|
139
|
+
start = until - timedelta(days=self._completed_days)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
for task_batch in self._client.get_completed_tasks_by_completion_date(
|
|
143
|
+
since=start,
|
|
144
|
+
until=until,
|
|
145
|
+
):
|
|
146
|
+
for task in task_batch:
|
|
147
|
+
# Apply project filter if configured
|
|
148
|
+
if self._project_filter and task.project_id not in self._project_filter:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
doc = self._task_to_document(task, is_completed=True)
|
|
152
|
+
if doc:
|
|
153
|
+
documents.append(doc)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
print(f" Warning: Could not fetch completed tasks: {e}", file=sys.stderr)
|
|
156
|
+
|
|
157
|
+
return documents
|
|
158
|
+
|
|
159
|
+
def _task_to_document(self, task, is_completed: bool) -> Document | None:
|
|
160
|
+
"""Convert a Todoist task to a Document."""
|
|
161
|
+
from okb.ingest import Document, DocumentMetadata
|
|
162
|
+
|
|
163
|
+
# Build content from task content + description + optional comments
|
|
164
|
+
content_parts = [task.content]
|
|
165
|
+
if task.description:
|
|
166
|
+
content_parts.append(task.description)
|
|
167
|
+
|
|
168
|
+
if self._include_comments:
|
|
169
|
+
comments = self._fetch_task_comments(task.id)
|
|
170
|
+
if comments:
|
|
171
|
+
content_parts.append("\n## Comments\n" + "\n".join(comments))
|
|
172
|
+
|
|
173
|
+
content = "\n\n".join(content_parts)
|
|
174
|
+
|
|
175
|
+
# Parse due date
|
|
176
|
+
due_date = None
|
|
177
|
+
if task.due:
|
|
178
|
+
due_date = self._parse_due(task.due)
|
|
179
|
+
|
|
180
|
+
# Map priority: Todoist uses 1-4 (4=urgent), OKB uses 1-5 (1=highest)
|
|
181
|
+
priority = 5 - task.priority if task.priority else None
|
|
182
|
+
|
|
183
|
+
# Get project name
|
|
184
|
+
project_name = self._projects.get(task.project_id)
|
|
185
|
+
|
|
186
|
+
# Build metadata
|
|
187
|
+
metadata = DocumentMetadata(
|
|
188
|
+
tags=task.labels or [],
|
|
189
|
+
project=project_name,
|
|
190
|
+
extra={
|
|
191
|
+
"todoist_id": task.id,
|
|
192
|
+
"project_id": task.project_id,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Determine status
|
|
197
|
+
status = "completed" if is_completed or task.is_completed else "pending"
|
|
198
|
+
|
|
199
|
+
return Document(
|
|
200
|
+
source_path=f"todoist://task/{task.id}",
|
|
201
|
+
source_type=self.source_type,
|
|
202
|
+
title=task.content,
|
|
203
|
+
content=content,
|
|
204
|
+
metadata=metadata,
|
|
205
|
+
due_date=due_date,
|
|
206
|
+
status=status,
|
|
207
|
+
priority=priority,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _parse_due(self, due) -> datetime | None:
|
|
211
|
+
"""Parse Todoist Due object to datetime."""
|
|
212
|
+
if due is None:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# Due has 'datetime' (full datetime) or 'date' (date only)
|
|
217
|
+
if hasattr(due, "datetime") and due.datetime:
|
|
218
|
+
return datetime.fromisoformat(due.datetime.replace("Z", "+00:00"))
|
|
219
|
+
elif hasattr(due, "date") and due.date:
|
|
220
|
+
# Date-only: treat as end of day in UTC
|
|
221
|
+
if isinstance(due.date, str):
|
|
222
|
+
d = datetime.strptime(due.date, "%Y-%m-%d").date()
|
|
223
|
+
else:
|
|
224
|
+
d = due.date
|
|
225
|
+
return datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=UTC)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _fetch_task_comments(self, task_id: str) -> list[str]:
|
|
231
|
+
"""Fetch comments for a task."""
|
|
232
|
+
comments = []
|
|
233
|
+
try:
|
|
234
|
+
for comment_batch in self._client.get_comments(task_id=task_id):
|
|
235
|
+
for comment in comment_batch:
|
|
236
|
+
comments.append(f"- {comment.content}")
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
return comments
|
|
240
|
+
|
|
241
|
+
def list_projects(self) -> list[tuple[str, str]]:
|
|
242
|
+
"""List all projects with their IDs.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
List of (project_id, project_name) tuples
|
|
246
|
+
"""
|
|
247
|
+
if self._client is None:
|
|
248
|
+
raise RuntimeError("Source not configured. Call configure() first.")
|
|
249
|
+
|
|
250
|
+
projects = []
|
|
251
|
+
for project_batch in self._client.get_projects():
|
|
252
|
+
for p in project_batch:
|
|
253
|
+
projects.append((p.id, p.name))
|
|
254
|
+
return projects
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: okb
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0a0
|
|
4
4
|
Summary: Personal knowledge base with semantic search for LLMs
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -13,6 +13,7 @@ Provides-Extra: docx
|
|
|
13
13
|
Provides-Extra: llm
|
|
14
14
|
Provides-Extra: llm-bedrock
|
|
15
15
|
Provides-Extra: pdf
|
|
16
|
+
Provides-Extra: todoist
|
|
16
17
|
Provides-Extra: web
|
|
17
18
|
Requires-Dist: PyGithub (>=2.0.0)
|
|
18
19
|
Requires-Dist: anthropic (>=0.40.0) ; extra == "all"
|
|
@@ -35,10 +36,15 @@ Requires-Dist: python-docx (>=1.1.0) ; extra == "docx"
|
|
|
35
36
|
Requires-Dist: pyyaml (>=6.0)
|
|
36
37
|
Requires-Dist: ruff (>=0.1.0) ; extra == "dev"
|
|
37
38
|
Requires-Dist: sentence-transformers (>=2.2.0)
|
|
39
|
+
Requires-Dist: todoist-api-python (>=3.0.0) ; extra == "all"
|
|
40
|
+
Requires-Dist: todoist-api-python (>=3.0.0) ; extra == "todoist"
|
|
38
41
|
Requires-Dist: trafilatura (>=1.6.0) ; extra == "all"
|
|
39
42
|
Requires-Dist: trafilatura (>=1.6.0) ; extra == "web"
|
|
40
43
|
Requires-Dist: watchdog (>=3.0.0)
|
|
41
44
|
Requires-Dist: yoyo-migrations (>=8.0.0)
|
|
45
|
+
Project-URL: Homepage, https://github.com/username/okb
|
|
46
|
+
Project-URL: Issues, https://github.com/username/okb/issues
|
|
47
|
+
Project-URL: Repository, https://github.com/username/okb
|
|
42
48
|
Description-Content-Type: text/markdown
|
|
43
49
|
|
|
44
50
|
# Owned Knowledge Base (OKB)
|
|
@@ -47,15 +53,14 @@ A local-first semantic search system for personal documents with Claude Code int
|
|
|
47
53
|
|
|
48
54
|
## Installation
|
|
49
55
|
|
|
56
|
+
pipx - preferred!
|
|
50
57
|
```bash
|
|
51
|
-
|
|
58
|
+
pipx install okb
|
|
52
59
|
```
|
|
53
60
|
|
|
54
|
-
Or
|
|
61
|
+
Or pip:
|
|
55
62
|
```bash
|
|
56
|
-
|
|
57
|
-
cd okb
|
|
58
|
-
pip install -e .
|
|
63
|
+
pip install okb
|
|
59
64
|
```
|
|
60
65
|
|
|
61
66
|
## Quick Start
|
|
@@ -82,7 +87,7 @@ okb ingest ~/notes ~/docs
|
|
|
82
87
|
| `okb db status` | Show database status |
|
|
83
88
|
| `okb db destroy` | Remove container and volume (destructive) |
|
|
84
89
|
| `okb ingest <paths>` | Ingest documents into knowledge base |
|
|
85
|
-
| `okb ingest <paths> --local` | Ingest using CPU embedding (no Modal) |
|
|
90
|
+
| `okb ingest <paths> --local` | Ingest using local GPU/CPU embedding (no Modal) |
|
|
86
91
|
| `okb serve` | Start MCP server (stdio, for Claude Code) |
|
|
87
92
|
| `okb serve --http` | Start HTTP MCP server with token auth |
|
|
88
93
|
| `okb watch <paths>` | Watch directories for changes |
|
|
@@ -93,7 +98,9 @@ okb ingest ~/notes ~/docs
|
|
|
93
98
|
| `okb token list` | List tokens for a database |
|
|
94
99
|
| `okb token revoke` | Revoke an API token |
|
|
95
100
|
| `okb sync list` | List available API sources (plugins) |
|
|
101
|
+
| `okb sync list-projects <source>` | List projects from source (for config) |
|
|
96
102
|
| `okb sync run <sources>` | Sync data from external APIs |
|
|
103
|
+
| `okb sync auth <source>` | Interactive OAuth setup (e.g., dropbox-paper) |
|
|
97
104
|
| `okb sync status` | Show last sync times |
|
|
98
105
|
| `okb rescan` | Check indexed files for changes, re-ingest stale |
|
|
99
106
|
| `okb rescan --dry-run` | Show what would change without executing |
|
|
@@ -102,36 +109,6 @@ okb ingest ~/notes ~/docs
|
|
|
102
109
|
| `okb llm deploy` | Deploy Modal LLM for open model inference |
|
|
103
110
|
| `okb llm clear-cache` | Clear LLM response cache |
|
|
104
111
|
|
|
105
|
-
## Architecture
|
|
106
|
-
|
|
107
|
-
```
|
|
108
|
-
┌─────────────────────────────────────────────────────────────────────┐
|
|
109
|
-
│ INGESTION (Burst GPU) │
|
|
110
|
-
│ │
|
|
111
|
-
│ Local Files → Contextual Chunking → Modal (GPU T4) → pgvector │
|
|
112
|
-
│ │
|
|
113
|
-
│ ~/notes/project-x/api-design.md │
|
|
114
|
-
│ ↓ │
|
|
115
|
-
│ "Document: API Design Notes │
|
|
116
|
-
│ Project: project-x │
|
|
117
|
-
│ Section: Authentication │
|
|
118
|
-
│ Content: Use JWT tokens with..." │
|
|
119
|
-
│ ↓ │
|
|
120
|
-
│ [0.23, -0.41, 0.87, ...] → pgvector │
|
|
121
|
-
└─────────────────────────────────────────────────────────────────────┘
|
|
122
|
-
|
|
123
|
-
┌─────────────────────────────────────────────────────────────────────┐
|
|
124
|
-
│ RETRIEVAL (Always-on, Local) │
|
|
125
|
-
│ │
|
|
126
|
-
│ Claude Code → MCP Server → CPU Embedding → pgvector → Results │
|
|
127
|
-
│ │
|
|
128
|
-
│ "How do I handle auth?" │
|
|
129
|
-
│ ↓ │
|
|
130
|
-
│ [0.19, -0.38, 0.91, ...] (local CPU, ~300ms) │
|
|
131
|
-
│ ↓ │
|
|
132
|
-
│ Cosine similarity search → Top 5 chunks with context │
|
|
133
|
-
└─────────────────────────────────────────────────────────────────────┘
|
|
134
|
-
```
|
|
135
112
|
|
|
136
113
|
## Configuration
|
|
137
114
|
|
|
@@ -273,7 +250,7 @@ Then configure Claude Code to connect via SSE:
|
|
|
273
250
|
}
|
|
274
251
|
```
|
|
275
252
|
|
|
276
|
-
## MCP Tools
|
|
253
|
+
## MCP Tools available to LLM
|
|
277
254
|
|
|
278
255
|
| Tool | Purpose |
|
|
279
256
|
|------|---------|
|
|
@@ -287,6 +264,11 @@ Then configure Claude Code to connect via SSE:
|
|
|
287
264
|
| `save_knowledge` | Save knowledge from Claude for future reference |
|
|
288
265
|
| `delete_knowledge` | Delete a Claude-saved knowledge entry |
|
|
289
266
|
| `get_actionable_items` | Query tasks/events with structured filters |
|
|
267
|
+
| `get_database_info` | Get database description, topics, and stats |
|
|
268
|
+
| `set_database_description` | Update database description/topics (LLM can self-document) |
|
|
269
|
+
| `add_todo` | Create a TODO item in the knowledge base |
|
|
270
|
+
| `trigger_sync` | Sync API sources (Todoist, GitHub, Dropbox Paper) |
|
|
271
|
+
| `trigger_rescan` | Check indexed files for changes and re-ingest |
|
|
290
272
|
|
|
291
273
|
## Contextual Chunking
|
|
292
274
|
|
|
@@ -309,32 +291,6 @@ project: student-app
|
|
|
309
291
|
category: backend
|
|
310
292
|
---
|
|
311
293
|
|
|
312
|
-
# Query Optimization
|
|
313
|
-
|
|
314
|
-
Use `select_related()` for foreign keys...
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
## Cost Estimate
|
|
318
|
-
|
|
319
|
-
| Component | Local | Cloud Alternative |
|
|
320
|
-
|-----------|-------|-------------------|
|
|
321
|
-
| pgvector | $0 | ~$15-30/mo (CloudSQL) |
|
|
322
|
-
| MCP Server | $0 | ~$5/mo (small VM) |
|
|
323
|
-
| Modal embedding | ~$0.50-2/mo | N/A |
|
|
324
|
-
| **Total** | **~$1-2/mo** | **~$20-35/mo** |
|
|
325
|
-
|
|
326
|
-
## Development
|
|
327
|
-
|
|
328
|
-
```bash
|
|
329
|
-
# Install dev dependencies
|
|
330
|
-
pip install -e ".[dev]"
|
|
331
|
-
|
|
332
|
-
# Run tests
|
|
333
|
-
pytest
|
|
334
|
-
|
|
335
|
-
# Lint and format
|
|
336
|
-
ruff check . && ruff format .
|
|
337
|
-
```
|
|
338
294
|
|
|
339
295
|
## Plugin System
|
|
340
296
|
|
|
@@ -385,12 +341,30 @@ plugins:
|
|
|
385
341
|
enabled: true
|
|
386
342
|
token: ${GITHUB_TOKEN} # Resolved from environment
|
|
387
343
|
repos: [owner/repo1, owner/repo2]
|
|
344
|
+
todoist:
|
|
345
|
+
enabled: true
|
|
346
|
+
token: ${TODOIST_TOKEN}
|
|
347
|
+
include_completed: false # Sync completed tasks
|
|
348
|
+
completed_days: 30 # Days of completed history
|
|
349
|
+
include_comments: false # Include task comments (1 API call per task)
|
|
350
|
+
project_filter: [] # List of project IDs (use sync list-projects to find)
|
|
388
351
|
dropbox-paper:
|
|
389
352
|
enabled: true
|
|
390
|
-
|
|
353
|
+
# Option 1: Refresh token (recommended, auto-refreshes)
|
|
354
|
+
app_key: ${DROPBOX_APP_KEY}
|
|
355
|
+
app_secret: ${DROPBOX_APP_SECRET}
|
|
356
|
+
refresh_token: ${DROPBOX_REFRESH_TOKEN}
|
|
357
|
+
# Option 2: Access token (short-lived, expires after ~4 hours)
|
|
358
|
+
# token: ${DROPBOX_TOKEN}
|
|
391
359
|
folders: [/] # Optional: filter to specific folders
|
|
392
360
|
```
|
|
393
361
|
|
|
362
|
+
**Dropbox Paper OAuth Setup:**
|
|
363
|
+
```bash
|
|
364
|
+
okb sync auth dropbox-paper
|
|
365
|
+
```
|
|
366
|
+
This interactive command will guide you through getting a refresh token from Dropbox.
|
|
367
|
+
|
|
394
368
|
## License
|
|
395
369
|
|
|
396
370
|
MIT
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
okb/__init__.py,sha256=2yaWIYQbho7N2O2zwTn3ZH11b8b3SaoDVlxluVTqwy4,92
|
|
2
|
-
okb/cli.py,sha256=
|
|
2
|
+
okb/cli.py,sha256=y8Vr9Scy7PyAtgrCb2yIsN3kRvhwUvxpnpiF6RVV_MA,47735
|
|
3
3
|
okb/config.py,sha256=DKmX2fgteGdh0QMsA-Immu-mZcvLjHWeB8HIf9rcM5o,22898
|
|
4
4
|
okb/data/init.sql,sha256=QpsicUN7PQ7d8zyOCRNChOu5XKdUVC3xySlRDPyKSN8,2728
|
|
5
|
-
okb/http_server.py,sha256=
|
|
5
|
+
okb/http_server.py,sha256=jcpNWB1aGtcHE7h0U4gCxA4lZyqWHGgsiArv7DyPSZw,20595
|
|
6
6
|
okb/ingest.py,sha256=D5plxCC2tQXZenMNUa482dUDqsyuaq2APAQqaIgRAqU,54505
|
|
7
7
|
okb/llm/__init__.py,sha256=4jelqgXvF-eEPyLCuAmcxagN0H923wI9pBJJZKv4r0E,2368
|
|
8
8
|
okb/llm/base.py,sha256=gOm7zBiNdHrj7xxJfpb-4qZdYxWM0lA0vKfrBStO60E,2279
|
|
@@ -10,7 +10,7 @@ okb/llm/cache.py,sha256=rxRPMNBtP336MSpGWA8F7rDZnF0O2RM3rEsNtoxS0Zk,6142
|
|
|
10
10
|
okb/llm/filter.py,sha256=y20bc3vHtp5gj7T7AhsJ45ZkAkBgztj6WPjsVAmvEeo,5447
|
|
11
11
|
okb/llm/providers.py,sha256=AdVw9FFgv58-KJEfXv9JqWlkxBl-LcRWOao95CsjqWA,9718
|
|
12
12
|
okb/local_embedder.py,sha256=zzjBUFp4IH2xsvKyKjKZyX9dJuE_3PDMHMwpyRYSISQ,2098
|
|
13
|
-
okb/mcp_server.py,sha256=
|
|
13
|
+
okb/mcp_server.py,sha256=BnMxyGf524sK-8CYPyL3ZM_DEqWFsXpF7_66xj3-Ecs,59407
|
|
14
14
|
okb/migrate.py,sha256=2faYL-SHiQCkGXpTUlBFMCj0B-6JYCHqZl9u6vOlui8,1693
|
|
15
15
|
okb/migrations/0001.initial-schema.sql,sha256=0s5pj9Ad6f0u_mxODAM_-DbDI3aI37Wdu5XMPAzAIqw,2577
|
|
16
16
|
okb/migrations/0002.sync-state.sql,sha256=w34dOA9sPg60NMS1aHvOhORff1k_Di9cO2ghwVQSPHU,696
|
|
@@ -23,14 +23,15 @@ okb/modal_llm.py,sha256=4rYE3VZ_T09HXCgTIYFLu1s_C2FRC9y4dgMUGqJuO2M,5368
|
|
|
23
23
|
okb/plugins/__init__.py,sha256=50LNAH4bvfIw5CHT82sknGjdCldQ-4ds0wxo1zM9E2k,324
|
|
24
24
|
okb/plugins/base.py,sha256=6TIN1UIItmuIsP4NDJhuRMH0ngKkQiGmtHTeYj1K8OU,3171
|
|
25
25
|
okb/plugins/registry.py,sha256=fN7NfoOaRnMyXSWT2srd6vEr4riJjmncQFfberf0IE8,3741
|
|
26
|
-
okb/plugins/sources/__init__.py,sha256=
|
|
27
|
-
okb/plugins/sources/dropbox_paper.py,sha256=
|
|
26
|
+
okb/plugins/sources/__init__.py,sha256=n58rAbcJC45JbofUY6IA526rSRjkYn4_tGjWma3TOUI,214
|
|
27
|
+
okb/plugins/sources/dropbox_paper.py,sha256=Oi59NbJGQrwjE2Xhcinc2InKRc27Gdg7l8xVTbKLkI8,7493
|
|
28
28
|
okb/plugins/sources/github.py,sha256=ozdTZPkU8h2-ZIx5o1FB58QBZ6P0eoVntluWL3vG87I,16309
|
|
29
|
+
okb/plugins/sources/todoist.py,sha256=B22tKYFZhuDhZHhpRdGWDGho9y7FBNgGlI1g2nf13-8,8849
|
|
29
30
|
okb/rescan.py,sha256=dVdQEkVUjsrtOKAGZc0LC2uwcnkjB8hn2SOVWHnY-R8,8396
|
|
30
31
|
okb/scripts/__init__.py,sha256=HPp8YCtIeo9XMOtOGCtntiwYr9eCxAJ1MF9Lo9WVzUA,53
|
|
31
32
|
okb/scripts/watch.py,sha256=b8oGPTN3flNdNQJETeqQ1RNZ8U1LiKvHntLwvHRIviA,6354
|
|
32
33
|
okb/tokens.py,sha256=JJ1C-mvtnT2O0cmjSu57PI9Nt53Sl9DqbmPuLnHlN6g,8043
|
|
33
|
-
okb-1.
|
|
34
|
-
okb-1.
|
|
35
|
-
okb-1.
|
|
36
|
-
okb-1.
|
|
34
|
+
okb-1.1.0a0.dist-info/METADATA,sha256=IhNkQv-lucqtYIaXcNfxkKFkSD5Avo3Vy5buDbXHELo,10578
|
|
35
|
+
okb-1.1.0a0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
36
|
+
okb-1.1.0a0.dist-info/entry_points.txt,sha256=YX6b8BlV9sSAXrneoIm3dkXtRcgHhSzbDaOpJ0yCKRs,230
|
|
37
|
+
okb-1.1.0a0.dist-info/RECORD,,
|
|
File without changes
|