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 +3 -0
- dowse/_dist.py +13 -0
- dowse/cli.py +267 -0
- dowse/cursor_hooks.py +115 -0
- dowse/definitions.py +302 -0
- dowse/embed.py +53 -0
- dowse/extract.py +351 -0
- dowse/models.py +23 -0
- dowse/server.py +127 -0
- dowse/server_lock.py +139 -0
- dowse/service.py +697 -0
- dowse/store.py +261 -0
- dowse_context-0.2.0.dist-info/METADATA +415 -0
- dowse_context-0.2.0.dist-info/RECORD +18 -0
- dowse_context-0.2.0.dist-info/WHEEL +5 -0
- dowse_context-0.2.0.dist-info/entry_points.txt +2 -0
- dowse_context-0.2.0.dist-info/licenses/LICENSE +21 -0
- dowse_context-0.2.0.dist-info/top_level.txt +1 -0
dowse/__init__.py
ADDED
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}
|