@0dai-dev/cli 4.3.6 → 4.3.7
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.
- package/README.md +12 -11
- package/bin/0dai.js +127 -30
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +506 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +209 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +38 -10
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +934 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +95 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Graph-aware context slicing — agents get relevant graph subsets, not everything.
|
|
3
|
+
|
|
4
|
+
Given a task type and file scope, performs BFS traversal from seed nodes,
|
|
5
|
+
applies task-type relevance scoring, enforces a token budget, and produces
|
|
6
|
+
a structured ContextSlice with nodes, edges, insights, and warnings.
|
|
7
|
+
|
|
8
|
+
Tier gate: Pro/Team get full traversal + insights. Free gets nodes only.
|
|
9
|
+
|
|
10
|
+
Issue: #73
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as _dt
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import pathlib
|
|
19
|
+
from collections import deque
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("0dai.graph_slicer")
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Token estimation
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
CHARS_PER_TOKEN = 3 # conservative: 1 token ~ 3 chars for mixed code/English
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def estimate_tokens(text: str) -> int:
|
|
31
|
+
"""Rough token estimate. 1 token ~ 3 chars (conservative for code)."""
|
|
32
|
+
return max(1, len(text) // CHARS_PER_TOKEN)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Task-type relevance weights
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Higher weight = more relevant for this task type.
|
|
40
|
+
# Node types map to graph NODE_TYPES but we use lowercase keys for matching.
|
|
41
|
+
RELEVANCE_WEIGHTS: dict[str, dict[str, float]] = {
|
|
42
|
+
"feat": {"Decision": 1.0, "Outcome": 0.8, "Component": 0.6, "Technology": 0.5, "Requirement": 0.7, "Risk": 0.4},
|
|
43
|
+
"fix": {"Outcome": 1.0, "Decision": 0.7, "Component": 0.9, "Risk": 0.8, "Technology": 0.5, "Endpoint": 0.6},
|
|
44
|
+
"refactor": {"Decision": 1.0, "Component": 0.8, "Outcome": 0.7, "Technology": 0.6, "Risk": 0.5},
|
|
45
|
+
"test": {"Component": 1.0, "TestPlan": 0.9, "Outcome": 0.5, "Decision": 0.3, "Endpoint": 0.7},
|
|
46
|
+
"docs": {"Component": 0.8, "Decision": 0.6, "Outcome": 0.3, "Endpoint": 0.5, "Technology": 0.4},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Default weight for node types not explicitly listed
|
|
50
|
+
DEFAULT_RELEVANCE = 0.2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _relevance_score(node_type: str, task_type: str) -> float:
|
|
54
|
+
"""Return relevance score for a node type given a task type."""
|
|
55
|
+
weights = RELEVANCE_WEIGHTS.get(task_type, RELEVANCE_WEIGHTS["feat"])
|
|
56
|
+
return weights.get(node_type, DEFAULT_RELEVANCE)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Seed node resolution
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _find_seed_nodes(graph: dict, scope: list[str]) -> list[str]:
|
|
64
|
+
"""Find graph nodes matching the given file paths / module names.
|
|
65
|
+
|
|
66
|
+
Matching strategy:
|
|
67
|
+
1. Exact node name match
|
|
68
|
+
2. Node name ends with scope path component
|
|
69
|
+
3. Node description contains the scope path
|
|
70
|
+
"""
|
|
71
|
+
nodes = graph.get("nodes", {})
|
|
72
|
+
seeds: list[str] = []
|
|
73
|
+
seen: set[str] = set()
|
|
74
|
+
|
|
75
|
+
for scope_path in scope:
|
|
76
|
+
# Normalize: strip leading ./ or /
|
|
77
|
+
normalized = scope_path.lstrip("./").rstrip("/")
|
|
78
|
+
basename = pathlib.PurePosixPath(normalized).name
|
|
79
|
+
|
|
80
|
+
for nid, node in nodes.items():
|
|
81
|
+
if nid in seen:
|
|
82
|
+
continue
|
|
83
|
+
name = node.get("name", "")
|
|
84
|
+
desc = node.get("description", "")
|
|
85
|
+
|
|
86
|
+
# Exact name match
|
|
87
|
+
if name == normalized or name == basename:
|
|
88
|
+
seeds.append(nid)
|
|
89
|
+
seen.add(nid)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# Name ends with scope component
|
|
93
|
+
if normalized and name.endswith(normalized):
|
|
94
|
+
seeds.append(nid)
|
|
95
|
+
seen.add(nid)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Description contains scope path
|
|
99
|
+
if normalized and normalized in desc:
|
|
100
|
+
seeds.append(nid)
|
|
101
|
+
seen.add(nid)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# Slug-based match (node id contains filename slug)
|
|
105
|
+
slug = basename.replace(".", "_").replace("-", "_").lower()
|
|
106
|
+
if slug and len(slug) > 2 and slug in nid.lower():
|
|
107
|
+
seeds.append(nid)
|
|
108
|
+
seen.add(nid)
|
|
109
|
+
|
|
110
|
+
return seeds
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# BFS traversal with relevance scoring
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _build_adjacency(edges: list[dict]) -> dict[str, list[tuple[str, dict]]]:
|
|
118
|
+
"""Build bidirectional adjacency list from edges."""
|
|
119
|
+
adj: dict[str, list[tuple[str, dict]]] = {}
|
|
120
|
+
for edge in edges:
|
|
121
|
+
src = edge.get("from", "")
|
|
122
|
+
tgt = edge.get("to", "")
|
|
123
|
+
if src:
|
|
124
|
+
adj.setdefault(src, []).append((tgt, edge))
|
|
125
|
+
if tgt:
|
|
126
|
+
adj.setdefault(tgt, []).append((src, edge))
|
|
127
|
+
return adj
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _bfs_traverse(
|
|
131
|
+
graph: dict,
|
|
132
|
+
seed_ids: list[str],
|
|
133
|
+
max_depth: int,
|
|
134
|
+
task_type: str,
|
|
135
|
+
token_budget: int,
|
|
136
|
+
) -> tuple[list[dict], list[dict]]:
|
|
137
|
+
"""BFS from seeds, prioritized by relevance, bounded by depth and tokens.
|
|
138
|
+
|
|
139
|
+
Returns (selected_nodes_with_hop, selected_edges).
|
|
140
|
+
Each node dict includes a 'hop' field indicating distance from seed.
|
|
141
|
+
"""
|
|
142
|
+
nodes = graph.get("nodes", {})
|
|
143
|
+
edges = graph.get("edges", [])
|
|
144
|
+
adj = _build_adjacency(edges)
|
|
145
|
+
|
|
146
|
+
# BFS state
|
|
147
|
+
visited: dict[str, int] = {} # node_id -> hop
|
|
148
|
+
queue: deque[tuple[str, int]] = deque()
|
|
149
|
+
|
|
150
|
+
# Initialize with seeds
|
|
151
|
+
for sid in seed_ids:
|
|
152
|
+
if sid in nodes and sid not in visited:
|
|
153
|
+
visited[sid] = 0
|
|
154
|
+
queue.append((sid, 0))
|
|
155
|
+
|
|
156
|
+
# BFS expansion
|
|
157
|
+
while queue:
|
|
158
|
+
current_id, current_hop = queue.popleft()
|
|
159
|
+
if current_hop >= max_depth:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
for neighbor_id, edge in adj.get(current_id, []):
|
|
163
|
+
if neighbor_id in visited:
|
|
164
|
+
continue
|
|
165
|
+
if neighbor_id not in nodes:
|
|
166
|
+
continue
|
|
167
|
+
visited[neighbor_id] = current_hop + 1
|
|
168
|
+
queue.append((neighbor_id, current_hop + 1))
|
|
169
|
+
|
|
170
|
+
# Score and sort nodes by relevance (seeds first, then by score descending)
|
|
171
|
+
scored_nodes: list[tuple[str, int, float]] = []
|
|
172
|
+
for nid, hop in visited.items():
|
|
173
|
+
node = nodes[nid]
|
|
174
|
+
node_type = node.get("type", "")
|
|
175
|
+
relevance = _relevance_score(node_type, task_type)
|
|
176
|
+
# Seeds get max score, others decay by hop distance
|
|
177
|
+
score = 10.0 if hop == 0 else relevance / (hop * 0.5 + 0.5)
|
|
178
|
+
scored_nodes.append((nid, hop, score))
|
|
179
|
+
|
|
180
|
+
scored_nodes.sort(key=lambda x: (-x[2], x[1]))
|
|
181
|
+
|
|
182
|
+
# Token budget enforcement
|
|
183
|
+
used_tokens = 0
|
|
184
|
+
selected_ids: set[str] = set()
|
|
185
|
+
result_nodes: list[dict] = []
|
|
186
|
+
|
|
187
|
+
for nid, hop, score in scored_nodes:
|
|
188
|
+
node = nodes[nid]
|
|
189
|
+
# Estimate tokens for this node
|
|
190
|
+
node_text = f"{node.get('type', '')} {node.get('name', '')} {node.get('description', '')[:100]}"
|
|
191
|
+
node_tokens = estimate_tokens(node_text)
|
|
192
|
+
|
|
193
|
+
if used_tokens + node_tokens > token_budget and hop > 0:
|
|
194
|
+
# Don't skip seed nodes even if over budget
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
selected_ids.add(nid)
|
|
198
|
+
result_nodes.append({
|
|
199
|
+
"id": nid,
|
|
200
|
+
"type": node.get("type", ""),
|
|
201
|
+
"name": node.get("name", ""),
|
|
202
|
+
"hop": hop,
|
|
203
|
+
"data": {
|
|
204
|
+
k: v for k, v in node.items()
|
|
205
|
+
if k not in ("id", "type", "name", "created_at", "updated_at")
|
|
206
|
+
and v # skip empty values
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
used_tokens += node_tokens
|
|
210
|
+
|
|
211
|
+
# Collect edges between selected nodes
|
|
212
|
+
result_edges: list[dict] = []
|
|
213
|
+
for edge in edges:
|
|
214
|
+
if edge.get("from") in selected_ids and edge.get("to") in selected_ids:
|
|
215
|
+
result_edges.append({
|
|
216
|
+
"source": edge["from"],
|
|
217
|
+
"target": edge["to"],
|
|
218
|
+
"type": edge.get("type", ""),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
return result_nodes, result_edges
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Insight generation
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def _generate_insights(
|
|
229
|
+
graph: dict,
|
|
230
|
+
selected_nodes: list[dict],
|
|
231
|
+
selected_edges: list[dict],
|
|
232
|
+
task_type: str,
|
|
233
|
+
) -> list[str]:
|
|
234
|
+
"""Generate human-readable insights from the selected subgraph."""
|
|
235
|
+
insights: list[str] = []
|
|
236
|
+
nodes_by_id = {n["id"]: n for n in selected_nodes}
|
|
237
|
+
all_nodes = graph.get("nodes", {})
|
|
238
|
+
|
|
239
|
+
for edge in selected_edges:
|
|
240
|
+
src_id = edge["source"]
|
|
241
|
+
tgt_id = edge["target"]
|
|
242
|
+
etype = edge["type"]
|
|
243
|
+
src = nodes_by_id.get(src_id, {})
|
|
244
|
+
tgt = nodes_by_id.get(tgt_id, {})
|
|
245
|
+
src_name = src.get("name", src_id)
|
|
246
|
+
tgt_name = tgt.get("name", tgt_id)
|
|
247
|
+
|
|
248
|
+
if etype == "depends_on":
|
|
249
|
+
insights.append(f"{src_name} depends on {tgt_name} — changes may cascade")
|
|
250
|
+
elif etype == "uses":
|
|
251
|
+
insights.append(f"{src_name} uses {tgt_name}")
|
|
252
|
+
elif etype == "affects":
|
|
253
|
+
insights.append(f"Decision '{src_name}' affects {tgt_name}")
|
|
254
|
+
elif etype == "evaluates":
|
|
255
|
+
# Outcome evaluates Decision
|
|
256
|
+
status = src.get("data", {}).get("status", "")
|
|
257
|
+
lessons = src.get("data", {}).get("lessons", "")
|
|
258
|
+
if status:
|
|
259
|
+
insights.append(f"Outcome for '{tgt_name}': {status}" + (f" — {lessons[:80]}" if lessons else ""))
|
|
260
|
+
elif etype == "chose":
|
|
261
|
+
insights.append(f"Previous decision: chose {tgt_name}")
|
|
262
|
+
elif etype == "mitigates":
|
|
263
|
+
insights.append(f"Decision '{src_name}' mitigates risk: {tgt_name}")
|
|
264
|
+
elif etype == "blocks":
|
|
265
|
+
insights.append(f"Risk '{src_name}' blocks {tgt_name}")
|
|
266
|
+
|
|
267
|
+
# Add outcome insights for Decision nodes
|
|
268
|
+
for node in selected_nodes:
|
|
269
|
+
if node["type"] == "Outcome":
|
|
270
|
+
data = node.get("data", {})
|
|
271
|
+
status = data.get("status", "unknown")
|
|
272
|
+
agent = data.get("agent", "")
|
|
273
|
+
cost = data.get("cost", "")
|
|
274
|
+
duration = data.get("duration", "")
|
|
275
|
+
parts = [f"Outcome: {node['name']} — {status}"]
|
|
276
|
+
if agent:
|
|
277
|
+
parts.append(f"agent: {agent}")
|
|
278
|
+
if cost:
|
|
279
|
+
parts.append(f"${cost}")
|
|
280
|
+
if duration:
|
|
281
|
+
parts.append(f"{duration}s")
|
|
282
|
+
insights.append(" ".join(parts))
|
|
283
|
+
|
|
284
|
+
return insights[:10] # Cap at 10 insights
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Warning generation
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def _generate_warnings(
|
|
292
|
+
graph: dict,
|
|
293
|
+
selected_nodes: list[dict],
|
|
294
|
+
task_type: str,
|
|
295
|
+
) -> list[str]:
|
|
296
|
+
"""Generate warnings based on graph patterns."""
|
|
297
|
+
warnings: list[str] = []
|
|
298
|
+
all_nodes = graph.get("nodes", {})
|
|
299
|
+
|
|
300
|
+
for node in selected_nodes:
|
|
301
|
+
nid = node["id"]
|
|
302
|
+
full_node = all_nodes.get(nid, {})
|
|
303
|
+
|
|
304
|
+
# Check for frequent modifications (if updated_at is recent)
|
|
305
|
+
updated_at = full_node.get("updated_at", "")
|
|
306
|
+
if updated_at:
|
|
307
|
+
try:
|
|
308
|
+
updated = _dt.datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
|
309
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
310
|
+
if (now - updated).total_seconds() < 86400: # 24h
|
|
311
|
+
warnings.append(f"{node['name']} was modified in the last 24h")
|
|
312
|
+
except (ValueError, TypeError):
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
# Check for blocking risks
|
|
316
|
+
if node["type"] == "Risk":
|
|
317
|
+
status = full_node.get("status", "")
|
|
318
|
+
if status in ("open", "active", ""):
|
|
319
|
+
severity = full_node.get("severity", "medium")
|
|
320
|
+
warnings.append(f"Open risk: {node['name']} (severity: {severity})")
|
|
321
|
+
|
|
322
|
+
return warnings[:5] # Cap at 5 warnings
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Main context slice function
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
class ContextSlice:
|
|
330
|
+
"""Structured result of graph context slicing."""
|
|
331
|
+
|
|
332
|
+
def __init__(
|
|
333
|
+
self,
|
|
334
|
+
seed_nodes: list[str],
|
|
335
|
+
task_type: str,
|
|
336
|
+
depth_reached: int,
|
|
337
|
+
token_count: int,
|
|
338
|
+
nodes: list[dict],
|
|
339
|
+
edges: list[dict],
|
|
340
|
+
insights: list[str],
|
|
341
|
+
warnings: list[str],
|
|
342
|
+
):
|
|
343
|
+
self.slice_id = "slice_" + hashlib.sha256(
|
|
344
|
+
json.dumps(seed_nodes + [task_type]).encode()
|
|
345
|
+
).hexdigest()[:12]
|
|
346
|
+
self.created_at = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
347
|
+
self.seed_nodes = seed_nodes
|
|
348
|
+
self.task_type = task_type
|
|
349
|
+
self.depth_reached = depth_reached
|
|
350
|
+
self.token_count = token_count
|
|
351
|
+
self.nodes = nodes
|
|
352
|
+
self.edges = edges
|
|
353
|
+
self.insights = insights
|
|
354
|
+
self.warnings = warnings
|
|
355
|
+
|
|
356
|
+
def to_dict(self) -> dict:
|
|
357
|
+
return {
|
|
358
|
+
"slice_id": self.slice_id,
|
|
359
|
+
"created_at": self.created_at,
|
|
360
|
+
"seed_nodes": self.seed_nodes,
|
|
361
|
+
"task_type": self.task_type,
|
|
362
|
+
"depth_reached": self.depth_reached,
|
|
363
|
+
"token_count": self.token_count,
|
|
364
|
+
"nodes": self.nodes,
|
|
365
|
+
"edges": self.edges,
|
|
366
|
+
"insights": self.insights,
|
|
367
|
+
"warnings": self.warnings,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
def format_for_agent(self) -> str:
|
|
371
|
+
"""Format slice as compact text for agent context injection."""
|
|
372
|
+
lines = ["## Project Context (from graph)"]
|
|
373
|
+
|
|
374
|
+
if self.insights:
|
|
375
|
+
for insight in self.insights:
|
|
376
|
+
lines.append(f"- {insight}")
|
|
377
|
+
|
|
378
|
+
if self.warnings:
|
|
379
|
+
lines.append("")
|
|
380
|
+
for warning in self.warnings:
|
|
381
|
+
lines.append(f"- \u26a0 {warning}")
|
|
382
|
+
|
|
383
|
+
if not self.insights and not self.warnings:
|
|
384
|
+
lines.append("- No relevant context found in project graph")
|
|
385
|
+
|
|
386
|
+
return "\n".join(lines)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def get_context_slice(
|
|
390
|
+
graph: dict,
|
|
391
|
+
task_type: str = "feat",
|
|
392
|
+
scope: list[str] | None = None,
|
|
393
|
+
max_depth: int = 3,
|
|
394
|
+
token_budget: int = 4000,
|
|
395
|
+
agent: str | None = None,
|
|
396
|
+
) -> ContextSlice:
|
|
397
|
+
"""Get a relevant context slice from the project graph.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
graph: The full project graph dict (nodes, edges, meta).
|
|
401
|
+
task_type: One of "feat", "fix", "refactor", "test", "docs".
|
|
402
|
+
scope: File paths involved in the task (used to find seed nodes).
|
|
403
|
+
max_depth: Maximum BFS hops from seed nodes (default 3).
|
|
404
|
+
token_budget: Maximum tokens for the output context (default 4000).
|
|
405
|
+
agent: Optional agent name to tailor output.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
ContextSlice with nodes, edges, insights, and warnings.
|
|
409
|
+
"""
|
|
410
|
+
if scope is None:
|
|
411
|
+
scope = []
|
|
412
|
+
|
|
413
|
+
nodes = graph.get("nodes", {})
|
|
414
|
+
edges = graph.get("edges", [])
|
|
415
|
+
|
|
416
|
+
if not nodes:
|
|
417
|
+
return ContextSlice(
|
|
418
|
+
seed_nodes=[], task_type=task_type, depth_reached=0,
|
|
419
|
+
token_count=0, nodes=[], edges=[], insights=[], warnings=[],
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Step 1: Find seed nodes from scope
|
|
423
|
+
seed_ids = _find_seed_nodes(graph, scope)
|
|
424
|
+
|
|
425
|
+
# If no seeds found, try to use all nodes matching task-relevant types
|
|
426
|
+
if not seed_ids and scope:
|
|
427
|
+
# Fallback: return empty slice with a note
|
|
428
|
+
return ContextSlice(
|
|
429
|
+
seed_nodes=[], task_type=task_type, depth_reached=0,
|
|
430
|
+
token_count=0, nodes=[], edges=[],
|
|
431
|
+
insights=["No matching nodes found for scope: " + ", ".join(scope)],
|
|
432
|
+
warnings=[],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# If no scope at all, use top-level component nodes as seeds
|
|
436
|
+
if not seed_ids:
|
|
437
|
+
for nid, node in nodes.items():
|
|
438
|
+
if node.get("type") == "Component" and "root" in nid:
|
|
439
|
+
seed_ids.append(nid)
|
|
440
|
+
break
|
|
441
|
+
if not seed_ids and nodes:
|
|
442
|
+
# Just pick first few nodes
|
|
443
|
+
seed_ids = list(nodes.keys())[:3]
|
|
444
|
+
|
|
445
|
+
# Step 2: BFS traversal with relevance scoring
|
|
446
|
+
selected_nodes, selected_edges = _bfs_traverse(
|
|
447
|
+
graph, seed_ids, max_depth, task_type, token_budget,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Calculate actual depth reached
|
|
451
|
+
depth_reached = max((n["hop"] for n in selected_nodes), default=0)
|
|
452
|
+
|
|
453
|
+
# Calculate token count
|
|
454
|
+
total_tokens = sum(
|
|
455
|
+
estimate_tokens(f"{n['type']} {n['name']} {n.get('data', {}).get('description', '')[:100]}")
|
|
456
|
+
for n in selected_nodes
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Step 3: Generate insights
|
|
460
|
+
insights = _generate_insights(graph, selected_nodes, selected_edges, task_type)
|
|
461
|
+
|
|
462
|
+
# Step 4: Generate warnings
|
|
463
|
+
warnings = _generate_warnings(graph, selected_nodes, task_type)
|
|
464
|
+
|
|
465
|
+
# Add insight tokens to count
|
|
466
|
+
for text in insights + warnings:
|
|
467
|
+
total_tokens += estimate_tokens(text)
|
|
468
|
+
|
|
469
|
+
return ContextSlice(
|
|
470
|
+
seed_nodes=seed_ids,
|
|
471
|
+
task_type=task_type,
|
|
472
|
+
depth_reached=depth_reached,
|
|
473
|
+
token_count=total_tokens,
|
|
474
|
+
nodes=selected_nodes,
|
|
475
|
+
edges=selected_edges,
|
|
476
|
+
insights=insights,
|
|
477
|
+
warnings=warnings,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
# Free-tier slice: nodes only, no traversal, no insights
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
def get_free_tier_slice(
|
|
486
|
+
graph: dict,
|
|
487
|
+
scope: list[str] | None = None,
|
|
488
|
+
) -> dict:
|
|
489
|
+
"""Return a minimal slice for free-tier users: matched nodes only."""
|
|
490
|
+
if scope is None:
|
|
491
|
+
scope = []
|
|
492
|
+
nodes = graph.get("nodes", {})
|
|
493
|
+
seed_ids = _find_seed_nodes(graph, scope)
|
|
494
|
+
|
|
495
|
+
result_nodes = []
|
|
496
|
+
for nid in seed_ids:
|
|
497
|
+
node = nodes.get(nid)
|
|
498
|
+
if node:
|
|
499
|
+
result_nodes.append({
|
|
500
|
+
"id": nid,
|
|
501
|
+
"type": node.get("type", ""),
|
|
502
|
+
"name": node.get("name", ""),
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
"nodes": result_nodes,
|
|
507
|
+
"edges": [],
|
|
508
|
+
"insights": [],
|
|
509
|
+
"warnings": [],
|
|
510
|
+
"tier": "free",
|
|
511
|
+
"hint": "Upgrade to Pro for traversal, insights, and warnings: 0dai upgrade",
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
# CLI formatting
|
|
517
|
+
# ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
def format_cli_output(slice_data: ContextSlice | dict) -> str:
|
|
520
|
+
"""Format a context slice for CLI display."""
|
|
521
|
+
if isinstance(slice_data, ContextSlice):
|
|
522
|
+
d = slice_data.to_dict()
|
|
523
|
+
else:
|
|
524
|
+
d = slice_data
|
|
525
|
+
|
|
526
|
+
lines = []
|
|
527
|
+
|
|
528
|
+
# Header
|
|
529
|
+
task_type = d.get("task_type", "unknown")
|
|
530
|
+
seeds = d.get("seed_nodes", [])
|
|
531
|
+
if seeds:
|
|
532
|
+
lines.append(f"Graph context for {task_type} of {', '.join(seeds[:3])}:")
|
|
533
|
+
else:
|
|
534
|
+
lines.append(f"Graph context for {task_type}:")
|
|
535
|
+
lines.append("")
|
|
536
|
+
|
|
537
|
+
# Nodes
|
|
538
|
+
node_list = d.get("nodes", [])
|
|
539
|
+
if node_list:
|
|
540
|
+
depth = d.get("depth_reached", 0)
|
|
541
|
+
tokens = d.get("token_count", 0)
|
|
542
|
+
lines.append(f"Related nodes (depth {depth}, {tokens} tokens):")
|
|
543
|
+
for node in node_list:
|
|
544
|
+
ntype = node.get("type", "")
|
|
545
|
+
name = node.get("name", node.get("id", ""))
|
|
546
|
+
hop = node.get("hop", "?")
|
|
547
|
+
icon = {"Component": "\U0001f4c1", "Decision": "\U0001f4a1", "Outcome": "\u2705",
|
|
548
|
+
"Technology": "\U0001f527", "Risk": "\u26a0\ufe0f", "Requirement": "\U0001f4cb",
|
|
549
|
+
"Endpoint": "\U0001f310", "TestPlan": "\U0001f9ea"}.get(ntype, "\U0001f4c4")
|
|
550
|
+
label = "seed" if hop == 0 else f"hop {hop}"
|
|
551
|
+
lines.append(f" {icon} {name} ({label})")
|
|
552
|
+
else:
|
|
553
|
+
lines.append("No related nodes found.")
|
|
554
|
+
|
|
555
|
+
# Insights
|
|
556
|
+
insights = d.get("insights", [])
|
|
557
|
+
if insights:
|
|
558
|
+
lines.append("")
|
|
559
|
+
lines.append("Insights:")
|
|
560
|
+
for insight in insights:
|
|
561
|
+
lines.append(f" \u2022 {insight}")
|
|
562
|
+
|
|
563
|
+
# Warnings
|
|
564
|
+
warnings = d.get("warnings", [])
|
|
565
|
+
if warnings:
|
|
566
|
+
lines.append("")
|
|
567
|
+
lines.append("\u26a0 Warnings:")
|
|
568
|
+
for warning in warnings:
|
|
569
|
+
lines.append(f" \u2022 {warning}")
|
|
570
|
+
|
|
571
|
+
# Free tier hint
|
|
572
|
+
if d.get("tier") == "free":
|
|
573
|
+
lines.append("")
|
|
574
|
+
lines.append(d.get("hint", ""))
|
|
575
|
+
|
|
576
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI wrapper for graph context slicing.
|
|
3
|
+
|
|
4
|
+
Called by: 0dai graph context --scope FILE --task TYPE --depth N --budget N --json
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import pathlib
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
|
14
|
+
|
|
15
|
+
import graph_slicer
|
|
16
|
+
from graph import load_graph
|
|
17
|
+
|
|
18
|
+
MISSING_GRAPH_HINT = (
|
|
19
|
+
"No project graph found. Create ai/manifest/project_graph.json, or from a "
|
|
20
|
+
"repo checkout run: python3 scripts/generate_project_graph.py --target ."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv: list[str] | None = None) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(description="Graph-aware context slicing")
|
|
26
|
+
parser.add_argument("--target", type=str, default=".", help="Project root directory")
|
|
27
|
+
parser.add_argument("--scope", action="append", default=[], help="File paths (repeatable)")
|
|
28
|
+
parser.add_argument("--task", type=str, default="feat", help="Task type: feat|fix|refactor|test|docs")
|
|
29
|
+
parser.add_argument("--depth", type=int, default=3, help="Max BFS hops (default: 3)")
|
|
30
|
+
parser.add_argument("--budget", type=int, default=4000, help="Token budget (default: 4000)")
|
|
31
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
target = pathlib.Path(args.target).resolve()
|
|
35
|
+
graph_path = target / "ai" / "manifest" / "project_graph.json"
|
|
36
|
+
|
|
37
|
+
if not graph_path.exists():
|
|
38
|
+
print(MISSING_GRAPH_HINT, file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
graph = load_graph(graph_path, track_usage=False)
|
|
42
|
+
|
|
43
|
+
ctx = graph_slicer.get_context_slice(
|
|
44
|
+
graph=graph,
|
|
45
|
+
task_type=args.task,
|
|
46
|
+
scope=args.scope,
|
|
47
|
+
max_depth=args.depth,
|
|
48
|
+
token_budget=args.budget,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if args.json:
|
|
52
|
+
print(json.dumps(ctx.to_dict(), indent=2, ensure_ascii=False))
|
|
53
|
+
else:
|
|
54
|
+
print(graph_slicer.format_cli_output(ctx))
|
|
55
|
+
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
sys.exit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Graph validation helpers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from graph_core import EDGE_TYPES, NODE_TYPES, SCHEMA_VERSION, log
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GraphValidationError(ValueError):
|
|
10
|
+
"""Raised when a graph fails structural validation."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_graph(graph: dict) -> list[str]:
|
|
14
|
+
errors: list[str] = []
|
|
15
|
+
|
|
16
|
+
for key in ("nodes", "edges", "meta"):
|
|
17
|
+
if key not in graph:
|
|
18
|
+
errors.append(f"missing top-level key: {key}")
|
|
19
|
+
if errors:
|
|
20
|
+
return errors
|
|
21
|
+
|
|
22
|
+
if not isinstance(graph["nodes"], dict):
|
|
23
|
+
errors.append("nodes must be a dict")
|
|
24
|
+
if not isinstance(graph["edges"], list):
|
|
25
|
+
errors.append("edges must be a list")
|
|
26
|
+
if errors:
|
|
27
|
+
return errors
|
|
28
|
+
|
|
29
|
+
meta_version = graph["meta"].get("schema_version")
|
|
30
|
+
if meta_version != SCHEMA_VERSION:
|
|
31
|
+
errors.append(
|
|
32
|
+
f"schema_version mismatch: expected {SCHEMA_VERSION}, got {meta_version}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
for node_id, node in graph["nodes"].items():
|
|
36
|
+
if node.get("id") != node_id:
|
|
37
|
+
errors.append(f"node id mismatch: key={node_id}, node.id={node.get('id')}")
|
|
38
|
+
node_type = node.get("type")
|
|
39
|
+
if node_type not in NODE_TYPES:
|
|
40
|
+
errors.append(f"node {node_id} has unknown type: {node_type!r}")
|
|
41
|
+
for field in ("name", "status", "created_at", "updated_at"):
|
|
42
|
+
if field not in node:
|
|
43
|
+
errors.append(f"node {node_id} missing required field: {field}")
|
|
44
|
+
|
|
45
|
+
seen_edges: set[tuple] = set()
|
|
46
|
+
for i, edge in enumerate(graph["edges"]):
|
|
47
|
+
for field in ("from", "to", "type"):
|
|
48
|
+
if field not in edge:
|
|
49
|
+
errors.append(f"edge[{i}] missing field: {field}")
|
|
50
|
+
continue
|
|
51
|
+
edge_type = edge.get("type")
|
|
52
|
+
if edge_type not in EDGE_TYPES:
|
|
53
|
+
errors.append(f"edge[{i}] has unknown type: {edge_type!r}")
|
|
54
|
+
src, tgt = edge.get("from"), edge.get("to")
|
|
55
|
+
if src not in graph["nodes"]:
|
|
56
|
+
log.warning("edge[%d] dangling source: %s", i, src)
|
|
57
|
+
if tgt not in graph["nodes"]:
|
|
58
|
+
log.warning("edge[%d] dangling target: %s", i, tgt)
|
|
59
|
+
key = (src, tgt, edge_type)
|
|
60
|
+
if key in seen_edges:
|
|
61
|
+
log.warning("edge[%d] duplicate: %s -> %s [%s]", i, src, tgt, edge_type)
|
|
62
|
+
seen_edges.add(key)
|
|
63
|
+
|
|
64
|
+
return errors
|