docusense-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of docusense-mcp might be problematic. Click here for more details.

Files changed (49) hide show
  1. docusense/__init__.py +1 -0
  2. docusense/api.py +107 -0
  3. docusense/cli.py +430 -0
  4. docusense/config.py +80 -0
  5. docusense/context/__init__.py +0 -0
  6. docusense/context/assemble.py +105 -0
  7. docusense/db/__init__.py +0 -0
  8. docusense/db/conn.py +276 -0
  9. docusense/db/schema.sql +160 -0
  10. docusense/db/schema_sqlite.sql +145 -0
  11. docusense/db/sql.py +9 -0
  12. docusense/extract/__init__.py +0 -0
  13. docusense/extract/claims.py +223 -0
  14. docusense/extract/definitions.py +70 -0
  15. docusense/extract/llm_fallback.py +64 -0
  16. docusense/extract/references.py +126 -0
  17. docusense/extract/run.py +222 -0
  18. docusense/extract/sentences.py +53 -0
  19. docusense/index/__init__.py +0 -0
  20. docusense/index/bm25.py +56 -0
  21. docusense/index/embed.py +163 -0
  22. docusense/ingest/__init__.py +0 -0
  23. docusense/ingest/add.py +100 -0
  24. docusense/ingest/convert.py +309 -0
  25. docusense/ingest/md_parser.py +56 -0
  26. docusense/ingest/model.py +65 -0
  27. docusense/ingest/normalize.py +18 -0
  28. docusense/ingest/pdf_parser.py +344 -0
  29. docusense/ingest/pipeline.py +138 -0
  30. docusense/ingest/render.py +42 -0
  31. docusense/ingest/sections.py +98 -0
  32. docusense/jobs.py +115 -0
  33. docusense/llm.py +134 -0
  34. docusense/mcp_server.py +358 -0
  35. docusense/provenance.py +111 -0
  36. docusense/py.typed +0 -0
  37. docusense/savings.py +87 -0
  38. docusense/services.py +575 -0
  39. docusense/store/__init__.py +0 -0
  40. docusense/store/blobs.py +36 -0
  41. docusense/versions/__init__.py +0 -0
  42. docusense/versions/blame.py +68 -0
  43. docusense/versions/conflicts.py +165 -0
  44. docusense/versions/diff.py +194 -0
  45. docusense_mcp-0.1.0.dist-info/METADATA +143 -0
  46. docusense_mcp-0.1.0.dist-info/RECORD +49 -0
  47. docusense_mcp-0.1.0.dist-info/WHEEL +4 -0
  48. docusense_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  49. docusense_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
docusense/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """DocuSense: a verifiable and version-native document layer for AI agents."""
docusense/api.py ADDED
@@ -0,0 +1,107 @@
1
+ """REST API for the viewer. Thin wrapper over services.DocuSense.
2
+
3
+ Run: uv run docusense serve-api (http://localhost:8000, docs at /docs)
4
+ """
5
+
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI, HTTPException, Query, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from docusense.db.conn import ConnectionPool
13
+ from docusense.services import DocuSense, NotFound
14
+
15
+ pool: ConnectionPool | None = None
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ global pool
21
+ pool = ConnectionPool()
22
+ yield
23
+ pool.close()
24
+
25
+
26
+ app = FastAPI(title="DocuSense API", version="0.1.0", lifespan=lifespan)
27
+ app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:3000"], allow_methods=["GET"], allow_headers=["*"])
28
+
29
+
30
+ @app.exception_handler(NotFound)
31
+ async def not_found(_: Request, exc: NotFound):
32
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
33
+
34
+
35
+ @app.exception_handler(ValueError)
36
+ async def bad_request(_: Request, exc: ValueError):
37
+ return JSONResponse(status_code=400, content={"detail": str(exc)})
38
+
39
+
40
+ def run(fn, *args, **kwargs):
41
+ with pool.connection() as conn:
42
+ return fn(DocuSense(conn), *args, **kwargs)
43
+
44
+
45
+ # Sync endpoints run in FastAPI's thread pool, so blocking DB calls don't stall the event loop.
46
+
47
+ @app.get("/api/health")
48
+ def health():
49
+ return run(lambda ds: {"ok": True, "versions": ds.conn.execute("SELECT count(*) AS n FROM versions").fetchone()["n"]})
50
+
51
+
52
+ @app.get("/api/documents")
53
+ def documents():
54
+ return run(lambda ds: ds.list_documents())
55
+
56
+
57
+ @app.get("/api/documents/{doc}/versions")
58
+ def versions(doc: str):
59
+ return run(lambda ds: ds.list_versions(doc))
60
+
61
+
62
+ @app.get("/api/documents/{doc}/affected")
63
+ def affected(doc: str):
64
+ return run(lambda ds: ds.find_affected_documents(doc, max_tokens=20000))
65
+
66
+
67
+ @app.get("/api/versions/{version_id}")
68
+ def version_document(version_id: str):
69
+ return run(lambda ds: ds.version_document(version_id))
70
+
71
+
72
+ @app.get("/api/spans/{span_id}")
73
+ def span(span_id: int):
74
+ return run(lambda ds: ds.get_evidence(span_id))
75
+
76
+
77
+ @app.get("/api/spans/{span_id}/verify")
78
+ def verify(span_id: int, deep: bool = False):
79
+ return run(lambda ds: ds.verify_span(span_id, deep))
80
+
81
+
82
+ @app.get("/api/spans/{span_id}/blame")
83
+ def blame(span_id: int):
84
+ return run(lambda ds: ds.blame(span_id))
85
+
86
+
87
+ @app.get("/api/spans/{span_id}/references")
88
+ def references(span_id: int):
89
+ return run(lambda ds: ds.resolve_reference(span_id=span_id, max_tokens=20000))
90
+
91
+
92
+ @app.get("/api/diff")
93
+ def diff(doc: str, from_version: str | None = Query(None, alias="from"),
94
+ to_version: str | None = Query(None, alias="to"), level: str = "section"):
95
+ return run(lambda ds: ds.diff_versions(doc, from_version, to_version, level, max_tokens=200000))
96
+
97
+
98
+ @app.get("/api/search")
99
+ def search(q: str, mode: str = "hybrid", doc: str | None = None, k: int = 10):
100
+ if not q.strip():
101
+ raise HTTPException(400, "query is empty")
102
+ return run(lambda ds: ds.search(q, mode, doc, k, max_tokens=20000))
103
+
104
+
105
+ @app.get("/api/contradictions")
106
+ def contradictions(doc: str | None = None, history: bool = False, rejected: bool = False, k: int = 50):
107
+ return run(lambda ds: ds.find_contradictions(doc, history, rejected, k, max_tokens=200000))
docusense/cli.py ADDED
@@ -0,0 +1,430 @@
1
+ """DocuSense command line.
2
+
3
+ Everyday use: `docusense add <files or folders>`, `docusense list`, `docusense serve-mcp`, `docusense info`.
4
+ Corpus / dev: `docusense ingest <manifest>`, `extract`, `index`, `conflicts`, `verify`, `stats`, `db init|reset`.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from docusense.db.conn import connect, init_schema, reset_schema
16
+ from docusense.ingest.pipeline import ingest_version
17
+ from docusense.provenance import Verifier
18
+
19
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
20
+ db_app = typer.Typer(no_args_is_help=True)
21
+ app.add_typer(db_app, name="db", help="Database schema management.")
22
+ console = Console()
23
+
24
+
25
+ @db_app.command("init")
26
+ def db_init() -> None:
27
+ """Create tables (idempotent)."""
28
+ with connect() as conn:
29
+ init_schema(conn)
30
+ console.print("[green]schema ready[/]")
31
+
32
+
33
+ @db_app.command("reset")
34
+ def db_reset(yes: bool = typer.Option(False, "--yes", help="Skip confirmation.")) -> None:
35
+ """Drop ALL data and recreate the schema (dev only)."""
36
+ if not yes:
37
+ typer.confirm("This deletes every ingested version. Continue?", abort=True)
38
+ with connect() as conn:
39
+ reset_schema(conn)
40
+ console.print("[yellow]schema reset[/]")
41
+
42
+
43
+ @app.command()
44
+ def add(
45
+ paths: list[Path] = typer.Argument(..., help="Files (.pdf, .docx, .html, .md, .txt) or folders to add."),
46
+ doc: str | None = typer.Option(None, "--doc", help="Document id (default: derived from the file name). Single file only."),
47
+ title: str | None = typer.Option(None, help="Document title for a new document."),
48
+ label: str | None = typer.Option(None, help="Version label (default v1, v2, ...). Single file only."),
49
+ index: bool = typer.Option(True, help="Update search indexes afterwards (downloads the embedding model on first use)."),
50
+ ) -> None:
51
+ """Add documents. Re-adding a changed file appends a new version; unchanged files are skipped."""
52
+ from docusense.ingest.add import add_file, collect_files, refresh_derived
53
+
54
+ files = collect_files(paths)
55
+ if not files:
56
+ console.print("[yellow]no supported files found[/]")
57
+ raise typer.Exit(1)
58
+ if len(files) > 1 and (doc or label):
59
+ console.print("[red]--doc/--label apply to a single file[/]")
60
+ raise typer.Exit(2)
61
+ table = Table("file", "version", "status", "pages", "sections", "spans", "note")
62
+ added = 0
63
+ with connect() as conn:
64
+ init_schema(conn)
65
+ for f in files:
66
+ try:
67
+ r = add_file(conn, f, doc=doc, title=title, label=label)
68
+ except ValueError as e:
69
+ table.add_row(f.name, "", "[red]error[/]", "", "", "", str(e))
70
+ continue
71
+ added += r.status == "ingested"
72
+ table.add_row(f.name, r.version_id, r.status, str(r.pages or ""), str(r.sections or ""),
73
+ str(r.spans or ""), r.note)
74
+ console.print(table)
75
+ if added:
76
+ with console.status("extracting claims, references and building indexes…"):
77
+ stats = refresh_derived(conn, index=index)
78
+ console.print(f"[green]{added} new version(s)[/] · {stats}")
79
+
80
+
81
+ @app.command("list")
82
+ def list_cmd() -> None:
83
+ """List documents and their versions."""
84
+ from docusense.services import DocuSense
85
+
86
+ with connect() as conn:
87
+ init_schema(conn)
88
+ docs = DocuSense(conn).list_documents()["documents"]
89
+ if not docs:
90
+ console.print("no documents yet — add some with [bold]docusense add <file or folder>[/]")
91
+ return
92
+ table = Table("document", "title", "versions (oldest → newest)")
93
+ for d in docs:
94
+ table.add_row(d["document"], d["title"], ", ".join(v["label"] + ("*" if v["is_latest"] else "") for v in d["versions"]))
95
+ console.print(table)
96
+
97
+
98
+ @app.command()
99
+ def info() -> None:
100
+ """Show where DocuSense keeps its data and which backend it uses."""
101
+ from docusense.config import get_settings
102
+
103
+ s = get_settings()
104
+ url = s.resolved_database_url
105
+ with connect() as conn:
106
+ init_schema(conn)
107
+ n_docs = conn.execute("SELECT count(*) AS n FROM documents").fetchone()["n"]
108
+ n_versions = conn.execute("SELECT count(*) AS n FROM versions").fetchone()["n"]
109
+ backend = conn.dialect
110
+ shown = url if backend == "sqlite" else url.split("@")[-1] # never print credentials
111
+ console.print(f"home: {s.home}\nbackend: {backend} ({shown})\nstore: {s.resolved_store_dir}\n"
112
+ f"documents: {n_docs} ({n_versions} versions)\nembeddings: {s.embed_model}")
113
+
114
+
115
+ @app.command()
116
+ def ingest(
117
+ manifest: Path = typer.Argument(Path("corpus/manifest.yaml")),
118
+ extract: bool = typer.Option(True, help="Run reference/definition/claim extraction afterwards."),
119
+ index: bool = typer.Option(True, help="Build BM25 + embedding indexes afterwards."),
120
+ llm_fallback: bool = typer.Option(False, help="Use the bounded LLM fallback during extraction (see DOCUSENSE_LLM_PROVIDER)."),
121
+ ) -> None:
122
+ """Ingest every version in a manifest (already-ingested versions are skipped), then extract + index."""
123
+ spec = yaml.safe_load(manifest.read_text())
124
+ base = manifest.parent
125
+ table = Table("version", "status", "layout", "pages", "sections", "spans")
126
+ with connect() as conn:
127
+ init_schema(conn)
128
+ for doc in spec["documents"]:
129
+ for seq, v in enumerate(doc["versions"], start=1):
130
+ r = ingest_version(
131
+ conn,
132
+ document_id=doc["id"], title=doc["title"], family=doc["family"],
133
+ label=v["label"], seq=seq, path=base / v["file"], origin=v.get("origin"),
134
+ )
135
+ table.add_row(r.version_id, r.status, r.layout, str(r.pages or ""),
136
+ str(r.sections or ""), str(r.spans or ""))
137
+ console.print(table)
138
+ if extract:
139
+ extract_cmd(version=None, llm_fallback=llm_fallback)
140
+ conflicts_cmd(llm_judge=llm_fallback, max_judge_calls=300)
141
+ if index:
142
+ index_cmd()
143
+
144
+
145
+ @app.command("extract")
146
+ def extract_cmd(
147
+ version: str | None = typer.Option(None, help="Limit to one version id."),
148
+ llm_fallback: bool = typer.Option(False, help="Use the bounded LLM fallback for unresolved subjects."),
149
+ ) -> None:
150
+ """Deterministic extraction: cross-references, definitions, claims (re-runnable)."""
151
+ from docusense.extract.run import extract_all
152
+
153
+ table = Table("version", "refs resolved", "external", "unresolved", "definitions", "claims", "llm calls")
154
+ with connect() as conn:
155
+ for s in extract_all(conn, llm_fallback=llm_fallback, version_id=version):
156
+ table.add_row(s.version_id, str(s.refs["resolved"]), str(s.refs["external"]),
157
+ str(s.refs["unresolved"]), str(s.definitions), str(sum(s.claims.values())),
158
+ str(s.llm_calls))
159
+ console.print(table)
160
+
161
+
162
+ @app.command("index")
163
+ def index_cmd() -> None:
164
+ """Build the BM25 index and embed any spans that have no embedding yet."""
165
+ from docusense.index import bm25, embed
166
+
167
+ with connect() as conn:
168
+ console.print(f"bm25: indexed {bm25.build(conn)} spans")
169
+ n = embed.build(conn, progress=lambda done, total: console.print(f" embedded {done}/{total}"))
170
+ console.print(f"embeddings: {n} new")
171
+
172
+
173
+ @app.command("search")
174
+ def search_cmd(
175
+ query: str,
176
+ mode: str = typer.Option("hybrid", help="lexical | semantic | hybrid"),
177
+ doc: str | None = typer.Option(None),
178
+ k: int = typer.Option(5),
179
+ ) -> None:
180
+ """Search spans from the command line."""
181
+ from docusense.services import DocuSense
182
+
183
+ with connect() as conn:
184
+ for rank, r in enumerate(DocuSense(conn).search(query, mode, doc, k)["results"], 1):
185
+ console.print(f"[dim]{rank}.[/] [bold]{r['citation']}[/] [dim]p.{r['page']}[/]")
186
+ console.print(" " + " ".join(r["text"].split())[:220])
187
+
188
+
189
+ @app.command("define")
190
+ def define_cmd(term: str, doc: str | None = typer.Option(None)) -> None:
191
+ """Look up where a term is defined."""
192
+ from docusense.services import DocuSense
193
+
194
+ with connect() as conn:
195
+ for r in DocuSense(conn).lookup_definition(term, doc)["results"]:
196
+ console.print(f"[bold]{r['term']}[/] {r['citation']} [dim]{r['method']}[/]")
197
+ console.print(" " + " ".join(r["text"].split())[:220])
198
+
199
+
200
+ @app.command("conflicts")
201
+ def conflicts_cmd(
202
+ llm_judge: bool = typer.Option(False, help="Confirm candidates with the bounded LLM judge."),
203
+ max_judge_calls: int = typer.Option(300),
204
+ ) -> None:
205
+ """Recompute cross-document contradiction candidates."""
206
+ from docusense.versions.conflicts import detect
207
+
208
+ with connect() as conn:
209
+ console.print(detect(conn, llm_judge=llm_judge, max_judge_calls=max_judge_calls))
210
+
211
+
212
+ @app.command("serve-api")
213
+ def serve_api(port: int = typer.Option(8000), reload: bool = typer.Option(False)) -> None:
214
+ """Run the REST API for the viewer."""
215
+ import uvicorn
216
+
217
+ uvicorn.run("docusense.api:app", host="127.0.0.1", port=port, reload=reload)
218
+
219
+
220
+ @app.command("serve-mcp")
221
+ def serve_mcp(
222
+ http: bool = typer.Option(False, "--http", help="Serve Streamable HTTP instead of stdio."),
223
+ host: str = typer.Option("127.0.0.1", help="HTTP bind address."),
224
+ port: int = typer.Option(8765, help="HTTP port."),
225
+ path: str = typer.Option("/mcp", help="HTTP endpoint path."),
226
+ ) -> None:
227
+ """Run the MCP server: stdio by default (Claude Desktop/Code, Cursor, VS Code), or --http."""
228
+ if not http:
229
+ from docusense.mcp_server import main
230
+
231
+ main()
232
+ return
233
+
234
+ import uvicorn
235
+
236
+ from docusense.config import get_settings
237
+ from docusense.mcp_server import http_app
238
+
239
+ loopback = host in ("127.0.0.1", "localhost", "::1")
240
+ if not loopback and not get_settings().http_token:
241
+ console.print("[red]refusing to listen on a non-loopback address without DOCUSENSE_HTTP_TOKEN set[/]")
242
+ raise typer.Exit(2)
243
+ err = Console(stderr=True)
244
+ err.print(f"DocuSense MCP (Streamable HTTP) on http://{host}:{port}{path}"
245
+ + (" · bearer token required" if get_settings().http_token else ""))
246
+ uvicorn.run(http_app(host=host, path=path), host=host, port=port, log_level="warning")
247
+
248
+
249
+ @app.command()
250
+ def verify(
251
+ span: int | None = typer.Option(None, help="Verify one span id."),
252
+ version: str | None = typer.Option(None, help="Limit to one version id, e.g. smtp@rfc5321."),
253
+ deep: bool = typer.Option(False, help="Also re-parse the source file (determinism check)."),
254
+ ) -> None:
255
+ """Re-hash spans against the content-addressed canonical text."""
256
+ with connect() as conn:
257
+ verifier = Verifier(conn)
258
+ if span is not None:
259
+ console.print_json(json.dumps(verifier.verify_span(span, deep=deep)))
260
+ return
261
+ report = verifier.verify_all(deep=deep, version_id=version)
262
+
263
+ table = Table("version", "spans", "verified", "failed span ids")
264
+ for vid, s in sorted(report["versions"].items()):
265
+ ok = s["verified"] == s["total"]
266
+ table.add_row(vid, str(s["total"]), f"[{'green' if ok else 'red'}]{s['verified']}[/]",
267
+ ", ".join(map(str, s["failed"][:8])) + (" …" if len(s["failed"]) > 8 else ""))
268
+ console.print(table)
269
+ pct = 100 * report["verified"] / report["total"] if report["total"] else 0
270
+ console.print(f"[bold]{report['verified']}/{report['total']} spans verified ({pct:.1f}%)[/]")
271
+ if report["verified"] != report["total"]:
272
+ raise typer.Exit(1)
273
+
274
+
275
+ @app.command()
276
+ def sections(version: str = typer.Argument(..., help="Version id, e.g. smtp@rfc5321.")) -> None:
277
+ """Print the section map of a version."""
278
+ with connect() as conn:
279
+ rows = conn.execute(
280
+ """SELECT number, title, level, page_start, page_end, start_byte, end_byte
281
+ FROM sections WHERE version_id = %s ORDER BY ordinal""", (version,)
282
+ ).fetchall()
283
+ if not rows:
284
+ console.print(f"[red]no sections for {version}[/]")
285
+ raise typer.Exit(1)
286
+ for r in rows:
287
+ indent = " " * max(r["level"] - 1, 0)
288
+ console.print(f"{indent}{r['number'] or '·'} {r['title']} "
289
+ f"[dim]p{r['page_start']}-{r['page_end']} b{r['start_byte']}-{r['end_byte']}[/]")
290
+
291
+
292
+ @app.command()
293
+ def stats() -> None:
294
+ """Corpus overview."""
295
+ with connect() as conn:
296
+ rows = conn.execute(
297
+ """SELECT v.id, v.format, v.seq, jsonb_array_length(v.page_map) AS pages,
298
+ (SELECT count(*) FROM sections s WHERE s.version_id = v.id) AS sections,
299
+ (SELECT count(*) FROM spans p WHERE p.version_id = v.id AND p.kind <> 'sentence') AS spans,
300
+ (SELECT count(*) FROM refs r WHERE r.version_id = v.id) AS refs,
301
+ (SELECT count(*) FROM definitions d WHERE d.version_id = v.id) AS defs,
302
+ (SELECT count(*) FROM claims c WHERE c.version_id = v.id) AS claims
303
+ FROM versions v ORDER BY v.document_id, v.seq"""
304
+ ).fetchall()
305
+ table = Table("version", "format", "seq", "pages", "sections", "spans", "refs", "defs", "claims")
306
+ for r in rows:
307
+ table.add_row(r["id"], r["format"], str(r["seq"]), str(r["pages"]), str(r["sections"]),
308
+ str(r["spans"]), str(r["refs"]), str(r["defs"]), str(r["claims"]))
309
+ console.print(table)
310
+
311
+
312
+ @app.command()
313
+ def savings(
314
+ days: int = typer.Option(0, help="Only count the last N days (0 = all time)."),
315
+ as_json: bool = typer.Option(False, "--json", help="Machine-readable output."),
316
+ ) -> None:
317
+ """Tokens your agents read through DocuSense vs. pasting the same documents into context."""
318
+ import time
319
+
320
+ from docusense import savings as ledger
321
+
322
+ with connect() as conn:
323
+ init_schema(conn)
324
+ version_tokens = {r["id"]: r["text_bytes"] // 4
325
+ for r in conn.execute("SELECT id, text_bytes FROM versions").fetchall()}
326
+ s = ledger.summarize(version_tokens, since=time.time() - days * 86400 if days else None)
327
+ if as_json:
328
+ console.print_json(json.dumps(s))
329
+ return
330
+ if not s["calls"]:
331
+ console.print(f"No agent activity yet. Connect an agent ([bold]docusense setup[/]) and ask it about your documents.\n"
332
+ f"Ledger: {ledger.ledger_path()}")
333
+ return
334
+ console.print(f"[bold]{s['sessions']}[/] agent sessions · [bold]{s['calls']}[/] tool calls\n"
335
+ f"read via DocuSense: [bold]{s['returned']:>12,}[/] tokens\n"
336
+ f"pasting the docs: [bold]{s['paste']:>12,}[/] tokens")
337
+ if s["returned"] < s["paste"]:
338
+ console.print(f"[green]saved: {s['saved']:>12,} tokens ({s['ratio']}x less context)[/]")
339
+ else:
340
+ console.print("[yellow]no saving: these documents are small enough to paste directly. "
341
+ "DocuSense pays off on long or many documents.[/]")
342
+ top = ", ".join(f"{t} ×{n}" for t, n in list(s["tools"].items())[:6])
343
+ console.print(f"[dim]most used: {top}\nbaseline = each document version an agent touched, pasted once per session "
344
+ f"(chars/4). Ledger: {ledger.ledger_path()} · disable with DOCUSENSE_SAVINGS_LOG=0[/]")
345
+
346
+
347
+ def _server_command() -> list[str]:
348
+ """Absolute command for the MCP server: GUI clients (Claude Desktop, Cursor) don't inherit your shell PATH."""
349
+ import shutil
350
+ import sys
351
+
352
+ exe = shutil.which("docusense")
353
+ if exe:
354
+ return [str(Path(exe).resolve()), "serve-mcp"]
355
+ uvx = shutil.which("uvx")
356
+ if uvx:
357
+ return [uvx, "--from", "docusense-mcp", "docusense", "serve-mcp"]
358
+ return [sys.executable, "-m", "docusense.cli", "serve-mcp"]
359
+
360
+
361
+ def _desktop_config() -> Path:
362
+ import os
363
+ import sys
364
+
365
+ if sys.platform == "darwin":
366
+ return Path.home() / "Library/Application Support/Claude/claude_desktop_config.json"
367
+ if sys.platform == "win32":
368
+ return Path(os.environ.get("APPDATA", Path.home())) / "Claude/claude_desktop_config.json"
369
+ return Path.home() / ".config/Claude/claude_desktop_config.json"
370
+
371
+
372
+ def _merge_mcp_json(path: Path, cmd: list[str]) -> str:
373
+ data = {}
374
+ if path.exists():
375
+ text = path.read_text(encoding="utf-8").strip()
376
+ data = json.loads(text) if text else {}
377
+ path.with_suffix(path.suffix + ".bak").write_text(text, encoding="utf-8")
378
+ entry = {"command": cmd[0], "args": cmd[1:]}
379
+ if data.get("mcpServers", {}).get("docusense") == entry:
380
+ return f"already configured in {path}"
381
+ data.setdefault("mcpServers", {})["docusense"] = entry
382
+ path.parent.mkdir(parents=True, exist_ok=True)
383
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
384
+ return f"added to {path} (backup: {path.name}.bak) — restart the app"
385
+
386
+
387
+ @app.command()
388
+ def setup(
389
+ client: str = typer.Argument("claude-code", help="claude-code | claude-desktop | cursor | print"),
390
+ ) -> None:
391
+ """Register the DocuSense MCP server with an agent client."""
392
+ import shutil
393
+ import subprocess
394
+
395
+ cmd = _server_command()
396
+ client = client.lower()
397
+ if client == "claude-code":
398
+ claude = shutil.which("claude")
399
+ if not claude:
400
+ raise RuntimeError("`claude` CLI not found. Install Claude Code, or use: docusense setup print")
401
+ done = subprocess.run([claude, "mcp", "add", "--scope", "user", "docusense", "--", *cmd],
402
+ capture_output=True, text=True)
403
+ out = (done.stdout + done.stderr).strip()
404
+ if done.returncode and "already exists" not in out:
405
+ raise RuntimeError(out or "claude mcp add failed")
406
+ console.print(out or "added", "\n[green]Claude Code[/]: run /mcp in a session to confirm.")
407
+ elif client == "claude-desktop":
408
+ console.print(_merge_mcp_json(_desktop_config(), cmd))
409
+ elif client == "cursor":
410
+ console.print(_merge_mcp_json(Path.home() / ".cursor/mcp.json", cmd))
411
+ elif client == "print":
412
+ console.print_json(json.dumps({"mcpServers": {"docusense": {"command": cmd[0], "args": cmd[1:]}}}))
413
+ else:
414
+ raise ValueError(f"unknown client {client!r}: use claude-code, claude-desktop, cursor or print")
415
+ console.print("[dim]Next: `docusense add <folder>` your docs, ask your agent about them, then `docusense savings`.[/]")
416
+
417
+
418
+ if __name__ == "__main__":
419
+ app()
420
+
421
+
422
+ def main() -> None:
423
+ """Console entry point: expected errors print one line instead of a traceback."""
424
+ import sys
425
+
426
+ try:
427
+ app()
428
+ except (ValueError, LookupError, OSError, RuntimeError) as e:
429
+ Console(stderr=True).print(f"[red]error:[/] {e}")
430
+ sys.exit(1)
docusense/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Settings.
2
+
3
+ Where things live:
4
+ - DOCUSENSE_HOME (default ~/.docusense) holds the local SQLite database, blobs and indexes.
5
+ - Settings are DOCUSENSE_* variables, read from the environment, then ./.env, then $DOCUSENSE_HOME/.env.
6
+ They are namespaced so a project's own DATABASE_URL never leaks in when an MCP client launches us.
7
+ - DOCUSENSE_DATABASE_URL selects the backend: unset or sqlite:///path → embedded SQLite (no server);
8
+ postgresql://… → Postgres + pgvector (install the `postgres` extra).
9
+ - API keys also accept their conventional names (GROQ_API_KEY, OPENROUTER_API_KEY).
10
+ """
11
+
12
+ import os
13
+ from functools import lru_cache
14
+ from pathlib import Path
15
+
16
+ from pydantic import AliasChoices, Field
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+ PARSER_VERSION = "docusense-parser/0.2.0" # 0.2: multi-column PDFs, DOCX/HTML (single-column output unchanged)
20
+
21
+
22
+ def docusense_home() -> Path:
23
+ return Path(os.environ.get("DOCUSENSE_HOME") or Path.home() / ".docusense").expanduser()
24
+
25
+
26
+ class Settings(BaseSettings):
27
+ # Later files win, so a project-local .env overrides the per-user one.
28
+ model_config = SettingsConfigDict(env_prefix="DOCUSENSE_", env_file=(docusense_home() / ".env", ".env"),
29
+ extra="ignore", populate_by_name=True)
30
+
31
+ database_url: str = ""
32
+ store_dir: Path = Path("")
33
+
34
+ embed_model: str = "BAAI/bge-small-en-v1.5"
35
+ groq_api_key: str = Field("", validation_alias=AliasChoices("DOCUSENSE_GROQ_API_KEY", "GROQ_API_KEY"))
36
+ extract_model: str = "openai/gpt-oss-120b"
37
+ # LLM provider for the bounded fallbacks (see docusense/llm.py)
38
+ llm_provider: str = "auto" # auto | groq | anthropic | openai | ollama | openai-compatible | none
39
+ llm_model: str = ""
40
+ llm_base_url: str = ""
41
+ llm_api_key: str = ""
42
+ anthropic_api_key: str = Field("", validation_alias=AliasChoices("DOCUSENSE_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"))
43
+ openai_api_key: str = Field("", validation_alias=AliasChoices("DOCUSENSE_OPENAI_API_KEY", "OPENAI_API_KEY"))
44
+ openrouter_api_key: str = Field("", validation_alias=AliasChoices("DOCUSENSE_OPENROUTER_API_KEY", "OPENROUTER_API_KEY"))
45
+ agent_provider: str = "openrouter"
46
+ agent_model: str = ""
47
+ agent_max_tool_calls: int = 15
48
+ agent_max_tokens: int = 60000
49
+
50
+ # MCP server
51
+ tools: str = "all" # all | core (core = smaller tool list for tight agent contexts)
52
+ add_roots: str = "" # comma-separated folders add_document may read (default: home dir)
53
+ http_token: str = "" # bearer token required by `serve-mcp --http` when set
54
+
55
+ @property
56
+ def home(self) -> Path:
57
+ return docusense_home()
58
+
59
+ @property
60
+ def resolved_database_url(self) -> str:
61
+ return self.database_url or f"sqlite:///{self.home / 'docusense.db'}"
62
+
63
+ @property
64
+ def resolved_store_dir(self) -> Path:
65
+ if str(self.store_dir) in ("", "."):
66
+ return self.home / "store"
67
+ return self.store_dir.expanduser().resolve()
68
+
69
+ @property
70
+ def blob_dir(self) -> Path:
71
+ return self.resolved_store_dir / "blobs"
72
+
73
+ @property
74
+ def index_dir(self) -> Path:
75
+ return self.resolved_store_dir / "index"
76
+
77
+
78
+ @lru_cache
79
+ def get_settings() -> Settings:
80
+ return Settings()
File without changes