dowse-context 0.2.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.
dowse/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Local code Context Engine: tree-sitter extraction + zvec hybrid retrieval."""
2
+
3
+ __version__ = "0.1.0"
dowse/_dist.py ADDED
@@ -0,0 +1,13 @@
1
+ """PyPI distribution name (import package remains ``dowse``)."""
2
+ from __future__ import annotations
3
+
4
+ _FALLBACK = "dowse-context"
5
+
6
+
7
+ def distribution_name() -> str:
8
+ """Wheel/sdist name on PyPI (e.g. dowse-context), not the import path."""
9
+ return _FALLBACK
10
+
11
+
12
+ def pip_extra_hint(extra: str) -> str:
13
+ return f'pip install "{distribution_name()}[{extra}]"'
dowse/cli.py ADDED
@@ -0,0 +1,267 @@
1
+ """dowse: a fluff-free code Context Engine.
2
+
3
+ Commands:
4
+ index walk a directory, extract function/class symbols, embed, store in zvec
5
+ query embed a natural-language string / error, hybrid-search, emit JSON
6
+ status report index health (exists, stale, missing grammars)
7
+ doctor install + index + lock + harness diagnostics as JSON
8
+ init one-command bootstrap: MCP config, gitignore, coverage, index
9
+ hook install Cursor sessionStart auto-index (opt-in)
10
+ serve expose index/query as MCP tools over stdio for a coding harness
11
+
12
+ Design rule: stdout carries ONLY machine-readable JSON. All human/progress
13
+ output goes to stderr, so `dowse query ... | jq` always works.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ import typer
24
+
25
+ from .embed import DEFAULT_MODEL
26
+ from . import cursor_hooks
27
+ from . import service
28
+ from .server_lock import ServerLockHeld, acquire_server_lock
29
+ from .store import LockedIndexError, Store
30
+
31
+ app = typer.Typer(add_completion=False, help="Local code Context Engine (tree-sitter + zvec).")
32
+ hook_app = typer.Typer(help="Opt-in Cursor session hooks for incremental indexing.")
33
+ app.add_typer(hook_app, name="hook")
34
+
35
+
36
+ class InitHarness(str, Enum):
37
+ PI = "pi"
38
+
39
+
40
+ def _err(msg: str) -> None:
41
+ print(msg, file=sys.stderr, flush=True)
42
+
43
+
44
+ def _emit(payload) -> None:
45
+ """Write a JSON payload to stdout (and nothing else)."""
46
+ json.dump(payload, sys.stdout, indent=2)
47
+ sys.stdout.write("\n")
48
+ sys.stdout.flush()
49
+
50
+
51
+ def _locked_index_exit(exc: LockedIndexError) -> None:
52
+ _err(
53
+ f"[dowse] index is already open: {exc.path}\n"
54
+ "[dowse] Another dowse/zvec process is using this collection. "
55
+ "Wait for any indexing job to finish, stop the competing process, or use "
56
+ "one long-lived `dowse serve` MCP server instead of competing servers."
57
+ )
58
+ raise typer.Exit(code=1) from None
59
+
60
+
61
+ def _server_lock_exit(exc: ServerLockHeld, db: Path) -> None:
62
+ holder = f" (pid {exc.holder_pid})" if exc.holder_pid else ""
63
+ _err(
64
+ f"[serve] another dowse serve is already running for {db}{holder}\n"
65
+ f"[serve] lock file: {exc.lock_path}"
66
+ )
67
+ raise typer.Exit(code=1) from None
68
+
69
+
70
+ def _probe_serve_index(db: Path) -> None:
71
+ """Fail fast if an existing index is currently held by an active writer."""
72
+ if not db.exists() or not any(db.iterdir()):
73
+ return
74
+ try:
75
+ store = Store.open_readonly(db)
76
+ del store
77
+ except LockedIndexError as exc:
78
+ _locked_index_exit(exc)
79
+
80
+
81
+ @app.command()
82
+ def index(
83
+ path: Path = typer.Argument(..., exists=True, file_okay=False, help="Directory to index."),
84
+ db: Path = typer.Option(Path("./.dowse_index"), "--db", help="Zvec collection path."),
85
+ model: str = typer.Option(DEFAULT_MODEL, "--model", help="sentence-transformers model."),
86
+ reset: bool = typer.Option(False, "--reset", help="Recreate the collection from scratch."),
87
+ batch: int = typer.Option(128, "--batch", help="Embedding batch size."),
88
+ definitions: bool = typer.Option(
89
+ False, "--definitions", "-D",
90
+ help="Also index YAML, Markdown, and .NET/MSBuild definition files as sections.",
91
+ ),
92
+ ):
93
+ """Recursively index function/class definitions under PATH."""
94
+ try:
95
+ summary = service.run_index(
96
+ path=path, db=db, model=model, reset=reset,
97
+ batch=batch, definitions=definitions, log=_err,
98
+ )
99
+ except LockedIndexError as exc:
100
+ _locked_index_exit(exc)
101
+ _emit(summary)
102
+
103
+
104
+ @app.command()
105
+ def query(
106
+ text: str = typer.Argument(..., help="Natural-language query or error message."),
107
+ db: Path = typer.Option(Path("./.dowse_index"), "--db", help="Zvec collection path."),
108
+ model: str = typer.Option(DEFAULT_MODEL, "--model", help="Must match the index model."),
109
+ top: int = typer.Option(3, "--top", "-n", help="Number of snippets to return."),
110
+ candidates: int = typer.Option(30, "--candidates", help="Dense candidates before re-rank."),
111
+ filter: Optional[str] = typer.Option(None, "--filter", help="Raw zvec SQL filter, e.g. \"kind = 'function'\"."),
112
+ kind: Optional[str] = typer.Option(None, "--kind", help="Shortcut filter: function|class|section."),
113
+ lang: Optional[str] = typer.Option(None, "--lang", help="Shortcut filter by language."),
114
+ w_dense: float = typer.Option(0.7, "--w-dense", help="Weight for semantic similarity."),
115
+ w_lexical: float = typer.Option(0.3, "--w-lexical", help="Weight for lexical overlap."),
116
+ root: Optional[Path] = typer.Option(
117
+ None, "--root",
118
+ help="Workspace root for --tokens full-file comparison. Defaults to cwd.",
119
+ ),
120
+ tokens: bool = typer.Option(
121
+ False, "--tokens",
122
+ help="Include approximate token savings versus containing full files.",
123
+ ),
124
+ ):
125
+ """Return the top-N most relevant code snippets as JSON."""
126
+ try:
127
+ payload = service.run_query(
128
+ text=text, db=db, model=model, top=top, candidates=candidates,
129
+ filter=filter, kind=kind, lang=lang, w_dense=w_dense, w_lexical=w_lexical,
130
+ root=root, include_token_report=tokens,
131
+ )
132
+ except LockedIndexError as exc:
133
+ _locked_index_exit(exc)
134
+ _emit(payload)
135
+
136
+
137
+ @app.command()
138
+ def status(
139
+ db: Optional[Path] = typer.Option(
140
+ None, "--db",
141
+ help="Index path. Defaults to <root>/.dowse_index (or ./.dowse_index).",
142
+ ),
143
+ root: Optional[Path] = typer.Option(
144
+ None, "--root",
145
+ help="Workspace root for stale + missing-grammar signals. Defaults to cwd.",
146
+ ),
147
+ ):
148
+ """Report index health: does it exist, how big, which languages, is it stale?"""
149
+ root_path = Path(root) if root else Path.cwd()
150
+ db_path = Path(db) if db else root_path / ".dowse_index"
151
+ try:
152
+ payload = service.run_index_status(db=db_path, root=root_path)
153
+ except LockedIndexError as exc:
154
+ _locked_index_exit(exc)
155
+ _emit(payload)
156
+
157
+
158
+ @app.command()
159
+ def doctor(
160
+ db: Optional[Path] = typer.Option(
161
+ None, "--db",
162
+ help="Index path. Defaults to <root>/.dowse_index (or ./.dowse_index).",
163
+ ),
164
+ root: Optional[Path] = typer.Option(
165
+ None, "--root",
166
+ help="Workspace root for index, grammar, and MCP config checks. Defaults to cwd.",
167
+ ),
168
+ ):
169
+ """Report install, index, lock, and harness configuration health as JSON."""
170
+ root_path = Path(root) if root else Path.cwd()
171
+ db_path = Path(db) if db else root_path / ".dowse_index"
172
+ try:
173
+ payload = service.run_doctor(db=db_path, root=root_path)
174
+ except LockedIndexError as exc:
175
+ _locked_index_exit(exc)
176
+ _emit(payload)
177
+
178
+
179
+ @app.command()
180
+ def init(
181
+ path: Path = typer.Argument(..., exists=True, file_okay=False, help="Directory to initialise."),
182
+ db: Optional[Path] = typer.Option(
183
+ None, "--db",
184
+ help="Index path. Defaults to <path>/.dowse_index.",
185
+ ),
186
+ model: str = typer.Option(DEFAULT_MODEL, "--model", help="sentence-transformers model."),
187
+ skip_index: bool = typer.Option(
188
+ False, "--skip-index",
189
+ help="Write MCP config and gitignore but do not run an initial index.",
190
+ ),
191
+ harness: Optional[InitHarness] = typer.Option(
192
+ None, "--harness",
193
+ help="Harness-specific config preset to generate (currently: pi).",
194
+ ),
195
+ auto_index: bool = typer.Option(
196
+ False,
197
+ "--auto-index",
198
+ help=(
199
+ "Also install a user-level Cursor sessionStart hook (opt-in). "
200
+ "Does not run without this flag. May contend with dowse serve/index locks."
201
+ ),
202
+ ),
203
+ ):
204
+ """One-command bootstrap: MCP config, .gitignore, grammar coverage, index."""
205
+ root_path = Path(path).resolve()
206
+ db_path = Path(db).resolve() if db else root_path / ".dowse_index"
207
+ try:
208
+ payload = service.run_init(
209
+ root=root_path,
210
+ db=db_path,
211
+ model=model,
212
+ skip_index=skip_index,
213
+ harness=harness.value if harness else None,
214
+ auto_index=auto_index,
215
+ log=_err,
216
+ )
217
+ except LockedIndexError as exc:
218
+ _locked_index_exit(exc)
219
+ _emit(payload)
220
+
221
+
222
+ @hook_app.command("install")
223
+ def hook_install():
224
+ """Install or update ~/.cursor/hooks.json with dowse sessionStart auto-index."""
225
+ payload = cursor_hooks.run_hook_install()
226
+ _emit(payload)
227
+
228
+
229
+ @hook_app.command("session-start")
230
+ def hook_session_start():
231
+ """Cursor sessionStart target: incremental index when workspace opted in (fail-open)."""
232
+ payload = cursor_hooks.run_session_start_index(log=_err)
233
+ _emit(payload)
234
+ # Hooks must never block the editor session.
235
+ if payload.get("status") == "error":
236
+ raise typer.Exit(code=0)
237
+
238
+
239
+ @app.command()
240
+ def serve(
241
+ db: Path = typer.Option(Path("./.dowse_index"), "--db", help="Default Zvec collection path for tools."),
242
+ model: str = typer.Option(DEFAULT_MODEL, "--model", help="Default embedding model for tools."),
243
+ ):
244
+ """Run an MCP server (stdio) exposing `index` and `query` to a coding harness."""
245
+ try:
246
+ server_lock = acquire_server_lock(db)
247
+ except ServerLockHeld as exc:
248
+ _server_lock_exit(exc, db)
249
+
250
+ try:
251
+ _probe_serve_index(db)
252
+ try:
253
+ from .server import build_server
254
+ except ModuleNotFoundError as exc: # mcp not installed
255
+ from ._dist import pip_extra_hint
256
+
257
+ _err(f"[serve] missing dependency: {exc}. Install with: {pip_extra_hint('mcp')}")
258
+ raise typer.Exit(code=1) from None
259
+ _err(f"[serve] starting MCP stdio server (default db={db}, model={model})")
260
+ mcp = build_server(default_db=str(db), default_model=model)
261
+ mcp.run(transport="stdio")
262
+ finally:
263
+ server_lock.release()
264
+
265
+
266
+ if __name__ == "__main__":
267
+ app()
dowse/cursor_hooks.py ADDED
@@ -0,0 +1,115 @@
1
+ """Cursor user-level sessionStart hook for opt-in incremental indexing (#4, #19)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ from . import service
9
+
10
+ DOWSE_SESSION_HOOK_COMMAND = "dowse hook session-start"
11
+ _HOOK_MARKER = "dowse_session_auto_index"
12
+
13
+
14
+ def default_cursor_dir() -> Path:
15
+ return Path.home() / ".cursor"
16
+
17
+
18
+ def _hooks_path(cursor_dir: Path) -> Path:
19
+ return cursor_dir / "hooks.json"
20
+
21
+
22
+ def _is_dowse_session_entry(entry: object) -> bool:
23
+ if not isinstance(entry, dict):
24
+ return False
25
+ cmd = str(entry.get("command") or "")
26
+ return DOWSE_SESSION_HOOK_COMMAND in cmd or _HOOK_MARKER in cmd
27
+
28
+
29
+ def install_cursor_session_hook(*, cursor_dir: Path | None = None) -> dict:
30
+ """Merge a sessionStart hook into ~/.cursor/hooks.json (idempotent)."""
31
+ base = cursor_dir if cursor_dir is not None else default_cursor_dir()
32
+ base.mkdir(parents=True, exist_ok=True)
33
+ path = _hooks_path(base)
34
+ created = not path.is_file()
35
+
36
+ if created:
37
+ data: dict = {"version": 1, "hooks": {}}
38
+ else:
39
+ try:
40
+ data = json.loads(path.read_text(encoding="utf-8"))
41
+ except (OSError, json.JSONDecodeError):
42
+ data = {"version": 1, "hooks": {}}
43
+ if not isinstance(data, dict):
44
+ data = {"version": 1, "hooks": {}}
45
+
46
+ data.setdefault("version", 1)
47
+ hooks = data.setdefault("hooks", {})
48
+ if not isinstance(hooks, dict):
49
+ hooks = {}
50
+ data["hooks"] = hooks
51
+
52
+ session_list = hooks.get("sessionStart")
53
+ if not isinstance(session_list, list):
54
+ session_list = []
55
+ kept = [e for e in session_list if not _is_dowse_session_entry(e)]
56
+ kept.append({"command": DOWSE_SESSION_HOOK_COMMAND, "type": "command"})
57
+ hooks["sessionStart"] = kept
58
+
59
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
60
+ return {
61
+ "target": "cursor",
62
+ "hooks_path": str(path),
63
+ "created": created,
64
+ "merged": not created,
65
+ }
66
+
67
+
68
+ def _find_opted_in_workspace(start: Path) -> Path | None:
69
+ """Walk up from start while a parent contains .dowse_index/."""
70
+ current = start.resolve()
71
+ for directory in (current, *current.parents):
72
+ if (directory / ".dowse_index").is_dir():
73
+ return directory
74
+ if (directory / ".dowse.yaml").is_file():
75
+ return directory
76
+ return None
77
+
78
+
79
+ def run_session_start_index(
80
+ *,
81
+ db_rel: str = ".dowse_index",
82
+ log: Callable[[str], None] | None = None,
83
+ ) -> dict:
84
+ """Fail-open session hook: incremental index when workspace opted in."""
85
+ workspace = _find_opted_in_workspace(Path.cwd())
86
+ if workspace is None:
87
+ return {"status": "skipped", "reason": "no_opted_in_workspace"}
88
+
89
+ db_path = workspace / db_rel
90
+ try:
91
+ summary = service.run_index(
92
+ path=workspace,
93
+ db=db_path,
94
+ reset=False,
95
+ log=log,
96
+ )
97
+ except Exception as exc: # noqa: BLE001 — hook must fail open
98
+ return {
99
+ "status": "error",
100
+ "reason": "index_failed",
101
+ "workspace": str(workspace),
102
+ "detail": str(exc),
103
+ }
104
+
105
+ return {
106
+ "status": "ok",
107
+ "workspace": str(workspace),
108
+ "db_path": str(db_path),
109
+ "indexed_symbols": summary.get("indexed_symbols", 0),
110
+ }
111
+
112
+
113
+ def run_hook_install(*, cursor_dir: Path | None = None) -> dict:
114
+ hook = install_cursor_session_hook(cursor_dir=cursor_dir)
115
+ return {"status": "ok", "hook": hook}