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/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Bundled query files, resolved relative to this file.
|
|
7
|
+
_QUERIES_DIR = Path(__file__).parent / "queries"
|
|
8
|
+
_SCHEMA_FILE = Path(__file__).parent / "schema" / "code-schema.pg"
|
|
9
|
+
_BRIDGE_SCHEMA_FILE = Path(__file__).parent / "schema" / "bridge-schema.pg"
|
|
10
|
+
_DEFAULT_CODE_DIR = Path.home() / ".local" / "share" / "witan" / "code"
|
|
11
|
+
|
|
12
|
+
# Filename of the single shared cross-repo bridge store, a sibling of the
|
|
13
|
+
# per-repo `<slug>.omni` stores in code_dir. Not routed through sanitize_slug
|
|
14
|
+
# (whose .strip("_") would eat the leading underscore); no real repo slug
|
|
15
|
+
# resolves to this name.
|
|
16
|
+
BRIDGE_STORE_NAME = "_bridge.omni"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
code_dir: Path
|
|
22
|
+
"""Directory holding per-repo code stores (one ``<slug>.omni`` each)."""
|
|
23
|
+
|
|
24
|
+
author: str
|
|
25
|
+
"""Attribution string (carried for parity with Layer 1; unused on inserts)."""
|
|
26
|
+
|
|
27
|
+
queries_dir: Path
|
|
28
|
+
"""Directory containing code_read.gq, code_mutations.gq, delete.gq."""
|
|
29
|
+
|
|
30
|
+
schema_file: Path
|
|
31
|
+
"""Path to code-schema.pg, used to lazily init a per-repo store."""
|
|
32
|
+
|
|
33
|
+
bridge_schema_file: Path
|
|
34
|
+
"""Path to bridge-schema.pg, used to lazily init the shared bridge store."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load() -> Config:
|
|
38
|
+
"""Load config from environment. All variables are optional with defaults."""
|
|
39
|
+
return Config(
|
|
40
|
+
code_dir=Path(os.environ.get("WITAN_CODE_DIR", str(_DEFAULT_CODE_DIR))),
|
|
41
|
+
author=os.environ.get(
|
|
42
|
+
"WITAN_AUTHOR",
|
|
43
|
+
os.environ.get("USER", "unknown"),
|
|
44
|
+
),
|
|
45
|
+
queries_dir=_QUERIES_DIR,
|
|
46
|
+
schema_file=_SCHEMA_FILE,
|
|
47
|
+
bridge_schema_file=_BRIDGE_SCHEMA_FILE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sanitize_slug(slug: str) -> str:
|
|
52
|
+
"""Make a repo slug safe for use as a filename component."""
|
|
53
|
+
return re.sub(r"[/:]+", "_", slug).strip("_")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def store_path(slug: str, code_dir: Path | None = None) -> Path:
|
|
57
|
+
"""Resolve the per-repo store path for ``slug``."""
|
|
58
|
+
base = code_dir or load().code_dir
|
|
59
|
+
return base / f"{sanitize_slug(slug)}.omni"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def bridge_store_path(code_dir: Path | None = None) -> Path:
|
|
63
|
+
"""Resolve the shared cross-repo bridge store path."""
|
|
64
|
+
base = code_dir or load().code_dir
|
|
65
|
+
return base / BRIDGE_STORE_NAME
|
witan_code/context.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Self-contained UserPromptSubmit status block for witan-code.
|
|
2
|
+
|
|
3
|
+
Mirrors the shape of ``witan``'s own ``inject-context`` hook, but is a
|
|
4
|
+
deliberately independent implementation (no cross-package import — see
|
|
5
|
+
``graph.py``'s docstring) so witan-code stays fully usable, and self-announcing,
|
|
6
|
+
when installed standalone without ``witan``.
|
|
7
|
+
|
|
8
|
+
Tells the agent whether the current repo has a code graph ready to query (and
|
|
9
|
+
its rough size/freshness) — without this, an agent has no signal that
|
|
10
|
+
``code_*`` tools exist or are populated, short of trying one and seeing what
|
|
11
|
+
comes back.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import config as cfg_module
|
|
21
|
+
from . import repo as repo_module
|
|
22
|
+
from .cli import _code_store_stats, _dir_stats
|
|
23
|
+
|
|
24
|
+
# Matches the lock directory hooks.session_init() creates around a background
|
|
25
|
+
# SessionStart index, so this hook can report "indexing in progress" instead
|
|
26
|
+
# of a misleadingly empty/stale store. Keyed on a hash of the project
|
|
27
|
+
# directory (not the raw sanitized path) so two distinct paths can't collide
|
|
28
|
+
# on the same lock file (e.g. "/tmp/a/b" and "/tmp/a_b" both sanitizing to
|
|
29
|
+
# "_tmp_a_b") and so a deep/long checkout path can't blow past a filesystem's
|
|
30
|
+
# filename length limit and silently fail the `mkdir`.
|
|
31
|
+
_LOCK_PREFIX = "codegraph-init-"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _lock_digest(project_dir: Path) -> str:
|
|
35
|
+
return hashlib.sha256(str(project_dir).encode()).hexdigest()[:16]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _project_dir() -> Path:
|
|
39
|
+
try:
|
|
40
|
+
cwd = os.getcwd()
|
|
41
|
+
except OSError: # e.g. the cwd was deleted out from under this process
|
|
42
|
+
cwd = "/"
|
|
43
|
+
return Path(os.environ.get("CLAUDE_PROJECT_DIR", cwd))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _lock_path(project_dir: Path) -> Path:
|
|
47
|
+
tmp = Path(os.environ.get("TMPDIR", "/tmp"))
|
|
48
|
+
return tmp / f"{_LOCK_PREFIX}{_lock_digest(project_dir)}.lock"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def indexing_in_progress() -> bool:
|
|
52
|
+
"""Whether hooks.session_init()'s background index is still running."""
|
|
53
|
+
return _lock_path(_project_dir()).is_dir()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def inject_context() -> str:
|
|
57
|
+
"""A short markdown status block, or "" when there's nothing worth saying.
|
|
58
|
+
|
|
59
|
+
Silent when the repo has neither a store nor an index in flight (nothing
|
|
60
|
+
to report), so this hook adds no noise for repos that don't use witan-code.
|
|
61
|
+
"""
|
|
62
|
+
cfg = cfg_module.load()
|
|
63
|
+
slug = repo_module.detect()
|
|
64
|
+
if slug is None:
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
store = cfg_module.store_path(slug, cfg.code_dir)
|
|
68
|
+
in_progress = indexing_in_progress()
|
|
69
|
+
|
|
70
|
+
if not store.exists():
|
|
71
|
+
if not in_progress:
|
|
72
|
+
return ""
|
|
73
|
+
return (
|
|
74
|
+
"## Code Graph\n\n"
|
|
75
|
+
f"Indexing `{slug}` for the first time in the background — "
|
|
76
|
+
"`code_*` tools may return partial or empty results until it "
|
|
77
|
+
"finishes.\n"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
repo_uri, file_count = _code_store_stats(store)
|
|
81
|
+
try:
|
|
82
|
+
_, last_indexed = _dir_stats(store)
|
|
83
|
+
freshness = f", last updated {last_indexed}"
|
|
84
|
+
except OSError: # e.g. a file vanished mid-walk — degrade, don't blank the block
|
|
85
|
+
freshness = ""
|
|
86
|
+
lines = [
|
|
87
|
+
"## Code Graph",
|
|
88
|
+
"",
|
|
89
|
+
f"`{repo_uri}` is indexed: {file_count} files{freshness}.",
|
|
90
|
+
]
|
|
91
|
+
if in_progress:
|
|
92
|
+
lines.append("A background reindex is currently running.")
|
|
93
|
+
lines.append(
|
|
94
|
+
"Prefer `code_search_symbol` / `code_find_definition` / "
|
|
95
|
+
"`code_find_references` / `code_callers` / `code_impact` over grep "
|
|
96
|
+
"for symbol lookups, call graphs, and change-impact analysis."
|
|
97
|
+
)
|
|
98
|
+
return "\n".join(lines) + "\n"
|
witan_code/edges.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Typed cross-repo edge precision tiers (docs/EDGE_PRECISION_TIERS.md).
|
|
2
|
+
|
|
3
|
+
Kythe-inspired ``ref/call/direct`` vs ``ref/call`` pattern: replace the
|
|
4
|
+
previous binary consumer/provider model with typed edges that carry the
|
|
5
|
+
precision of *how* a cross-repo link was established, so callers filter by a
|
|
6
|
+
minimum trust floor instead of a confidence threshold they have to pick
|
|
7
|
+
themselves.
|
|
8
|
+
|
|
9
|
+
Tiers, in decreasing trust order:
|
|
10
|
+
* ``precise`` — Stage 2: canonical symbol string join (``stitch``).
|
|
11
|
+
* ``heuristic`` — Stage 3: the pre-existing ``(kind, key_norm)`` binding
|
|
12
|
+
grouping, confidence-scored (``bridge_extractors.adjust_confidence``,
|
|
13
|
+
``visualize.cross_repo_edges``).
|
|
14
|
+
* ``fuzzy`` — future: embedding/BM25 route similarity (tracked
|
|
15
|
+
separately: "Research: REST-route cross-service matching via embedding
|
|
16
|
+
similarity"). No fuzzy edges exist yet, so this tier is currently
|
|
17
|
+
identical to ``heuristic``.
|
|
18
|
+
|
|
19
|
+
``min_precision`` is a FLOOR: ``"precise"`` returns only the precise tier;
|
|
20
|
+
``"heuristic"`` (the default, preserving pre-existing behavior) returns
|
|
21
|
+
precise + heuristic; ``"fuzzy"`` returns everything. A heuristic edge is
|
|
22
|
+
suppressed whenever a precise edge already covers the same
|
|
23
|
+
``(consumer_repo, provider_repo, kind, key_norm)`` triple, so the same
|
|
24
|
+
logical link never appears twice at two precision tiers.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
from . import stitch
|
|
30
|
+
from .visualize import cross_repo_edges as _confidence_filter
|
|
31
|
+
|
|
32
|
+
PRECISION_TIERS = ("precise", "heuristic", "fuzzy")
|
|
33
|
+
_TIER_ORDER = {tier: i for i, tier in enumerate(PRECISION_TIERS)}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class TypedEdge:
|
|
38
|
+
precision: str # "precise" | "heuristic" | "fuzzy"
|
|
39
|
+
consumer_repo: str
|
|
40
|
+
provider_repo: str
|
|
41
|
+
kind: str
|
|
42
|
+
key_norm: str
|
|
43
|
+
canonical_symbol: str | None # the consumer's symbol; precise tier only
|
|
44
|
+
confidence: float
|
|
45
|
+
evidence: tuple[dict, ...]
|
|
46
|
+
|
|
47
|
+
def as_dict(self) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"precision": self.precision,
|
|
50
|
+
"consumer_repo": self.consumer_repo,
|
|
51
|
+
"provider_repo": self.provider_repo,
|
|
52
|
+
"kind": self.kind,
|
|
53
|
+
"key_norm": self.key_norm,
|
|
54
|
+
"canonical_symbol": self.canonical_symbol,
|
|
55
|
+
"confidence": self.confidence,
|
|
56
|
+
"evidence": list(self.evidence),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pair(edge) -> tuple:
|
|
61
|
+
return (edge.consumer_repo, edge.provider_repo, edge.kind, edge.key_norm)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _precise_edges(repo_symbol_rows: list[dict]) -> list[TypedEdge]:
|
|
65
|
+
precise, _ = stitch.resolve(repo_symbol_rows)
|
|
66
|
+
return [
|
|
67
|
+
TypedEdge(
|
|
68
|
+
precision="precise",
|
|
69
|
+
consumer_repo=e.consumer_repo,
|
|
70
|
+
provider_repo=e.provider_repo,
|
|
71
|
+
kind=e.kind,
|
|
72
|
+
key_norm=e.key_norm,
|
|
73
|
+
canonical_symbol=e.consumer_symbol,
|
|
74
|
+
confidence=1.0,
|
|
75
|
+
evidence=e.evidence,
|
|
76
|
+
)
|
|
77
|
+
for e in precise
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _heuristic_edges(
|
|
82
|
+
binding_rows: list[dict], *, min_confidence: float
|
|
83
|
+
) -> list[TypedEdge]:
|
|
84
|
+
"""Group raw ``InterfaceBinding`` rows into (consumer, provider) edges.
|
|
85
|
+
|
|
86
|
+
Mirrors ``visualize.build_graph``'s grouping/self-providing/service-anchor
|
|
87
|
+
rules but keeps per-occurrence evidence instead of collapsing into a
|
|
88
|
+
render-oriented ``DepGraph``.
|
|
89
|
+
"""
|
|
90
|
+
filtered = _confidence_filter(binding_rows, min_confidence=min_confidence)
|
|
91
|
+
|
|
92
|
+
groups: dict[tuple[str, str], dict] = {}
|
|
93
|
+
for b in filtered:
|
|
94
|
+
if b["kind"] == "service":
|
|
95
|
+
continue # service anchors aren't repo-to-repo edges (see visualize.build_graph)
|
|
96
|
+
group = groups.setdefault((b["kind"], b["key_norm"]), {"p": {}, "c": {}})
|
|
97
|
+
bucket = group["p"] if b["role"] == "provider" else group["c"]
|
|
98
|
+
repo_bucket = bucket.setdefault(b["repo"], {"confidence": None, "evidence": []})
|
|
99
|
+
if b["role"] != "provider":
|
|
100
|
+
conf = float(b["confidence"]) if b.get("confidence") is not None else 1.0
|
|
101
|
+
if repo_bucket["confidence"] is None or conf > repo_bucket["confidence"]:
|
|
102
|
+
repo_bucket["confidence"] = conf
|
|
103
|
+
if b.get("file"):
|
|
104
|
+
repo_bucket["evidence"].append(
|
|
105
|
+
{"repo": b["repo"], "file": b["file"], "line": b.get("line")}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
out: list[TypedEdge] = []
|
|
109
|
+
for (kind, key_norm), group in groups.items():
|
|
110
|
+
for cons_repo, cons in group["c"].items():
|
|
111
|
+
if cons_repo in group["p"]:
|
|
112
|
+
continue # self-providing: the repo serves its own route
|
|
113
|
+
for prov_repo in group["p"]:
|
|
114
|
+
if cons_repo == prov_repo:
|
|
115
|
+
continue
|
|
116
|
+
out.append(
|
|
117
|
+
TypedEdge(
|
|
118
|
+
precision="heuristic",
|
|
119
|
+
consumer_repo=cons_repo,
|
|
120
|
+
provider_repo=prov_repo,
|
|
121
|
+
kind=kind,
|
|
122
|
+
key_norm=key_norm,
|
|
123
|
+
canonical_symbol=None,
|
|
124
|
+
confidence=(
|
|
125
|
+
cons["confidence"]
|
|
126
|
+
if cons["confidence"] is not None
|
|
127
|
+
else 1.0
|
|
128
|
+
),
|
|
129
|
+
evidence=tuple(cons["evidence"]),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cross_repo_edges(
|
|
136
|
+
repo_symbol_rows: list[dict],
|
|
137
|
+
binding_rows: list[dict],
|
|
138
|
+
*,
|
|
139
|
+
min_precision: str = "heuristic",
|
|
140
|
+
min_confidence: float = 0.5,
|
|
141
|
+
) -> list[TypedEdge]:
|
|
142
|
+
"""Merge Stage 2 precise + Stage 3 heuristic edges into one typed, filterable list.
|
|
143
|
+
|
|
144
|
+
``repo_symbol_rows`` is a full ``RepoSymbol`` dump (``all_repo_symbols``);
|
|
145
|
+
``binding_rows`` is a full ``InterfaceBinding`` dump (``all_bindings``).
|
|
146
|
+
See the module docstring for tier semantics.
|
|
147
|
+
"""
|
|
148
|
+
if min_precision not in _TIER_ORDER:
|
|
149
|
+
raise ValueError(f"min_precision must be one of {PRECISION_TIERS!r}")
|
|
150
|
+
|
|
151
|
+
precise = _precise_edges(repo_symbol_rows)
|
|
152
|
+
out = list(precise)
|
|
153
|
+
if _TIER_ORDER[min_precision] >= _TIER_ORDER["heuristic"]:
|
|
154
|
+
covered = {_pair(e) for e in precise}
|
|
155
|
+
heuristic = _heuristic_edges(binding_rows, min_confidence=min_confidence)
|
|
156
|
+
out.extend(e for e in heuristic if _pair(e) not in covered)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def precise_pairs(repo_symbol_rows: list[dict]) -> frozenset[tuple[str, str, str, str]]:
|
|
161
|
+
"""``(consumer_repo, provider_repo, kind, key_norm)`` covered by a Stage-2 join.
|
|
162
|
+
|
|
163
|
+
A cheap membership-test helper for tools that need to know whether a
|
|
164
|
+
*specific* binding participates in a precise edge without building the
|
|
165
|
+
full merged/typed edge list — e.g. filtering ``code_interface_*`` rows by
|
|
166
|
+
``min_precision="precise"``.
|
|
167
|
+
"""
|
|
168
|
+
precise, _ = stitch.resolve(repo_symbol_rows)
|
|
169
|
+
return frozenset(
|
|
170
|
+
(e.consumer_repo, e.provider_repo, e.kind, e.key_norm) for e in precise
|
|
171
|
+
)
|
witan_code/elicit.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Additive MCP elicitation helpers for the witan-code tools.
|
|
2
|
+
|
|
3
|
+
FastMCP 3.4.3 supports ``ctx.elicit``, but the connected client may not, and
|
|
4
|
+
several ``code_*`` tools also run under headless automation (background
|
|
5
|
+
indexers, other MCP clients without elicitation support). These helpers keep
|
|
6
|
+
elicitation strictly *additive*: when the client can't elicit — or ``ctx`` is
|
|
7
|
+
absent — they return the caller's ``default`` so behavior is exactly what it
|
|
8
|
+
was before elicitation existed. Only an *explicit* user decline changes the
|
|
9
|
+
outcome.
|
|
10
|
+
|
|
11
|
+
Call sites in ``server.py``:
|
|
12
|
+
|
|
13
|
+
- ``code_symbols_in_file`` offers to elicit a repo URI when none can be
|
|
14
|
+
detected (``text``).
|
|
15
|
+
- ``code_find_references``/``code_callers``/``code_impact``/
|
|
16
|
+
``code_symbols_in_file`` and the bridge-backed interface/cross-repo tools
|
|
17
|
+
offer to index now when the relevant store is missing (``confirm``, via the
|
|
18
|
+
``_confirm_and_reindex``/``_confirm_and_reindex_bridge`` helpers in
|
|
19
|
+
``server.py``).
|
|
20
|
+
- ``code_find_definition`` offers to narrow a multi-repo name match down to
|
|
21
|
+
one repo (``choose_repo``).
|
|
22
|
+
|
|
23
|
+
Never call these from a tool that must stay non-interactive under automation
|
|
24
|
+
— ``code_reindex`` never takes a ``Context`` (see its docstring), and
|
|
25
|
+
``code_search_symbol`` returns many results by design, not an error case.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
from fastmcp.server.elicitation import AcceptedElicitation
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from fastmcp import Context
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def confirm(
|
|
39
|
+
ctx: Context | None, message: str, *, default_when_unsupported: bool
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""Ask a yes/no question.
|
|
42
|
+
|
|
43
|
+
``accept`` → the chosen bool; ``decline``/``cancel`` → ``False``; and when
|
|
44
|
+
elicitation is unsupported or errors (headless client, no ``ctx``) →
|
|
45
|
+
``default_when_unsupported`` — pick that so the non-interactive path keeps
|
|
46
|
+
today's behavior (here, always ``False``: never index without being asked).
|
|
47
|
+
"""
|
|
48
|
+
if ctx is None:
|
|
49
|
+
return default_when_unsupported
|
|
50
|
+
try:
|
|
51
|
+
result = await ctx.elicit(message, response_type=bool)
|
|
52
|
+
except Exception: # noqa: BLE001 — any elicit failure means "can't ask"
|
|
53
|
+
return default_when_unsupported
|
|
54
|
+
if isinstance(result, AcceptedElicitation):
|
|
55
|
+
return bool(result.data)
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def text(ctx: Context | None, message: str, *, default: str) -> str:
|
|
60
|
+
"""Ask for a line of text. A non-empty accepted value is returned; a
|
|
61
|
+
decline/cancel, an empty value, an unsupported client, or no ``ctx`` all
|
|
62
|
+
fall back to ``default``."""
|
|
63
|
+
if ctx is None:
|
|
64
|
+
return default
|
|
65
|
+
try:
|
|
66
|
+
result = await ctx.elicit(message, response_type=str)
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
return default
|
|
69
|
+
if isinstance(result, AcceptedElicitation) and (result.data or "").strip():
|
|
70
|
+
return result.data.strip()
|
|
71
|
+
return default
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def choose_repo(
|
|
75
|
+
ctx: Context | None, message: str, repos: list[str]
|
|
76
|
+
) -> str | None:
|
|
77
|
+
"""Ask the caller to narrow a multi-repo match down to one of ``repos``.
|
|
78
|
+
|
|
79
|
+
Reuses ``text`` for the primitive. The answer is matched against ``repos``
|
|
80
|
+
case-insensitively and whitespace-stripped; an exact match returns that
|
|
81
|
+
repo, and anything else — empty, declined, unsupported, or a value that
|
|
82
|
+
doesn't match any candidate — returns ``None``, meaning "keep every
|
|
83
|
+
match" (today's behavior, unchanged).
|
|
84
|
+
"""
|
|
85
|
+
answer = await text(ctx, message, default="")
|
|
86
|
+
if not answer:
|
|
87
|
+
return None
|
|
88
|
+
normalized = answer.strip().casefold()
|
|
89
|
+
for repo in repos:
|
|
90
|
+
if repo.casefold() == normalized:
|
|
91
|
+
return repo
|
|
92
|
+
return None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* witan-code — Pi extension
|
|
3
|
+
*
|
|
4
|
+
* Pi equivalent of the four Claude Code code-graph hooks:
|
|
5
|
+
* - session_start : seed/refresh the whole repo's Layer-2 code graph in
|
|
6
|
+
* the background (first session builds it, later
|
|
7
|
+
* sessions re-hash and skip unchanged files).
|
|
8
|
+
* - edit / write : incrementally re-index the edited file after the
|
|
9
|
+
* tool runs.
|
|
10
|
+
* - before_agent_start: report whether the code graph is indexed (file
|
|
11
|
+
* count, last-updated) or still being built, with a
|
|
12
|
+
* nudge toward code_* tools over grep.
|
|
13
|
+
* - session_shutdown : opportunistically compact the current repo's store
|
|
14
|
+
* and the shared cross-repo bridge store (throttled;
|
|
15
|
+
* see witan_code.maintenance) — no session-id
|
|
16
|
+
* dependency, unlike workflow-session-checkpoint, so
|
|
17
|
+
* this one *is* mirrored under Pi.
|
|
18
|
+
*
|
|
19
|
+
* Best-effort and non-blocking: a missing CLI, non-git dir, or parse failure
|
|
20
|
+
* never disrupts the session. Requires `witan-code` on PATH
|
|
21
|
+
* (`witan-code setup --agent pi`, or `uv tool install --editable
|
|
22
|
+
* mcp/servers/witan-code`); otherwise it silently no-ops.
|
|
23
|
+
*
|
|
24
|
+
* Install: `witan-code setup --agent pi`, or symlink into
|
|
25
|
+
* ~/.pi/agent/extensions/ (see configs/pi/README.md).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
29
|
+
import { existsSync } from "node:fs";
|
|
30
|
+
import { isAbsolute, resolve } from "node:path";
|
|
31
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
|
|
33
|
+
const SRC_EXT = /\.(py|pyi|ts|tsx|js|jsx|mjs|cjs)$/;
|
|
34
|
+
const EDIT_TOOLS = new Set(["edit", "write"]);
|
|
35
|
+
|
|
36
|
+
function inGitRepo(cwd: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
39
|
+
cwd,
|
|
40
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Run a witan-code subcommand detached in the background; ignore all failures. */
|
|
49
|
+
function runInBackground(args: string[], cwd?: string): void {
|
|
50
|
+
try {
|
|
51
|
+
const child = spawn("witan-code", args, {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: "ignore",
|
|
54
|
+
...(cwd ? { cwd } : {}),
|
|
55
|
+
});
|
|
56
|
+
child.on("error", () => {}); // CLI not installed, etc.
|
|
57
|
+
child.unref();
|
|
58
|
+
} catch {
|
|
59
|
+
/* ignore */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function editedPath(event: any, cwd: string): string | null {
|
|
64
|
+
const input = event?.input ?? {};
|
|
65
|
+
const raw = input.path ?? input.file_path ?? input.filename;
|
|
66
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
67
|
+
const abs = isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
68
|
+
return SRC_EXT.test(abs) ? abs : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function codegraphExtension(pi: ExtensionAPI): void {
|
|
72
|
+
// Paths captured at tool_call, consumed at tool_result (FIFO) — covers Pi
|
|
73
|
+
// builds where tool_result carries no input.
|
|
74
|
+
const pending: string[] = [];
|
|
75
|
+
|
|
76
|
+
// Seed / refresh the whole repo once per session (incremental).
|
|
77
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
78
|
+
if (inGitRepo(ctx.cwd)) runInBackground(["index", ctx.cwd]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Push one entry per edit tool_call (path or "") so the FIFO stays paired
|
|
82
|
+
// 1:1 with tool_result, which always shifts exactly one.
|
|
83
|
+
pi.on("tool_call", (event: any, ctx) => {
|
|
84
|
+
if (!EDIT_TOOLS.has(event?.toolName)) return;
|
|
85
|
+
pending.push(editedPath(event, ctx.cwd) ?? "");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// After a successful edit/write, re-index just that file.
|
|
89
|
+
pi.on("tool_result", (event: any, ctx) => {
|
|
90
|
+
if (!EDIT_TOOLS.has(event?.toolName)) return;
|
|
91
|
+
const queued = pending.shift() ?? "";
|
|
92
|
+
if (event?.isError) return;
|
|
93
|
+
const p = editedPath(event, ctx.cwd) ?? queued;
|
|
94
|
+
if (p && existsSync(p)) runInBackground(["index", p]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Report code-graph readiness before each turn (mirrors the Claude
|
|
98
|
+
// `witan-code inject-context` UserPromptSubmit hook).
|
|
99
|
+
pi.on("before_agent_start", async (event: any, ctx: any) => {
|
|
100
|
+
try {
|
|
101
|
+
const r = spawnSync("witan-code", ["inject-context"], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
timeout: 5000,
|
|
104
|
+
cwd: ctx?.cwd,
|
|
105
|
+
});
|
|
106
|
+
const text = (r.stdout ?? "").trim();
|
|
107
|
+
if (r.status !== 0 || !text) return;
|
|
108
|
+
return { systemPrompt: `${event.systemPrompt ?? ""}\n\n${text}` };
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Opportunistically compact the store(s) on session end (mirrors the
|
|
115
|
+
// Claude `witan-code checkpoint` Stop hook). Detached and non-blocking,
|
|
116
|
+
// like session_start's index — session_shutdown fires before teardown,
|
|
117
|
+
// not after, so this must not wait on the child process.
|
|
118
|
+
pi.on("session_shutdown", async (_event, ctx: any) => {
|
|
119
|
+
if (inGitRepo(ctx?.cwd)) runInBackground(["checkpoint"], ctx.cwd);
|
|
120
|
+
});
|
|
121
|
+
}
|