witan-code 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.
- witan_code/__init__.py +0 -0
- witan_code/__main__.py +4 -0
- witan_code/_detach.py +27 -0
- witan_code/bridge.py +392 -0
- witan_code/bridge_extractors.py +642 -0
- witan_code/cli.py +722 -0
- witan_code/config.py +65 -0
- witan_code/context.py +98 -0
- witan_code/edges.py +171 -0
- witan_code/elicit.py +92 -0
- witan_code/extensions/pi/codegraph.ts +121 -0
- witan_code/graph.py +271 -0
- witan_code/hooks.py +116 -0
- witan_code/indexer.py +1042 -0
- witan_code/maintenance.py +130 -0
- witan_code/package_map.py +74 -0
- witan_code/queries/bridge.gq +193 -0
- witan_code/queries/code_mutations.gq +81 -0
- witan_code/queries/code_read.gq +154 -0
- witan_code/queries/delete.gq +16 -0
- witan_code/queries_ts/bash.scm +9 -0
- witan_code/queries_ts/hcl.scm +16 -0
- witan_code/queries_ts/python.scm +29 -0
- witan_code/queries_ts/sql.scm +20 -0
- witan_code/queries_ts/typescript.scm +51 -0
- witan_code/queries_ts/yaml.scm +5 -0
- witan_code/repo.py +282 -0
- witan_code/schema/bridge-schema.pg +82 -0
- witan_code/schema/code-schema.pg +51 -0
- witan_code/server.py +859 -0
- witan_code/setup.py +230 -0
- witan_code/skills/witan-code/SKILL.md +97 -0
- witan_code/stitch.py +177 -0
- witan_code/store.py +116 -0
- witan_code/visualize.py +347 -0
- witan_code-0.2.0.dist-info/METADATA +476 -0
- witan_code-0.2.0.dist-info/RECORD +39 -0
- witan_code-0.2.0.dist-info/WHEEL +4 -0
- witan_code-0.2.0.dist-info/entry_points.txt +2 -0
witan_code/graph.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fcntl
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# omnigraph local stores use optimistic concurrency (Lance manifest versions) and
|
|
11
|
+
# are NOT safe for concurrent writers — a manual index racing the
|
|
12
|
+
# PostToolUse/SessionStart reindex hooks can leave HEAD ahead of the manifest.
|
|
13
|
+
# We serialize writes with a per-store advisory lock (prevention) and, as a
|
|
14
|
+
# safety net, retry transient "stale view" conflicts and `omnigraph repair`
|
|
15
|
+
# stores already in the drifted state.
|
|
16
|
+
_WRITE_SUBCOMMANDS = {"mutate", "load", "optimize", "cleanup"}
|
|
17
|
+
_RETRYABLE = ("stale view", "manifest table version", "refresh and retry")
|
|
18
|
+
_NEEDS_REPAIR = ("ahead of manifest", "omnigraph repair")
|
|
19
|
+
_MAX_ATTEMPTS = 8
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OmnigraphClient:
|
|
23
|
+
"""Subprocess wrapper for the omnigraph CLI.
|
|
24
|
+
|
|
25
|
+
Copied verbatim from witan (queries_dir default adjusted) so the
|
|
26
|
+
two Layer packages stay independent — no cross-package imports.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
graph_uri: str,
|
|
32
|
+
queries_dir: Path,
|
|
33
|
+
token: str | None = None,
|
|
34
|
+
branch: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.graph_uri = graph_uri
|
|
37
|
+
self.queries_dir = queries_dir
|
|
38
|
+
self.token = token
|
|
39
|
+
# Target omnigraph branch for query/mutate/load; None = the store's
|
|
40
|
+
# main branch. Loads pass `--from main` so the branch forks lazily on
|
|
41
|
+
# first write (docs/BRANCH_INDEXING.md).
|
|
42
|
+
self.branch = branch
|
|
43
|
+
self._binary = self._find_binary()
|
|
44
|
+
|
|
45
|
+
# ── Public API ────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def read(
|
|
48
|
+
self,
|
|
49
|
+
query_file: str,
|
|
50
|
+
query_name: str,
|
|
51
|
+
params: dict,
|
|
52
|
+
) -> list[dict]:
|
|
53
|
+
"""Run a named read query. Returns a list of result rows."""
|
|
54
|
+
result = self._run(
|
|
55
|
+
"query",
|
|
56
|
+
"--query",
|
|
57
|
+
str(self.queries_dir / query_file),
|
|
58
|
+
query_name,
|
|
59
|
+
"--params",
|
|
60
|
+
json.dumps(params),
|
|
61
|
+
"--format",
|
|
62
|
+
"json",
|
|
63
|
+
)
|
|
64
|
+
if not result.strip():
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
parsed = json.loads(result)
|
|
68
|
+
except json.JSONDecodeError as exc:
|
|
69
|
+
raise RuntimeError(f"omnigraph returned non-JSON: {result!r}") from exc
|
|
70
|
+
# v0.7.0 wraps results in {rows: [...], columns: [...], ...}
|
|
71
|
+
rows = parsed.get("rows", parsed) if isinstance(parsed, dict) else parsed
|
|
72
|
+
# strip alias prefixes: "s.name" → "name"
|
|
73
|
+
return [{k.split(".", 1)[-1]: v for k, v in row.items()} for row in rows]
|
|
74
|
+
|
|
75
|
+
def change(
|
|
76
|
+
self,
|
|
77
|
+
query_file: str,
|
|
78
|
+
query_name: str,
|
|
79
|
+
params: dict,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Run a named mutation query."""
|
|
82
|
+
self._run(
|
|
83
|
+
"mutate",
|
|
84
|
+
"--query",
|
|
85
|
+
str(self.queries_dir / query_file),
|
|
86
|
+
query_name,
|
|
87
|
+
"--params",
|
|
88
|
+
json.dumps(params),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def load(self, records: list[dict], mode: str = "merge") -> None:
|
|
92
|
+
"""Bulk-load node/edge records via one ``omnigraph load`` call.
|
|
93
|
+
|
|
94
|
+
Each record is a JSONL line: ``{"type": Node, "data": {...}}`` for a
|
|
95
|
+
node or ``{"edge": Edge, "from": key, "to": key}`` for an edge. This
|
|
96
|
+
replaces thousands of per-record ``mutate`` subprocesses with a single
|
|
97
|
+
invocation — essential for indexing large repositories.
|
|
98
|
+
"""
|
|
99
|
+
if not records:
|
|
100
|
+
return
|
|
101
|
+
fd, tmp = tempfile.mkstemp(suffix=".jsonl", prefix="codegraph-load-")
|
|
102
|
+
try:
|
|
103
|
+
with os.fdopen(fd, "w") as fh:
|
|
104
|
+
for record in records:
|
|
105
|
+
fh.write(json.dumps(record))
|
|
106
|
+
fh.write("\n")
|
|
107
|
+
self._run("load", "--data", tmp, "--mode", mode)
|
|
108
|
+
finally:
|
|
109
|
+
Path(tmp).unlink(missing_ok=True)
|
|
110
|
+
|
|
111
|
+
def optimize(self) -> str:
|
|
112
|
+
"""Compact small Lance fragments across every table (non-destructive).
|
|
113
|
+
|
|
114
|
+
Every load()/mutate() appends a new tiny Lance fragment + manifest
|
|
115
|
+
version; an un-compacted store bloats until *opening* it dominates
|
|
116
|
+
query latency (a fixed per-query cost regardless of rows) — the same
|
|
117
|
+
failure mode witan's own store hit (#98). ``omnigraph optimize``
|
|
118
|
+
collapses the fragments. It takes the store's write lock and can run
|
|
119
|
+
for tens of seconds on a bloated store, so callers must throttle it
|
|
120
|
+
and keep it off the indexing hot path (see ``witan_code.maintenance``).
|
|
121
|
+
"""
|
|
122
|
+
return self._run("optimize")
|
|
123
|
+
|
|
124
|
+
def cleanup(self, *, keep: int | None = None, older_than: str | None = None) -> str:
|
|
125
|
+
"""Reclaim disk by removing old Lance versions (**destructive**).
|
|
126
|
+
|
|
127
|
+
``optimize`` compacts fragments but leaves old versions behind, so disk
|
|
128
|
+
stays large until they are GC'd. ``cleanup`` removes them, keeping the
|
|
129
|
+
most recent ``keep`` versions per table and/or those newer than
|
|
130
|
+
``older_than`` (a Go-style duration like ``7d``). At least one bound
|
|
131
|
+
must be given (omnigraph requires it). ``--confirm`` is passed so it
|
|
132
|
+
actually runs.
|
|
133
|
+
"""
|
|
134
|
+
if keep is None and older_than is None:
|
|
135
|
+
raise ValueError("cleanup requires keep and/or older_than")
|
|
136
|
+
args = ["--confirm"]
|
|
137
|
+
if keep is not None:
|
|
138
|
+
args += ["--keep", str(keep)]
|
|
139
|
+
if older_than is not None:
|
|
140
|
+
args += ["--older-than", older_than]
|
|
141
|
+
return self._run("cleanup", *args)
|
|
142
|
+
|
|
143
|
+
# ── Internals ─────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
# ── Branch operations ─────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def list_branches(self) -> list[str]:
|
|
148
|
+
"""Names of all branches on this store (includes ``main``)."""
|
|
149
|
+
result = self._run("branch", "list", "--json")
|
|
150
|
+
try:
|
|
151
|
+
parsed = json.loads(result)
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
return []
|
|
154
|
+
rows = parsed.get("branches", parsed) if isinstance(parsed, dict) else parsed
|
|
155
|
+
if not isinstance(rows, list):
|
|
156
|
+
return []
|
|
157
|
+
out: list[str] = []
|
|
158
|
+
for row in rows:
|
|
159
|
+
name = row.get("name") if isinstance(row, dict) else row
|
|
160
|
+
if isinstance(name, str):
|
|
161
|
+
out.append(name)
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
def ensure_branch(self) -> None:
|
|
165
|
+
"""Create ``self.branch`` from main if it doesn't exist yet.
|
|
166
|
+
|
|
167
|
+
Needed before the first *read* on a new branch — reads never fork
|
|
168
|
+
(only ``load --from`` does), so a read against a missing branch errors.
|
|
169
|
+
"""
|
|
170
|
+
if self.branch is None or self.branch in self.list_branches():
|
|
171
|
+
return
|
|
172
|
+
self._run("branch", "create", self.branch, "--from", "main")
|
|
173
|
+
|
|
174
|
+
def delete_branch(self, name: str) -> None:
|
|
175
|
+
self._run("branch", "delete", name, "--yes")
|
|
176
|
+
|
|
177
|
+
# ── Internals ─────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def _branch_args(self, subcommand: str) -> list[str]:
|
|
180
|
+
# optimize/cleanup compact the whole store (every branch), not a
|
|
181
|
+
# single one, so they never take --branch even on a branched client.
|
|
182
|
+
if self.branch is None or subcommand in ("branch", "optimize", "cleanup"):
|
|
183
|
+
return []
|
|
184
|
+
if subcommand == "load":
|
|
185
|
+
return ["--branch", self.branch, "--from", "main"]
|
|
186
|
+
return ["--branch", self.branch]
|
|
187
|
+
|
|
188
|
+
def _run(self, subcommand: str, *args: str) -> str:
|
|
189
|
+
quiet = ["--quiet"] if subcommand in _WRITE_SUBCOMMANDS else []
|
|
190
|
+
cmd = [
|
|
191
|
+
self._binary,
|
|
192
|
+
subcommand,
|
|
193
|
+
"--store",
|
|
194
|
+
self.graph_uri,
|
|
195
|
+
*quiet,
|
|
196
|
+
*self._branch_args(subcommand),
|
|
197
|
+
*args,
|
|
198
|
+
]
|
|
199
|
+
env = dict(os.environ)
|
|
200
|
+
if self.token:
|
|
201
|
+
env["OMNIGRAPH_SERVER_BEARER_TOKEN"] = self.token
|
|
202
|
+
|
|
203
|
+
lock_fh = self._acquire_write_lock(subcommand)
|
|
204
|
+
try:
|
|
205
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
206
|
+
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
207
|
+
if result.returncode == 0:
|
|
208
|
+
return result.stdout
|
|
209
|
+
err = result.stderr
|
|
210
|
+
if attempt + 1 < _MAX_ATTEMPTS:
|
|
211
|
+
if any(m in err for m in _NEEDS_REPAIR):
|
|
212
|
+
self._repair(env)
|
|
213
|
+
continue
|
|
214
|
+
if any(m in err for m in _RETRYABLE):
|
|
215
|
+
time.sleep(0.05 * (attempt + 1))
|
|
216
|
+
continue
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
f"omnigraph {subcommand} failed (exit {result.returncode}):\n"
|
|
219
|
+
f"{err.strip()}"
|
|
220
|
+
)
|
|
221
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
222
|
+
finally:
|
|
223
|
+
if lock_fh is not None:
|
|
224
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
|
|
225
|
+
lock_fh.close()
|
|
226
|
+
|
|
227
|
+
def _acquire_write_lock(self, subcommand: str):
|
|
228
|
+
"""Hold a per-store exclusive lock for write subcommands (local stores)."""
|
|
229
|
+
if subcommand not in _WRITE_SUBCOMMANDS or self.graph_uri.startswith(
|
|
230
|
+
("http://", "https://", "s3://")
|
|
231
|
+
):
|
|
232
|
+
return None
|
|
233
|
+
lock_path = Path(f"{self.graph_uri}.lock")
|
|
234
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
fh = open(lock_path, "w") # noqa: SIM115 — released in _run's finally
|
|
236
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
237
|
+
return fh
|
|
238
|
+
|
|
239
|
+
def _repair(self, env: dict) -> None:
|
|
240
|
+
"""Reconcile manifest/HEAD drift so the retried write can proceed."""
|
|
241
|
+
subprocess.run(
|
|
242
|
+
[
|
|
243
|
+
self._binary,
|
|
244
|
+
"repair",
|
|
245
|
+
"--store",
|
|
246
|
+
self.graph_uri,
|
|
247
|
+
"--confirm",
|
|
248
|
+
"--force",
|
|
249
|
+
"--quiet",
|
|
250
|
+
],
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
env=env,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _find_binary() -> str:
|
|
258
|
+
binary = shutil.which("omnigraph")
|
|
259
|
+
if binary is not None:
|
|
260
|
+
return binary
|
|
261
|
+
# MCP servers are often launched by a desktop app or IDE extension
|
|
262
|
+
# whose process doesn't inherit a shell PATH — `witan setup`/
|
|
263
|
+
# `witan-code setup` always install to this fixed location, so check
|
|
264
|
+
# it directly rather than relying on PATH alone.
|
|
265
|
+
fallback = Path.home() / ".local" / "bin" / "omnigraph"
|
|
266
|
+
if fallback.exists():
|
|
267
|
+
return str(fallback)
|
|
268
|
+
raise RuntimeError(
|
|
269
|
+
"omnigraph binary not found. Install via: witan-code setup "
|
|
270
|
+
"(or `witan setup`, if witan is also installed)"
|
|
271
|
+
)
|
witan_code/hooks.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""SessionStart and PostToolUse hook logic, as plain Python.
|
|
2
|
+
|
|
3
|
+
Ported from the former codegraph-session-init.sh / codegraph-reindex.sh bash
|
|
4
|
+
scripts so hook invocation is a portable CLI command everywhere the
|
|
5
|
+
`witan-code` binary installs — Windows included, where bash/setsid don't
|
|
6
|
+
exist — matching the bare `witan-code inject-context`/`checkpoint` pattern
|
|
7
|
+
this package's other two hooks already use (and witan's own `witan
|
|
8
|
+
inject-context`/`session-checkpoint`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import indexer
|
|
19
|
+
from . import repo as repo_module
|
|
20
|
+
from ._detach import popen_detached
|
|
21
|
+
from .context import _lock_path, _project_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def session_init() -> None:
|
|
25
|
+
"""SessionStart: seed/refresh the whole repo's code graph in the
|
|
26
|
+
background, at most once across overlapping sessions.
|
|
27
|
+
|
|
28
|
+
Best-effort and non-blocking: skips non-git directories, never raises,
|
|
29
|
+
and returns immediately — the actual indexing happens in a fully detached
|
|
30
|
+
child process (see :func:`_index_and_unlock`), which is what makes this
|
|
31
|
+
safe to call from a hook that must not block session start.
|
|
32
|
+
"""
|
|
33
|
+
project_dir = _project_dir()
|
|
34
|
+
if repo_module.root(project_dir) is None:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
lock = _lock_path(project_dir)
|
|
38
|
+
try:
|
|
39
|
+
lock.mkdir(parents=True)
|
|
40
|
+
except FileExistsError:
|
|
41
|
+
return # another session is already indexing this repo
|
|
42
|
+
except OSError:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
popen_detached(
|
|
47
|
+
[
|
|
48
|
+
sys.executable,
|
|
49
|
+
"-m",
|
|
50
|
+
"witan_code",
|
|
51
|
+
"_index-and-unlock",
|
|
52
|
+
str(project_dir),
|
|
53
|
+
str(lock),
|
|
54
|
+
],
|
|
55
|
+
stdin=subprocess.DEVNULL,
|
|
56
|
+
stdout=subprocess.DEVNULL,
|
|
57
|
+
stderr=subprocess.DEVNULL,
|
|
58
|
+
)
|
|
59
|
+
except OSError:
|
|
60
|
+
try:
|
|
61
|
+
lock.rmdir()
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def index_and_unlock(target: Path, lock: Path) -> None:
|
|
67
|
+
"""Run by the detached child :func:`session_init` spawns — never called
|
|
68
|
+
directly by a hook. Indexes ``target``, then always releases ``lock``,
|
|
69
|
+
however indexing turns out, so a parse failure can't wedge the lock and
|
|
70
|
+
permanently block future sessions from indexing this repo."""
|
|
71
|
+
try:
|
|
72
|
+
indexer.index_path(target, force=False)
|
|
73
|
+
except Exception: # noqa: BLE001 — a bad repo must not leave the lock held
|
|
74
|
+
pass
|
|
75
|
+
finally:
|
|
76
|
+
try:
|
|
77
|
+
lock.rmdir()
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def reindex_hook(payload: str) -> None:
|
|
83
|
+
"""PostToolUse (matcher ``Edit|Write``): incrementally reindex the edited
|
|
84
|
+
file. ``payload`` is the raw hook JSON read from stdin. Foreground and
|
|
85
|
+
fast (a single file), unlike :func:`session_init`'s full-repo background
|
|
86
|
+
index — mirrors the synchronous ``witan-code index <file>`` the old
|
|
87
|
+
reindex hook script ran.
|
|
88
|
+
|
|
89
|
+
Best-effort: a missing/malformed payload, an untracked tool, or a parse
|
|
90
|
+
failure all degrade to a silent no-op rather than interrupting the agent.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(payload) if payload else {}
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
return
|
|
96
|
+
tool_input = data.get("tool_input") if isinstance(data, dict) else None
|
|
97
|
+
if not isinstance(tool_input, dict):
|
|
98
|
+
return
|
|
99
|
+
raw = (
|
|
100
|
+
tool_input.get("file_path")
|
|
101
|
+
or tool_input.get("path")
|
|
102
|
+
or tool_input.get("filename")
|
|
103
|
+
)
|
|
104
|
+
if not isinstance(raw, str) or not raw:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
path = Path(raw)
|
|
108
|
+
if not path.is_absolute():
|
|
109
|
+
path = Path.cwd() / path
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
indexer.index_path(path, force=False)
|
|
115
|
+
except Exception: # noqa: BLE001 — a parse failure must not fail the hook
|
|
116
|
+
pass
|