agentforge-graph 0.3.2__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.
- agentforge_graph/__init__.py +6 -0
- agentforge_graph/chunking/__init__.py +12 -0
- agentforge_graph/chunking/cast.py +159 -0
- agentforge_graph/chunking/chunk.py +19 -0
- agentforge_graph/chunking/tokens.py +15 -0
- agentforge_graph/cli.py +607 -0
- agentforge_graph/config.py +259 -0
- agentforge_graph/core/__init__.py +54 -0
- agentforge_graph/core/conformance.py +270 -0
- agentforge_graph/core/contracts.py +163 -0
- agentforge_graph/core/kinds.py +68 -0
- agentforge_graph/core/models.py +134 -0
- agentforge_graph/core/provenance.py +62 -0
- agentforge_graph/core/symbols.py +116 -0
- agentforge_graph/embed/__init__.py +28 -0
- agentforge_graph/embed/base.py +22 -0
- agentforge_graph/embed/bedrock.py +85 -0
- agentforge_graph/embed/fake.py +34 -0
- agentforge_graph/embed/openai.py +67 -0
- agentforge_graph/embed/pipeline.py +184 -0
- agentforge_graph/embed/registry.py +66 -0
- agentforge_graph/embed/report.py +15 -0
- agentforge_graph/enrich/__init__.py +70 -0
- agentforge_graph/enrich/anthropic.py +38 -0
- agentforge_graph/enrich/anthropic_client.py +109 -0
- agentforge_graph/enrich/bedrock.py +24 -0
- agentforge_graph/enrich/bedrock_client.py +115 -0
- agentforge_graph/enrich/bedrock_summarizer.py +23 -0
- agentforge_graph/enrich/claude.py +172 -0
- agentforge_graph/enrich/enricher.py +108 -0
- agentforge_graph/enrich/governs.py +173 -0
- agentforge_graph/enrich/governs_enricher.py +152 -0
- agentforge_graph/enrich/heuristics.py +224 -0
- agentforge_graph/enrich/judge.py +63 -0
- agentforge_graph/enrich/registry.py +133 -0
- agentforge_graph/enrich/report.py +60 -0
- agentforge_graph/enrich/summarizer.py +62 -0
- agentforge_graph/enrich/summary_enricher.py +211 -0
- agentforge_graph/enrich/taxonomy.py +38 -0
- agentforge_graph/frameworks/__init__.py +29 -0
- agentforge_graph/frameworks/base.py +75 -0
- agentforge_graph/frameworks/detect.py +124 -0
- agentforge_graph/frameworks/extractor.py +63 -0
- agentforge_graph/frameworks/orm.py +93 -0
- agentforge_graph/frameworks/packs/_js_ast.py +56 -0
- agentforge_graph/frameworks/packs/_python_ast.py +157 -0
- agentforge_graph/frameworks/packs/django/__init__.py +240 -0
- agentforge_graph/frameworks/packs/django/models.scm +7 -0
- agentforge_graph/frameworks/packs/express/__init__.py +133 -0
- agentforge_graph/frameworks/packs/express/routes.scm +8 -0
- agentforge_graph/frameworks/packs/fastapi/__init__.py +210 -0
- agentforge_graph/frameworks/packs/fastapi/depends.scm +6 -0
- agentforge_graph/frameworks/packs/fastapi/routes.scm +10 -0
- agentforge_graph/frameworks/packs/flask/__init__.py +143 -0
- agentforge_graph/frameworks/packs/flask/routes.scm +11 -0
- agentforge_graph/frameworks/packs/nestjs/__init__.py +205 -0
- agentforge_graph/frameworks/packs/nestjs/routes.scm +6 -0
- agentforge_graph/frameworks/packs/spring/__init__.py +267 -0
- agentforge_graph/frameworks/packs/spring/routes.scm +6 -0
- agentforge_graph/frameworks/packs/sqlalchemy/__init__.py +250 -0
- agentforge_graph/frameworks/packs/sqlalchemy/models.scm +7 -0
- agentforge_graph/frameworks/registry.py +44 -0
- agentforge_graph/ingest/__init__.py +30 -0
- agentforge_graph/ingest/codegraph.py +847 -0
- agentforge_graph/ingest/extractor.py +353 -0
- agentforge_graph/ingest/incremental/__init__.py +25 -0
- agentforge_graph/ingest/incremental/detect.py +118 -0
- agentforge_graph/ingest/incremental/dirty.py +61 -0
- agentforge_graph/ingest/incremental/indexer.py +218 -0
- agentforge_graph/ingest/incremental/meta.py +72 -0
- agentforge_graph/ingest/incremental/ports.py +39 -0
- agentforge_graph/ingest/pack.py +160 -0
- agentforge_graph/ingest/packs/__init__.py +34 -0
- agentforge_graph/ingest/packs/cpp/__init__.py +35 -0
- agentforge_graph/ingest/packs/cpp/references.scm +15 -0
- agentforge_graph/ingest/packs/cpp/structure.scm +49 -0
- agentforge_graph/ingest/packs/csharp/__init__.py +35 -0
- agentforge_graph/ingest/packs/csharp/references.scm +12 -0
- agentforge_graph/ingest/packs/csharp/structure.scm +45 -0
- agentforge_graph/ingest/packs/go/__init__.py +38 -0
- agentforge_graph/ingest/packs/go/references.scm +12 -0
- agentforge_graph/ingest/packs/go/structure.scm +64 -0
- agentforge_graph/ingest/packs/java/__init__.py +35 -0
- agentforge_graph/ingest/packs/java/references.scm +12 -0
- agentforge_graph/ingest/packs/java/structure.scm +38 -0
- agentforge_graph/ingest/packs/javascript/__init__.py +34 -0
- agentforge_graph/ingest/packs/javascript/references.scm +11 -0
- agentforge_graph/ingest/packs/javascript/structure.scm +166 -0
- agentforge_graph/ingest/packs/php/__init__.py +35 -0
- agentforge_graph/ingest/packs/php/references.scm +15 -0
- agentforge_graph/ingest/packs/php/structure.scm +44 -0
- agentforge_graph/ingest/packs/python/__init__.py +25 -0
- agentforge_graph/ingest/packs/python/references.scm +14 -0
- agentforge_graph/ingest/packs/python/structure.scm +57 -0
- agentforge_graph/ingest/packs/ruby/__init__.py +37 -0
- agentforge_graph/ingest/packs/ruby/references.scm +12 -0
- agentforge_graph/ingest/packs/ruby/structure.scm +37 -0
- agentforge_graph/ingest/packs/rust/__init__.py +39 -0
- agentforge_graph/ingest/packs/rust/references.scm +12 -0
- agentforge_graph/ingest/packs/rust/structure.scm +46 -0
- agentforge_graph/ingest/packs/typescript/__init__.py +31 -0
- agentforge_graph/ingest/packs/typescript/references.scm +11 -0
- agentforge_graph/ingest/packs/typescript/structure.scm +99 -0
- agentforge_graph/ingest/pipeline.py +134 -0
- agentforge_graph/ingest/report.py +84 -0
- agentforge_graph/ingest/resolver.py +467 -0
- agentforge_graph/ingest/source.py +79 -0
- agentforge_graph/knowledge/__init__.py +28 -0
- agentforge_graph/knowledge/adr.py +136 -0
- agentforge_graph/knowledge/commits.py +152 -0
- agentforge_graph/knowledge/ingest.py +312 -0
- agentforge_graph/knowledge/mentions.py +71 -0
- agentforge_graph/knowledge/report.py +32 -0
- agentforge_graph/main.py +21 -0
- agentforge_graph/providers.py +36 -0
- agentforge_graph/repomap/__init__.py +14 -0
- agentforge_graph/repomap/rank.py +161 -0
- agentforge_graph/repomap/render.py +55 -0
- agentforge_graph/repomap/repomap.py +66 -0
- agentforge_graph/retrieve/__init__.py +21 -0
- agentforge_graph/retrieve/pack.py +76 -0
- agentforge_graph/retrieve/rerank.py +251 -0
- agentforge_graph/retrieve/retriever.py +286 -0
- agentforge_graph/retrieve/scoring.py +36 -0
- agentforge_graph/serve/__init__.py +19 -0
- agentforge_graph/serve/engine.py +204 -0
- agentforge_graph/serve/http_runner.py +133 -0
- agentforge_graph/serve/server.py +110 -0
- agentforge_graph/serve/tools.py +307 -0
- agentforge_graph/store/__init__.py +32 -0
- agentforge_graph/store/_rowmap.py +102 -0
- agentforge_graph/store/errors.py +22 -0
- agentforge_graph/store/facade.py +89 -0
- agentforge_graph/store/kuzu_store.py +380 -0
- agentforge_graph/store/lance_store.py +146 -0
- agentforge_graph/store/neo4j_store.py +294 -0
- agentforge_graph/store/pgvector_store.py +170 -0
- agentforge_graph/store/registry.py +45 -0
- agentforge_graph/temporal/__init__.py +36 -0
- agentforge_graph/temporal/backfill.py +338 -0
- agentforge_graph/temporal/events.py +82 -0
- agentforge_graph/temporal/index.py +190 -0
- agentforge_graph/temporal/mining.py +190 -0
- agentforge_graph/temporal/recorder.py +114 -0
- agentforge_graph/temporal/store.py +282 -0
- agentforge_graph-0.3.2.dist-info/METADATA +291 -0
- agentforge_graph-0.3.2.dist-info/RECORD +151 -0
- agentforge_graph-0.3.2.dist-info/WHEEL +4 -0
- agentforge_graph-0.3.2.dist-info/entry_points.txt +3 -0
- agentforge_graph-0.3.2.dist-info/licenses/LICENSE +202 -0
- agentforge_graph-0.3.2.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""The read-only CKG tools (feat-008), thin over feat-006/007/009.
|
|
2
|
+
|
|
3
|
+
Each is an AgentForge ``Tool`` holding the shared ``_Engine``; ``run`` clamps
|
|
4
|
+
params to ``ServeConfig`` and returns a JSON string (structured) or text (the
|
|
5
|
+
map) — MCP coerces results via ``str()``, so JSON keeps structure intact.
|
|
6
|
+
Every structured envelope carries ``indexed_commit`` + ``dirty`` (staleness)
|
|
7
|
+
and a ``truncated`` flag. Names & schemas are locked at v1.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, ClassVar
|
|
14
|
+
|
|
15
|
+
from agentforge_core.contracts.tool import Tool
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from agentforge_graph.chunking import estimate_tokens
|
|
19
|
+
from agentforge_graph.core import EdgeKind, GraphQuery
|
|
20
|
+
|
|
21
|
+
from .engine import TOOL_API_VERSION, _Engine
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RepoMapInput(BaseModel):
|
|
25
|
+
budget_tokens: int = Field(default=2000, description="token budget for the map")
|
|
26
|
+
focus: list[str] = Field(
|
|
27
|
+
default_factory=list, description="paths or symbol ids to rank the map around"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SearchInput(BaseModel):
|
|
32
|
+
query: str = Field(description="natural-language question about the code")
|
|
33
|
+
k: int = Field(default=8, description="number of vector hits (clamped to serve.max_k)")
|
|
34
|
+
mode: str = Field(default="context", description="context | similar | definition | impact")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SymbolInput(BaseModel):
|
|
38
|
+
symbol_id: str = Field(default="", description="exact symbol id, if known")
|
|
39
|
+
name: str = Field(default="", description="symbol name (use with path) when id is unknown")
|
|
40
|
+
path: str = Field(default="", description="file path for a name lookup")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ImpactInput(BaseModel):
|
|
44
|
+
symbol_id: str = Field(description="symbol whose reverse dependencies to trace")
|
|
45
|
+
depth: int = Field(default=1, description="hops (clamped to serve.max_depth)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NeighborsInput(BaseModel):
|
|
49
|
+
symbol_id: str = Field(description="symbol id")
|
|
50
|
+
edge_kinds: list[str] = Field(
|
|
51
|
+
default_factory=list, description="edge kinds to follow (e.g. CALLS, CONTAINS); default all"
|
|
52
|
+
)
|
|
53
|
+
depth: int = Field(default=1, description="hops (clamped to serve.max_depth)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RoutesInput(BaseModel):
|
|
57
|
+
method: str = Field(default="", description="filter by HTTP method, e.g. GET (optional)")
|
|
58
|
+
path: str = Field(default="", description="filter by path prefix, e.g. /users (optional)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DecisionsInput(BaseModel):
|
|
62
|
+
scope: str = Field(default="", description="restrict to decisions governing a path prefix")
|
|
63
|
+
status: str = Field(default="", description="filter by status, e.g. accepted (optional)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ExplainInput(BaseModel):
|
|
67
|
+
symbol_id: str = Field(description="exact symbol id to explain")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HistoryInput(BaseModel):
|
|
71
|
+
symbol_id: str = Field(description="exact symbol id whose git history to report")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class EmptyInput(BaseModel):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _CkgTool(Tool):
|
|
79
|
+
capabilities: ClassVar[frozenset[str]] = frozenset()
|
|
80
|
+
|
|
81
|
+
def __init__(self, engine: _Engine) -> None:
|
|
82
|
+
self._engine = engine
|
|
83
|
+
|
|
84
|
+
async def _pack_json(self, pack: Any) -> str:
|
|
85
|
+
data = pack.to_dict()
|
|
86
|
+
data.update(await self._engine.staleness())
|
|
87
|
+
data["tool_api_version"] = TOOL_API_VERSION
|
|
88
|
+
cap = self._engine.serve.response_token_cap
|
|
89
|
+
truncated = False
|
|
90
|
+
while len(data.get("items", [])) > 1 and estimate_tokens(json.dumps(data)) > cap:
|
|
91
|
+
data["items"].pop()
|
|
92
|
+
truncated = True
|
|
93
|
+
if truncated:
|
|
94
|
+
data.setdefault("notes", []).append("response truncated to fit response_token_cap")
|
|
95
|
+
data["truncated"] = truncated
|
|
96
|
+
return json.dumps(data, indent=2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CkgRepoMap(_CkgTool):
|
|
100
|
+
name: ClassVar[str] = "ckg_repo_map"
|
|
101
|
+
description: ClassVar[str] = (
|
|
102
|
+
"Orient in a codebase: a budget-aware, centrality-ranked map of the most "
|
|
103
|
+
"structurally important symbols (signatures), grouped by file. Call this FIRST "
|
|
104
|
+
"to understand an unfamiliar repo. Pass `focus` (paths/symbol ids) to rank the "
|
|
105
|
+
"map around a working set."
|
|
106
|
+
)
|
|
107
|
+
input_schema: ClassVar[type[BaseModel]] = RepoMapInput
|
|
108
|
+
|
|
109
|
+
async def run(self, **kwargs: Any) -> str:
|
|
110
|
+
repomap = await self._engine.repomap()
|
|
111
|
+
budget = min(int(kwargs.get("budget_tokens", 2000)), self._engine.serve.response_token_cap)
|
|
112
|
+
text = await repomap.render(budget_tokens=budget, focus=kwargs.get("focus") or None)
|
|
113
|
+
return text or "(empty repo map)"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CkgSearch(_CkgTool):
|
|
117
|
+
name: ClassVar[str] = "ckg_search"
|
|
118
|
+
description: ClassVar[str] = (
|
|
119
|
+
"Search the codebase for code relevant to a natural-language question. Returns "
|
|
120
|
+
"ranked, CONNECTED context: matching chunks plus their symbols and neighbors. "
|
|
121
|
+
"`mode`: 'context' (default), 'similar' (just similar code), 'definition' (a "
|
|
122
|
+
"symbol's chunks), 'impact' (reverse deps — prefer ckg_impact). Take a result's "
|
|
123
|
+
"`id` to chain into ckg_impact / ckg_neighbors / ckg_symbol."
|
|
124
|
+
)
|
|
125
|
+
input_schema: ClassVar[type[BaseModel]] = SearchInput
|
|
126
|
+
|
|
127
|
+
async def run(self, **kwargs: Any) -> str:
|
|
128
|
+
retriever = await self._engine.retriever()
|
|
129
|
+
k = min(int(kwargs.get("k", 8)), self._engine.serve.max_k)
|
|
130
|
+
pack = await retriever.retrieve(
|
|
131
|
+
query=kwargs["query"], k=k, mode=kwargs.get("mode", "context")
|
|
132
|
+
)
|
|
133
|
+
return await self._pack_json(pack)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CkgSymbol(_CkgTool):
|
|
137
|
+
name: ClassVar[str] = "ckg_symbol"
|
|
138
|
+
description: ClassVar[str] = (
|
|
139
|
+
"Look up a specific symbol's definition: the symbol, its chunks and members. "
|
|
140
|
+
"Provide `symbol_id` (exact) or `name` + `path`. When the temporal layer is "
|
|
141
|
+
"enabled, items carry `temporal` (churn_90d, top_authors, introduced, "
|
|
142
|
+
"last_changed) — recency/ownership signals for triage."
|
|
143
|
+
)
|
|
144
|
+
input_schema: ClassVar[type[BaseModel]] = SymbolInput
|
|
145
|
+
|
|
146
|
+
async def run(self, **kwargs: Any) -> str:
|
|
147
|
+
symbol_id = kwargs.get("symbol_id", "")
|
|
148
|
+
if not symbol_id:
|
|
149
|
+
cg = await self._engine.code_graph()
|
|
150
|
+
res = await cg.store.graph.query(
|
|
151
|
+
GraphQuery(name=kwargs.get("name") or None, path_prefix=kwargs.get("path") or None)
|
|
152
|
+
)
|
|
153
|
+
symbol_id = res.nodes[0].id if res.nodes else ""
|
|
154
|
+
if not symbol_id:
|
|
155
|
+
return json.dumps({"error": "symbol not found", "tool_api_version": TOOL_API_VERSION})
|
|
156
|
+
retriever = await self._engine.retriever()
|
|
157
|
+
depth = min(1, self._engine.serve.max_depth)
|
|
158
|
+
pack = await retriever.retrieve(symbol=symbol_id, mode="definition", depth=depth)
|
|
159
|
+
return await self._pack_json(pack)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CkgImpact(_CkgTool):
|
|
163
|
+
name: ClassVar[str] = "ckg_impact"
|
|
164
|
+
description: ClassVar[str] = (
|
|
165
|
+
"Find what DEPENDS ON a symbol (reverse CALLS/IMPORTS/IMPLEMENTS): 'who calls "
|
|
166
|
+
"this', 'what breaks if I change this'. The impact question grep cannot answer."
|
|
167
|
+
)
|
|
168
|
+
input_schema: ClassVar[type[BaseModel]] = ImpactInput
|
|
169
|
+
|
|
170
|
+
async def run(self, **kwargs: Any) -> str:
|
|
171
|
+
retriever = await self._engine.retriever()
|
|
172
|
+
depth = min(int(kwargs.get("depth", 1)), self._engine.serve.max_depth)
|
|
173
|
+
pack = await retriever.retrieve(symbol=kwargs["symbol_id"], mode="impact", depth=depth)
|
|
174
|
+
return await self._pack_json(pack)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CkgNeighbors(_CkgTool):
|
|
178
|
+
name: ClassVar[str] = "ckg_neighbors"
|
|
179
|
+
description: ClassVar[str] = (
|
|
180
|
+
"List a symbol's typed graph neighbors (edges in both directions), optionally "
|
|
181
|
+
"filtered by edge kind (CALLS, CONTAINS, IMPORTS, INHERITS, REFERENCES, CHUNK_OF)."
|
|
182
|
+
)
|
|
183
|
+
input_schema: ClassVar[type[BaseModel]] = NeighborsInput
|
|
184
|
+
|
|
185
|
+
async def run(self, **kwargs: Any) -> str:
|
|
186
|
+
cg = await self._engine.code_graph()
|
|
187
|
+
kinds = [EdgeKind(k) for k in kwargs.get("edge_kinds", [])] or None
|
|
188
|
+
depth = min(int(kwargs.get("depth", 1)), self._engine.serve.max_depth)
|
|
189
|
+
start = kwargs["symbol_id"]
|
|
190
|
+
seen: set[tuple[str, str, str]] = set()
|
|
191
|
+
edges: list[dict[str, str]] = []
|
|
192
|
+
frontier = {start}
|
|
193
|
+
visited = {start}
|
|
194
|
+
for _ in range(depth):
|
|
195
|
+
nxt: set[str] = set()
|
|
196
|
+
for nid in frontier:
|
|
197
|
+
for e in await cg.store.graph.adjacent(nid, kinds, "both"):
|
|
198
|
+
key = (e.src, e.dst, e.kind.value)
|
|
199
|
+
if key not in seen:
|
|
200
|
+
seen.add(key)
|
|
201
|
+
edges.append(
|
|
202
|
+
{
|
|
203
|
+
"src": e.src,
|
|
204
|
+
"dst": e.dst,
|
|
205
|
+
"kind": e.kind.value,
|
|
206
|
+
"provenance": e.provenance.source.value,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
other = e.dst if e.src == nid else e.src
|
|
210
|
+
if other not in visited:
|
|
211
|
+
visited.add(other)
|
|
212
|
+
nxt.add(other)
|
|
213
|
+
frontier = nxt
|
|
214
|
+
envelope = await self._engine.staleness()
|
|
215
|
+
return json.dumps(
|
|
216
|
+
{"symbol_id": start, "edges": edges, **envelope, "tool_api_version": TOOL_API_VERSION},
|
|
217
|
+
indent=2,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class CkgStatus(_CkgTool):
|
|
222
|
+
name: ClassVar[str] = "ckg_status"
|
|
223
|
+
description: ClassVar[str] = (
|
|
224
|
+
"Report index status: the indexed git commit, whether it is stale vs the working "
|
|
225
|
+
"tree (`dirty`), node counts by kind, and the tool-API version. If results seem "
|
|
226
|
+
"out of date and `dirty` is true, tell the user to re-run `ckg index`."
|
|
227
|
+
)
|
|
228
|
+
input_schema: ClassVar[type[BaseModel]] = EmptyInput
|
|
229
|
+
|
|
230
|
+
async def run(self, **kwargs: Any) -> str:
|
|
231
|
+
return json.dumps(await self._engine.status(), indent=2)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class CkgRoutes(_CkgTool):
|
|
235
|
+
name: ClassVar[str] = "ckg_routes"
|
|
236
|
+
description: ClassVar[str] = (
|
|
237
|
+
"List the app's HTTP API surface: every framework route (e.g. FastAPI) with its "
|
|
238
|
+
"method, path pattern and handler symbol id. Filter by `method` and/or `path` prefix. "
|
|
239
|
+
"Take a route's `handler` into ckg_symbol / ckg_impact to see the handler and what it "
|
|
240
|
+
"touches. Returns empty if no web framework is detected."
|
|
241
|
+
)
|
|
242
|
+
input_schema: ClassVar[type[BaseModel]] = RoutesInput
|
|
243
|
+
|
|
244
|
+
async def run(self, **kwargs: Any) -> str:
|
|
245
|
+
data = await self._engine.routes(
|
|
246
|
+
method=kwargs.get("method", ""), path=kwargs.get("path", "")
|
|
247
|
+
)
|
|
248
|
+
return json.dumps(data, indent=2)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class CkgDecisions(_CkgTool):
|
|
252
|
+
name: ClassVar[str] = "ckg_decisions"
|
|
253
|
+
description: ClassVar[str] = (
|
|
254
|
+
"List the architecture decisions (ADRs) governing the codebase: each decision's "
|
|
255
|
+
"status, date, title and the symbols/files it governs. Filter by `scope` (a path "
|
|
256
|
+
"prefix the decision governs) and/or `status` (e.g. accepted). Call this before a "
|
|
257
|
+
"refactor to check no documented decision forbids the change. Empty if no ADRs."
|
|
258
|
+
)
|
|
259
|
+
input_schema: ClassVar[type[BaseModel]] = DecisionsInput
|
|
260
|
+
|
|
261
|
+
async def run(self, **kwargs: Any) -> str:
|
|
262
|
+
data = await self._engine.decisions(
|
|
263
|
+
scope=kwargs.get("scope", ""), status=kwargs.get("status", "")
|
|
264
|
+
)
|
|
265
|
+
return json.dumps(data, indent=2)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class CkgExplain(_CkgTool):
|
|
269
|
+
name: ClassVar[str] = "ckg_explain"
|
|
270
|
+
description: ClassVar[str] = (
|
|
271
|
+
"Explain a symbol: its LLM-derived design-pattern tags (e.g. 'Repository', with "
|
|
272
|
+
"confidence + rationale) and its 1-hop typed graph facts. Use to learn what role a "
|
|
273
|
+
"class/function plays before changing it. Tags are [llm]-provenance; empty until "
|
|
274
|
+
"`ckg enrich` has run."
|
|
275
|
+
)
|
|
276
|
+
input_schema: ClassVar[type[BaseModel]] = ExplainInput
|
|
277
|
+
|
|
278
|
+
async def run(self, **kwargs: Any) -> str:
|
|
279
|
+
return json.dumps(await self._engine.explain(kwargs["symbol_id"]), indent=2)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class CkgHistory(_CkgTool):
|
|
283
|
+
name: ClassVar[str] = "ckg_history"
|
|
284
|
+
description: ClassVar[str] = (
|
|
285
|
+
"Report a symbol's git evolution (feat-009 temporal): when it was introduced and "
|
|
286
|
+
"last changed, churn over the last 30/90 days, its top authors, and its lifecycle "
|
|
287
|
+
"events. Use to gauge recency/ownership before changing a symbol or to triage a "
|
|
288
|
+
"regression. Returns `available: false` if the temporal layer is off or unindexed."
|
|
289
|
+
)
|
|
290
|
+
input_schema: ClassVar[type[BaseModel]] = HistoryInput
|
|
291
|
+
|
|
292
|
+
async def run(self, **kwargs: Any) -> str:
|
|
293
|
+
return json.dumps(await self._engine.history(kwargs["symbol_id"]), indent=2)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
ALL_TOOLS = [
|
|
297
|
+
CkgRepoMap,
|
|
298
|
+
CkgSearch,
|
|
299
|
+
CkgSymbol,
|
|
300
|
+
CkgImpact,
|
|
301
|
+
CkgNeighbors,
|
|
302
|
+
CkgStatus,
|
|
303
|
+
CkgRoutes,
|
|
304
|
+
CkgDecisions,
|
|
305
|
+
CkgExplain,
|
|
306
|
+
CkgHistory,
|
|
307
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""agentforge_graph.store — persistent storage adapters (feat-003).
|
|
2
|
+
|
|
3
|
+
Embedded-first (ADR-0006): the default ``Store`` writes a Kuzu graph DB
|
|
4
|
+
and a LanceDB vector index under ``.ckg/`` with no server. Server adapters
|
|
5
|
+
(Neo4j, FalkorDB) register out-of-tree via entry points. Imports nothing
|
|
6
|
+
from ``agentforge`` (ADR-0001).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .errors import (
|
|
12
|
+
DriverNotFound,
|
|
13
|
+
SchemaVersionError,
|
|
14
|
+
StoreConfigError,
|
|
15
|
+
StoreError,
|
|
16
|
+
)
|
|
17
|
+
from .facade import Store
|
|
18
|
+
from .kuzu_store import KuzuGraphStore
|
|
19
|
+
from .lance_store import LanceVectorStore
|
|
20
|
+
from .registry import graph_driver, vector_driver
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Store",
|
|
24
|
+
"KuzuGraphStore",
|
|
25
|
+
"LanceVectorStore",
|
|
26
|
+
"graph_driver",
|
|
27
|
+
"vector_driver",
|
|
28
|
+
"StoreError",
|
|
29
|
+
"StoreConfigError",
|
|
30
|
+
"DriverNotFound",
|
|
31
|
+
"SchemaVersionError",
|
|
32
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pure ``Node``/``Edge`` ↔ property-row mapping, shared by every property-graph
|
|
2
|
+
adapter (Kuzu embedded, Neo4j server — ENH-004). No DB driver imports: the open
|
|
3
|
+
schema (arbitrary kinds, free-form ``attrs``) is flattened to a fixed set of
|
|
4
|
+
scalar properties (``kind``, ``name``, span, provenance, ``origin_path``) with
|
|
5
|
+
``attrs`` as a JSON string, so an unrecognized kind round-trips with no DDL
|
|
6
|
+
change (ADR-0005). Each backend supplies the storage; this supplies the shape.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentforge_graph.core import Edge, EdgeKind, Node, NodeKind, Provenance, Source, SymbolID
|
|
15
|
+
|
|
16
|
+
# Trust ordering (PARSED is highest-trust): a node passes a ``min_source`` floor
|
|
17
|
+
# iff its source rank is >= the floor's.
|
|
18
|
+
SOURCE_RANK = {Source.LLM: 0, Source.RESOLVED: 1, Source.MANUAL: 1, Source.PARSED: 2}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dump_attrs(attrs: dict[str, Any]) -> str:
|
|
22
|
+
return json.dumps(attrs, sort_keys=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_attrs(s: str | None) -> dict[str, Any]:
|
|
26
|
+
return json.loads(s) if s else {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def acceptable_sources(floor: Source) -> list[str]:
|
|
30
|
+
threshold = SOURCE_RANK[floor]
|
|
31
|
+
return [s.value for s, rank in SOURCE_RANK.items() if rank >= threshold]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def node_params(node: Node, origin_path: str) -> dict[str, Any]:
|
|
35
|
+
span_start, span_end = node.span if node.span is not None else (None, None)
|
|
36
|
+
p = node.provenance
|
|
37
|
+
return {
|
|
38
|
+
"id": node.id,
|
|
39
|
+
"kind": node.kind.value,
|
|
40
|
+
"name": node.name,
|
|
41
|
+
"span_start": span_start,
|
|
42
|
+
"span_end": span_end,
|
|
43
|
+
"attrs": dump_attrs(node.attrs),
|
|
44
|
+
"sym_path": SymbolID.parse(node.id).path,
|
|
45
|
+
"prov_source": p.source.value,
|
|
46
|
+
"prov_extractor": p.extractor,
|
|
47
|
+
"prov_commit": p.commit,
|
|
48
|
+
"prov_confidence": p.confidence,
|
|
49
|
+
"origin_path": origin_path,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def edge_params(edge: Edge, origin_path: str) -> dict[str, Any]:
|
|
54
|
+
p = edge.provenance
|
|
55
|
+
return {
|
|
56
|
+
"src": edge.src,
|
|
57
|
+
"dst": edge.dst,
|
|
58
|
+
"kind": edge.kind.value,
|
|
59
|
+
"attrs": dump_attrs(edge.attrs),
|
|
60
|
+
"prov_source": p.source.value,
|
|
61
|
+
"prov_extractor": p.extractor,
|
|
62
|
+
"prov_commit": p.commit,
|
|
63
|
+
"prov_confidence": p.confidence,
|
|
64
|
+
# An edge that carries its own owner file (resolver edges, feat-004) wins;
|
|
65
|
+
# otherwise the caller's stamp (the upserted file's path).
|
|
66
|
+
"origin_path": edge.origin_path or origin_path,
|
|
67
|
+
"resolved_from": "",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prov_from_row(d: dict[str, Any]) -> Provenance:
|
|
72
|
+
# The validating constructor — a corrupt row fails loudly, not silently.
|
|
73
|
+
return Provenance(
|
|
74
|
+
source=Source(d["prov_source"]),
|
|
75
|
+
extractor=d["prov_extractor"],
|
|
76
|
+
commit=d["prov_commit"],
|
|
77
|
+
confidence=d["prov_confidence"],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def node_from_row(d: dict[str, Any]) -> Node:
|
|
82
|
+
# span may be absent (Neo4j drops null properties) or present-but-None (Kuzu).
|
|
83
|
+
span_start = d.get("span_start")
|
|
84
|
+
span = (span_start, d.get("span_end")) if span_start is not None else None
|
|
85
|
+
return Node(
|
|
86
|
+
id=d["id"],
|
|
87
|
+
kind=NodeKind(d["kind"]),
|
|
88
|
+
name=d["name"],
|
|
89
|
+
span=span,
|
|
90
|
+
attrs=load_attrs(d["attrs"]),
|
|
91
|
+
provenance=prov_from_row(d),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def edge_from_row(d: dict[str, Any], src: str, dst: str) -> Edge:
|
|
96
|
+
return Edge(
|
|
97
|
+
src=src,
|
|
98
|
+
dst=dst,
|
|
99
|
+
kind=EdgeKind(d["kind"]),
|
|
100
|
+
attrs=load_attrs(d["attrs"]),
|
|
101
|
+
provenance=prov_from_row(d),
|
|
102
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Storage-layer errors. All raised at ``Store.open`` / adapter open time
|
|
2
|
+
(fail-at-startup), never mid-index — a half-written graph is worse than a
|
|
3
|
+
refused one."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StoreError(Exception):
|
|
9
|
+
"""Base for all storage-adapter errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StoreConfigError(StoreError):
|
|
13
|
+
"""Malformed or unreadable ``ckg.yaml`` ``store:`` block."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DriverNotFound(StoreConfigError):
|
|
17
|
+
"""Config names a graph/vector driver that isn't registered."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SchemaVersionError(StoreError):
|
|
21
|
+
"""On-disk index schema version differs from this build's. 0.x policy
|
|
22
|
+
is to rebuild the index (the data is derivable)."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The ``Store`` facade — one ``GraphStore`` + one ``VectorStore`` resolved
|
|
2
|
+
from ``ckg.yaml``, plus the vector→graph join (``expand``) that retrieval
|
|
3
|
+
(feat-006) builds on. Embedded-first: the default writes ``.ckg/graph.kuzu``
|
|
4
|
+
and ``.ckg/vectors.lance`` under the repo root (ADR-0006).
|
|
5
|
+
|
|
6
|
+
All failure modes (unknown driver, schema mismatch, malformed config) raise
|
|
7
|
+
at ``open`` — never mid-index.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from agentforge_graph.config import StoreConfig
|
|
16
|
+
from agentforge_graph.core import EdgeKind, GraphStore, Node, QueryResult, ScoredRef, VectorStore
|
|
17
|
+
|
|
18
|
+
from .errors import SchemaVersionError
|
|
19
|
+
from .registry import graph_driver, vector_driver
|
|
20
|
+
|
|
21
|
+
# Store-level on-disk layout version. Bumped when the .ckg/ layout changes;
|
|
22
|
+
# 0.x policy on mismatch is rebuild (the index is derivable — ADR-0006).
|
|
23
|
+
STORE_SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Store:
|
|
27
|
+
"""Owns a graph store and a vector store, resolved from config."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, graph: GraphStore, vectors: VectorStore, config: StoreConfig) -> None:
|
|
30
|
+
self.graph = graph
|
|
31
|
+
self.vectors = vectors
|
|
32
|
+
self.config = config
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
async def open(cls, repo_path: str | Path = ".", config: str | Path | None = None) -> Store:
|
|
36
|
+
"""Resolve drivers from ``config`` (a ckg.yaml path) and open the
|
|
37
|
+
embedded index under ``repo_path``/<store.path>. Raises before any
|
|
38
|
+
store is opened if the config or on-disk schema is bad."""
|
|
39
|
+
cfg = StoreConfig.load(config)
|
|
40
|
+
root = Path(repo_path) / cfg.path
|
|
41
|
+
_check_or_init_meta(root) # fail-at-startup on schema mismatch
|
|
42
|
+
graph_cls = graph_driver(cfg.graph.driver)
|
|
43
|
+
vector_cls = vector_driver(cfg.vectors.driver)
|
|
44
|
+
# Embedded drivers use the path under .ckg/; server drivers (ENH-004)
|
|
45
|
+
# ignore the path and read connection details from their config block.
|
|
46
|
+
graph: GraphStore = await graph_cls.open(root / "graph.kuzu", config=cfg.graph.config)
|
|
47
|
+
vectors: VectorStore = await vector_cls.open(
|
|
48
|
+
root / "vectors.lance", config=cfg.vectors.config
|
|
49
|
+
)
|
|
50
|
+
return cls(graph, vectors, cfg)
|
|
51
|
+
|
|
52
|
+
async def expand(
|
|
53
|
+
self,
|
|
54
|
+
refs: list[ScoredRef],
|
|
55
|
+
kinds: list[EdgeKind] | None = None,
|
|
56
|
+
depth: int = 1,
|
|
57
|
+
) -> QueryResult:
|
|
58
|
+
"""Join vector hits back into the graph: for each ref, collect the
|
|
59
|
+
node and its ``kinds``-edge neighborhood within ``depth`` hops. The
|
|
60
|
+
single place the graph+vector join lives (feat-006)."""
|
|
61
|
+
nodes: dict[str, Node] = {}
|
|
62
|
+
for r in refs:
|
|
63
|
+
hit = await self.graph.get(r.ref)
|
|
64
|
+
if hit is not None:
|
|
65
|
+
nodes[hit.id] = hit
|
|
66
|
+
for nb in await self.graph.neighbors(r.ref, kinds, depth):
|
|
67
|
+
nodes[nb.id] = nb
|
|
68
|
+
return QueryResult(nodes=list(nodes.values()))
|
|
69
|
+
|
|
70
|
+
async def close(self) -> None:
|
|
71
|
+
await self.graph.close()
|
|
72
|
+
await self.vectors.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_or_init_meta(root: Path) -> None:
|
|
76
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
meta = root / "meta.json"
|
|
78
|
+
if meta.exists():
|
|
79
|
+
data = json.loads(meta.read_text())
|
|
80
|
+
on_disk = data.get("schema_version")
|
|
81
|
+
if on_disk != STORE_SCHEMA_VERSION:
|
|
82
|
+
raise SchemaVersionError(
|
|
83
|
+
f"index at {root} is schema v{on_disk}, this build expects "
|
|
84
|
+
f"v{STORE_SCHEMA_VERSION}; rebuild the index (0.x policy)"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
meta.write_text(
|
|
88
|
+
json.dumps({"schema_version": STORE_SCHEMA_VERSION, "indexed_commit": ""}, indent=2)
|
|
89
|
+
)
|