ref-agents 1.0.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.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"""Context Graph for REF-Agents.
|
|
2
|
+
|
|
3
|
+
NetworkX-based graph for tracking relationships between:
|
|
4
|
+
- Decisions
|
|
5
|
+
- Patterns
|
|
6
|
+
- Files
|
|
7
|
+
- Issues
|
|
8
|
+
- Agent sessions
|
|
9
|
+
- Stories
|
|
10
|
+
|
|
11
|
+
Provides causality chains and impact analysis for agent context.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
import networkx as nx
|
|
23
|
+
|
|
24
|
+
from ref_agents.utils.git_utils import find_project_root
|
|
25
|
+
|
|
26
|
+
# Valid node and edge types
|
|
27
|
+
NodeType = Literal[
|
|
28
|
+
"decision",
|
|
29
|
+
"pattern",
|
|
30
|
+
"file",
|
|
31
|
+
"issue",
|
|
32
|
+
"agent_session",
|
|
33
|
+
"story",
|
|
34
|
+
"module",
|
|
35
|
+
"class",
|
|
36
|
+
"function",
|
|
37
|
+
]
|
|
38
|
+
EdgeType = Literal[
|
|
39
|
+
"led_to",
|
|
40
|
+
"affects",
|
|
41
|
+
"discovered_by",
|
|
42
|
+
"applies_to",
|
|
43
|
+
"supersedes",
|
|
44
|
+
"depends_on",
|
|
45
|
+
"imports",
|
|
46
|
+
"calls",
|
|
47
|
+
"inherits",
|
|
48
|
+
"contains",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# AST-origin node types — stored in .ref_cache/ast_graph.json, regenerable
|
|
52
|
+
AST_NODE_TYPES: set[str] = {"module", "class", "function", "file"}
|
|
53
|
+
|
|
54
|
+
VALID_NODE_TYPES: set[str] = {
|
|
55
|
+
"decision",
|
|
56
|
+
"pattern",
|
|
57
|
+
"file",
|
|
58
|
+
"issue",
|
|
59
|
+
"agent_session",
|
|
60
|
+
"story",
|
|
61
|
+
"module",
|
|
62
|
+
"class",
|
|
63
|
+
"function",
|
|
64
|
+
}
|
|
65
|
+
VALID_EDGE_TYPES: set[str] = {
|
|
66
|
+
"led_to",
|
|
67
|
+
"affects",
|
|
68
|
+
"discovered_by",
|
|
69
|
+
"applies_to",
|
|
70
|
+
"supersedes",
|
|
71
|
+
"depends_on",
|
|
72
|
+
"imports",
|
|
73
|
+
"calls",
|
|
74
|
+
"inherits",
|
|
75
|
+
"contains",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
GRAPH_FILENAME = "context_graph.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ContextNode:
|
|
83
|
+
"""Node in the context graph."""
|
|
84
|
+
|
|
85
|
+
id: str
|
|
86
|
+
node_type: NodeType
|
|
87
|
+
content: str
|
|
88
|
+
timestamp: str = field(
|
|
89
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
90
|
+
)
|
|
91
|
+
story_id: str = ""
|
|
92
|
+
# "ast" = extracted by tree-sitter (regenerable); "context" = human/agent-authored
|
|
93
|
+
origin: str = "context"
|
|
94
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
"""Convert to dictionary for serialization."""
|
|
98
|
+
return {
|
|
99
|
+
"id": self.id,
|
|
100
|
+
"node_type": self.node_type,
|
|
101
|
+
"content": self.content,
|
|
102
|
+
"timestamp": self.timestamp,
|
|
103
|
+
"story_id": self.story_id,
|
|
104
|
+
"origin": self.origin,
|
|
105
|
+
"metadata": self.metadata,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_dict(cls, data: dict[str, Any]) -> ContextNode:
|
|
110
|
+
"""Create from dictionary."""
|
|
111
|
+
return cls(
|
|
112
|
+
id=data["id"],
|
|
113
|
+
node_type=data["node_type"],
|
|
114
|
+
content=data["content"],
|
|
115
|
+
timestamp=data.get("timestamp", ""),
|
|
116
|
+
story_id=data.get("story_id", ""),
|
|
117
|
+
origin=data.get("origin", "context"),
|
|
118
|
+
metadata=data.get("metadata", {}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ContextEdge:
|
|
124
|
+
"""Edge in the context graph."""
|
|
125
|
+
|
|
126
|
+
from_id: str
|
|
127
|
+
to_id: str
|
|
128
|
+
edge_type: EdgeType
|
|
129
|
+
timestamp: str = field(
|
|
130
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
131
|
+
)
|
|
132
|
+
# 1.0 = certain (AST-extracted or human), 0.5–0.9 = inferred (scanner/git/LLM)
|
|
133
|
+
confidence: float = 1.0
|
|
134
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
"""Convert to dictionary for serialization."""
|
|
138
|
+
return {
|
|
139
|
+
"from_id": self.from_id,
|
|
140
|
+
"to_id": self.to_id,
|
|
141
|
+
"edge_type": self.edge_type,
|
|
142
|
+
"timestamp": self.timestamp,
|
|
143
|
+
"confidence": self.confidence,
|
|
144
|
+
"metadata": self.metadata,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_dict(cls, data: dict[str, Any]) -> ContextEdge:
|
|
149
|
+
"""Create from dictionary."""
|
|
150
|
+
return cls(
|
|
151
|
+
from_id=data["from_id"],
|
|
152
|
+
to_id=data["to_id"],
|
|
153
|
+
edge_type=data["edge_type"],
|
|
154
|
+
timestamp=data.get("timestamp", ""),
|
|
155
|
+
confidence=float(data.get("confidence", 1.0)),
|
|
156
|
+
metadata=data.get("metadata", {}),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ContextGraph:
|
|
161
|
+
"""Directed graph representing the codebase context and decision history."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, root: Path | None = None):
|
|
164
|
+
self._root = root or find_project_root() or Path.cwd()
|
|
165
|
+
|
|
166
|
+
# Primary storage (Dashboards, Recovery)
|
|
167
|
+
self._primary_dir = self._root / "ref-reports"
|
|
168
|
+
self._primary_file = self._primary_dir / GRAPH_FILENAME
|
|
169
|
+
|
|
170
|
+
# Cache storage (Runtime performance)
|
|
171
|
+
self._cache_dir = self._root / ".ref_cache"
|
|
172
|
+
self._cache_file = self._cache_dir / GRAPH_FILENAME
|
|
173
|
+
|
|
174
|
+
self._graph = nx.DiGraph()
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def node_count(self) -> int:
|
|
178
|
+
"""Number of nodes in graph."""
|
|
179
|
+
return self._graph.number_of_nodes()
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def edge_count(self) -> int:
|
|
183
|
+
"""Number of edges in graph."""
|
|
184
|
+
return self._graph.number_of_edges()
|
|
185
|
+
|
|
186
|
+
def add_node(self, node: ContextNode) -> None:
|
|
187
|
+
"""Add a node to the graph.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
node: ContextNode to add.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: If node_type is invalid.
|
|
194
|
+
"""
|
|
195
|
+
if node.node_type not in VALID_NODE_TYPES:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"Invalid node_type: {node.node_type}. Must be one of {VALID_NODE_TYPES}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
self._graph.add_node(node.id, **node.to_dict())
|
|
201
|
+
|
|
202
|
+
def add_edge( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
|
|
203
|
+
self,
|
|
204
|
+
from_id: str,
|
|
205
|
+
to_id: str,
|
|
206
|
+
edge_type: EdgeType,
|
|
207
|
+
metadata: dict[str, Any] | None = None,
|
|
208
|
+
confidence: float = 1.0,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Add an edge between two nodes.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
from_id: Source node ID.
|
|
214
|
+
to_id: Target node ID.
|
|
215
|
+
edge_type: Type of relationship.
|
|
216
|
+
metadata: Optional edge metadata.
|
|
217
|
+
confidence: Certainty of relationship (1.0=certain, 0.5=inferred).
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
ValueError: If edge_type is invalid or nodes don't exist.
|
|
221
|
+
"""
|
|
222
|
+
if edge_type not in VALID_EDGE_TYPES:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Invalid edge_type: {edge_type}. Must be one of {VALID_EDGE_TYPES}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if from_id not in self._graph:
|
|
228
|
+
raise ValueError(f"Source node not found: {from_id}")
|
|
229
|
+
if to_id not in self._graph:
|
|
230
|
+
raise ValueError(f"Target node not found: {to_id}")
|
|
231
|
+
|
|
232
|
+
edge = ContextEdge(
|
|
233
|
+
from_id=from_id,
|
|
234
|
+
to_id=to_id,
|
|
235
|
+
edge_type=edge_type,
|
|
236
|
+
confidence=confidence,
|
|
237
|
+
metadata=metadata or {},
|
|
238
|
+
)
|
|
239
|
+
self._graph.add_edge(from_id, to_id, **edge.to_dict())
|
|
240
|
+
|
|
241
|
+
def get_node(self, node_id: str) -> ContextNode | None:
|
|
242
|
+
"""Get a node by ID.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
node_id: Node identifier.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
ContextNode if found, None otherwise.
|
|
249
|
+
"""
|
|
250
|
+
if node_id not in self._graph:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
data = dict(self._graph.nodes[node_id])
|
|
254
|
+
return ContextNode.from_dict(data)
|
|
255
|
+
|
|
256
|
+
def get_related(
|
|
257
|
+
self,
|
|
258
|
+
node_id: str,
|
|
259
|
+
edge_types: list[str] | None = None,
|
|
260
|
+
direction: Literal["outgoing", "incoming", "both"] = "both",
|
|
261
|
+
) -> list[ContextNode]:
|
|
262
|
+
"""Get nodes related to a given node.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
node_id: Source node ID.
|
|
266
|
+
edge_types: Filter by edge types. None = all types.
|
|
267
|
+
direction: Which edges to follow.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of related ContextNodes.
|
|
271
|
+
"""
|
|
272
|
+
if node_id not in self._graph:
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
related_ids: set[str] = set()
|
|
276
|
+
|
|
277
|
+
if direction in ("outgoing", "both"):
|
|
278
|
+
for _, target, data in self._graph.out_edges(node_id, data=True):
|
|
279
|
+
if edge_types is None or data.get("edge_type") in edge_types:
|
|
280
|
+
related_ids.add(target)
|
|
281
|
+
|
|
282
|
+
if direction in ("incoming", "both"):
|
|
283
|
+
for source, _, data in self._graph.in_edges(node_id, data=True):
|
|
284
|
+
if edge_types is None or data.get("edge_type") in edge_types:
|
|
285
|
+
related_ids.add(source)
|
|
286
|
+
|
|
287
|
+
return [n for nid in related_ids if (n := self.get_node(nid)) is not None]
|
|
288
|
+
|
|
289
|
+
def get_causality_chain(
|
|
290
|
+
self, node_id: str, max_depth: int = 10
|
|
291
|
+
) -> list[ContextNode]:
|
|
292
|
+
"""Get the causal ancestry of a node (led_to, caused_by edges).
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
node_id: Starting node ID.
|
|
296
|
+
max_depth: Maximum traversal depth.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
List of nodes in causal chain, oldest first.
|
|
300
|
+
"""
|
|
301
|
+
if node_id not in self._graph:
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
chain: list[str] = []
|
|
305
|
+
visited: set[str] = set()
|
|
306
|
+
current = node_id
|
|
307
|
+
|
|
308
|
+
for _ in range(max_depth):
|
|
309
|
+
visited.add(current)
|
|
310
|
+
chain.append(current)
|
|
311
|
+
|
|
312
|
+
# Find predecessor via led_to edge
|
|
313
|
+
predecessors = [
|
|
314
|
+
source
|
|
315
|
+
for source, _, data in self._graph.in_edges(current, data=True)
|
|
316
|
+
if data.get("edge_type") == "led_to" and source not in visited
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
if not predecessors:
|
|
320
|
+
break
|
|
321
|
+
current = predecessors[0]
|
|
322
|
+
|
|
323
|
+
# Reverse to get oldest first
|
|
324
|
+
chain.reverse()
|
|
325
|
+
return [n for nid in chain if (n := self.get_node(nid)) is not None]
|
|
326
|
+
|
|
327
|
+
def get_nodes_by_story(self, story_id: str) -> list[ContextNode]:
|
|
328
|
+
"""Get all nodes associated with a story.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
story_id: Story identifier.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
List of ContextNodes for the story.
|
|
335
|
+
"""
|
|
336
|
+
nodes = []
|
|
337
|
+
for node_id in self._graph.nodes:
|
|
338
|
+
data = self._graph.nodes[node_id]
|
|
339
|
+
if data.get("story_id") == story_id:
|
|
340
|
+
nodes.append(ContextNode.from_dict(data))
|
|
341
|
+
return nodes
|
|
342
|
+
|
|
343
|
+
def get_nodes_by_type(self, node_type: NodeType) -> list[ContextNode]:
|
|
344
|
+
"""Get all nodes of a specific type.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
node_type: Type of node to filter by.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
List of ContextNodes of the given type.
|
|
351
|
+
"""
|
|
352
|
+
nodes = []
|
|
353
|
+
for node_id in self._graph.nodes:
|
|
354
|
+
data = self._graph.nodes[node_id]
|
|
355
|
+
if data.get("node_type") == node_type:
|
|
356
|
+
nodes.append(ContextNode.from_dict(data))
|
|
357
|
+
return nodes
|
|
358
|
+
|
|
359
|
+
def query_nodes(self, term: str) -> list[ContextNode]:
|
|
360
|
+
"""Search nodes whose content or ID contains term (case-insensitive).
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
term: Search string. Empty string returns [].
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
List of matching ContextNodes.
|
|
367
|
+
"""
|
|
368
|
+
if not term:
|
|
369
|
+
return []
|
|
370
|
+
term_lower = term.lower()
|
|
371
|
+
results: list[ContextNode] = []
|
|
372
|
+
for node_id in self._graph.nodes:
|
|
373
|
+
data = self._graph.nodes[node_id]
|
|
374
|
+
if (
|
|
375
|
+
term_lower in data.get("content", "").lower()
|
|
376
|
+
or term_lower in node_id.lower()
|
|
377
|
+
):
|
|
378
|
+
results.append(ContextNode.from_dict(data))
|
|
379
|
+
return results
|
|
380
|
+
|
|
381
|
+
def subgraph(self, node_id: str, depth: int = 2) -> list[ContextNode]:
|
|
382
|
+
"""Return all nodes within BFS depth of node_id (both directions).
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
node_id: Starting node ID.
|
|
386
|
+
depth: BFS traversal depth. Values <= 0 are treated as 1 (returns
|
|
387
|
+
direct neighbours). Use get_node() to retrieve only the starting
|
|
388
|
+
node without traversal.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
List of ContextNodes in the subgraph (includes starting node).
|
|
392
|
+
"""
|
|
393
|
+
if node_id not in self._graph:
|
|
394
|
+
return []
|
|
395
|
+
|
|
396
|
+
visited: set[str] = set()
|
|
397
|
+
frontier: list[str] = [node_id]
|
|
398
|
+
for _ in range(max(1, depth)):
|
|
399
|
+
next_frontier: list[str] = []
|
|
400
|
+
for nid in frontier:
|
|
401
|
+
if nid in visited:
|
|
402
|
+
continue
|
|
403
|
+
visited.add(nid)
|
|
404
|
+
for _, t in self._graph.out_edges(nid):
|
|
405
|
+
if t not in visited:
|
|
406
|
+
next_frontier.append(t)
|
|
407
|
+
for s, _ in self._graph.in_edges(nid):
|
|
408
|
+
if s not in visited:
|
|
409
|
+
next_frontier.append(s)
|
|
410
|
+
frontier = next_frontier
|
|
411
|
+
# Include final frontier tier — these were reached but not yet expanded
|
|
412
|
+
visited.update(frontier)
|
|
413
|
+
|
|
414
|
+
return [
|
|
415
|
+
ContextNode.from_dict(dict(self._graph.nodes[nid]))
|
|
416
|
+
for nid in visited
|
|
417
|
+
if nid in self._graph
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
def to_json(self) -> str:
|
|
421
|
+
"""Serialize graph to JSON string.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
JSON string representation of graph.
|
|
425
|
+
"""
|
|
426
|
+
nodes = [dict(self._graph.nodes[n]) for n in self._graph.nodes]
|
|
427
|
+
edges = [dict(self._graph.edges[e]) for e in self._graph.edges]
|
|
428
|
+
|
|
429
|
+
return json.dumps({"nodes": nodes, "edges": edges}, indent=2)
|
|
430
|
+
|
|
431
|
+
@classmethod
|
|
432
|
+
def from_json(cls, data: str, root: Path | None = None) -> ContextGraph:
|
|
433
|
+
"""Deserialize graph from JSON string.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
data: JSON string.
|
|
437
|
+
root: Project root directory.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
New ContextGraph instance.
|
|
441
|
+
"""
|
|
442
|
+
graph = cls(root=root)
|
|
443
|
+
parsed = json.loads(data)
|
|
444
|
+
|
|
445
|
+
for node_data in parsed.get("nodes", []):
|
|
446
|
+
node = ContextNode.from_dict(node_data)
|
|
447
|
+
graph._graph.add_node(node.id, **node.to_dict())
|
|
448
|
+
|
|
449
|
+
for edge_data in parsed.get("edges", []):
|
|
450
|
+
from_id = edge_data["from_id"]
|
|
451
|
+
to_id = edge_data["to_id"]
|
|
452
|
+
if from_id in graph._graph and to_id in graph._graph:
|
|
453
|
+
graph._graph.add_edge(from_id, to_id, **edge_data)
|
|
454
|
+
|
|
455
|
+
return graph
|
|
456
|
+
|
|
457
|
+
def save(self) -> Path:
|
|
458
|
+
"""Save graph to disk.
|
|
459
|
+
|
|
460
|
+
Context nodes (decisions/patterns/issues) → ref-reports/context_graph.json (durable).
|
|
461
|
+
AST nodes (module/class/function/file) → .ref_cache/ast_graph.json (regenerable).
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Path to primary context file.
|
|
465
|
+
"""
|
|
466
|
+
context_nodes = []
|
|
467
|
+
ast_nodes = []
|
|
468
|
+
for n in self._graph.nodes:
|
|
469
|
+
data = dict(self._graph.nodes[n])
|
|
470
|
+
if data.get("origin") == "ast":
|
|
471
|
+
ast_nodes.append(data)
|
|
472
|
+
else:
|
|
473
|
+
context_nodes.append(data)
|
|
474
|
+
|
|
475
|
+
all_edges = [dict(self._graph.edges[e]) for e in self._graph.edges]
|
|
476
|
+
|
|
477
|
+
# Context edges: both endpoints are context nodes
|
|
478
|
+
context_node_ids = {n["id"] for n in context_nodes}
|
|
479
|
+
context_edges = [
|
|
480
|
+
e
|
|
481
|
+
for e in all_edges
|
|
482
|
+
if e["from_id"] in context_node_ids and e["to_id"] in context_node_ids
|
|
483
|
+
]
|
|
484
|
+
# AST edges: at least one endpoint is an AST node
|
|
485
|
+
ast_edges = [e for e in all_edges if e not in context_edges]
|
|
486
|
+
|
|
487
|
+
self._primary_dir.mkdir(parents=True, exist_ok=True)
|
|
488
|
+
self._primary_file.write_text(
|
|
489
|
+
json.dumps({"nodes": context_nodes, "edges": context_edges}, indent=2)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
494
|
+
ast_cache = self._cache_dir / "ast_graph.json"
|
|
495
|
+
ast_cache.write_text(
|
|
496
|
+
json.dumps({"nodes": ast_nodes, "edges": ast_edges}, indent=2)
|
|
497
|
+
)
|
|
498
|
+
except OSError as e:
|
|
499
|
+
import structlog
|
|
500
|
+
|
|
501
|
+
structlog.get_logger(__name__).warning(
|
|
502
|
+
"ast_graph_cache_save_failed", error=str(e)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return self._primary_file
|
|
506
|
+
|
|
507
|
+
@classmethod
|
|
508
|
+
def load(cls, root: Path | None = None) -> ContextGraph:
|
|
509
|
+
"""Load graph from disk.
|
|
510
|
+
|
|
511
|
+
Loads context nodes from ref-reports/context_graph.json (durable).
|
|
512
|
+
Merges AST nodes from .ref_cache/ast_graph.json if present (regenerable).
|
|
513
|
+
|
|
514
|
+
.. warning::
|
|
515
|
+
Do not call directly — use get_context_graph().
|
|
516
|
+
Direct calls bypass the singleton and drop in-memory state accumulated
|
|
517
|
+
since the last save, causing context graph update failures (RC5).
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
root: Project root directory.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Loaded ContextGraph or empty graph if no files exist.
|
|
524
|
+
"""
|
|
525
|
+
graph = cls(root=root)
|
|
526
|
+
|
|
527
|
+
# Load durable context nodes
|
|
528
|
+
if graph._primary_file.exists():
|
|
529
|
+
try:
|
|
530
|
+
loaded = cls.from_json(graph._primary_file.read_text(), root=root)
|
|
531
|
+
graph._graph = loaded._graph
|
|
532
|
+
except (json.JSONDecodeError, ValueError, KeyError) as e:
|
|
533
|
+
import structlog
|
|
534
|
+
|
|
535
|
+
structlog.get_logger(__name__).warning(
|
|
536
|
+
"context_graph_primary_load_failed", error=str(e)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Merge AST nodes from cache (optional — missing = needs graph_populate)
|
|
540
|
+
ast_cache = graph._cache_dir / "ast_graph.json"
|
|
541
|
+
if ast_cache.exists():
|
|
542
|
+
try:
|
|
543
|
+
parsed = json.loads(ast_cache.read_text())
|
|
544
|
+
for node_data in parsed.get("nodes", []):
|
|
545
|
+
node = ContextNode.from_dict(node_data)
|
|
546
|
+
if node.id not in graph._graph:
|
|
547
|
+
graph._graph.add_node(node.id, **node.to_dict())
|
|
548
|
+
for edge_data in parsed.get("edges", []):
|
|
549
|
+
f, t = edge_data["from_id"], edge_data["to_id"]
|
|
550
|
+
if f in graph._graph and t in graph._graph:
|
|
551
|
+
graph._graph.add_edge(f, t, **edge_data)
|
|
552
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
553
|
+
import structlog
|
|
554
|
+
|
|
555
|
+
structlog.get_logger(__name__).warning(
|
|
556
|
+
"ast_graph_cache_load_failed", error=str(e)
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return graph
|
|
560
|
+
|
|
561
|
+
def format_for_prompt_graph(
|
|
562
|
+
self,
|
|
563
|
+
story_id: str | None = None,
|
|
564
|
+
depth: int = 2,
|
|
565
|
+
) -> str:
|
|
566
|
+
"""Build context string via BFS subgraph instead of flat JSONL.
|
|
567
|
+
|
|
568
|
+
Finds story node (or uses all context nodes), BFS to depth, returns
|
|
569
|
+
ranked markdown. Replaces flat chronological JSONL injection.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
story_id: Story node ID to start BFS from. None = all context nodes.
|
|
573
|
+
depth: BFS traversal depth.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Formatted markdown string for prompt injection.
|
|
577
|
+
"""
|
|
578
|
+
if self._graph.number_of_nodes() == 0:
|
|
579
|
+
return ""
|
|
580
|
+
|
|
581
|
+
# Collect seed node IDs
|
|
582
|
+
seeds: list[str] = []
|
|
583
|
+
if story_id:
|
|
584
|
+
story_nodes = [
|
|
585
|
+
n
|
|
586
|
+
for n in self._graph.nodes
|
|
587
|
+
if self._graph.nodes[n].get("story_id") == story_id
|
|
588
|
+
or self._graph.nodes[n].get("node_type") == "story"
|
|
589
|
+
and story_id in n
|
|
590
|
+
]
|
|
591
|
+
seeds = story_nodes
|
|
592
|
+
|
|
593
|
+
if not seeds:
|
|
594
|
+
# Fall back: 5 most recent non-AST nodes (timestamp desc).
|
|
595
|
+
# Capped to prevent cross-story context bleed when no active story.
|
|
596
|
+
non_ast = [
|
|
597
|
+
n
|
|
598
|
+
for n in self._graph.nodes
|
|
599
|
+
if self._graph.nodes[n].get("origin") != "ast"
|
|
600
|
+
]
|
|
601
|
+
non_ast.sort(
|
|
602
|
+
key=lambda n: self._graph.nodes[n].get("timestamp", ""),
|
|
603
|
+
reverse=True,
|
|
604
|
+
)
|
|
605
|
+
seeds = non_ast[:5]
|
|
606
|
+
|
|
607
|
+
if not seeds:
|
|
608
|
+
return ""
|
|
609
|
+
|
|
610
|
+
# BFS from seeds up to depth
|
|
611
|
+
visited: set[str] = set()
|
|
612
|
+
frontier = list(seeds)
|
|
613
|
+
for _ in range(depth):
|
|
614
|
+
next_frontier: list[str] = []
|
|
615
|
+
for node_id in frontier:
|
|
616
|
+
if node_id in visited:
|
|
617
|
+
continue
|
|
618
|
+
visited.add(node_id)
|
|
619
|
+
for _, target in self._graph.out_edges(node_id):
|
|
620
|
+
if target not in visited:
|
|
621
|
+
next_frontier.append(target)
|
|
622
|
+
for source, _ in self._graph.in_edges(node_id):
|
|
623
|
+
if source not in visited:
|
|
624
|
+
next_frontier.append(source)
|
|
625
|
+
frontier = next_frontier
|
|
626
|
+
|
|
627
|
+
visited.update(frontier)
|
|
628
|
+
|
|
629
|
+
# Group by node_type, sort by confidence on incoming edges
|
|
630
|
+
by_type: dict[str, list[dict[str, Any]]] = {}
|
|
631
|
+
for node_id in visited:
|
|
632
|
+
if node_id not in self._graph:
|
|
633
|
+
continue
|
|
634
|
+
data = dict(self._graph.nodes[node_id])
|
|
635
|
+
node_type = data.get("node_type", "unknown")
|
|
636
|
+
if node_type in AST_NODE_TYPES and data.get("origin") == "ast":
|
|
637
|
+
continue # Skip raw AST structural nodes from prompt
|
|
638
|
+
by_type.setdefault(node_type, []).append(data)
|
|
639
|
+
|
|
640
|
+
if not by_type:
|
|
641
|
+
return ""
|
|
642
|
+
|
|
643
|
+
lines: list[str] = ["\n--- SESSION CONTEXT (graph-based) ---\n"]
|
|
644
|
+
priority = ["decision", "blocker", "pattern", "issue", "story", "note"]
|
|
645
|
+
ordered = sorted(
|
|
646
|
+
by_type.keys(), key=lambda k: priority.index(k) if k in priority else 99
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
for node_type in ordered:
|
|
650
|
+
nodes_of_type = by_type[node_type]
|
|
651
|
+
lines.append(f"**{node_type.title()}s:**")
|
|
652
|
+
for n in nodes_of_type[:8]: # Cap per type
|
|
653
|
+
sid = f" [{n.get('story_id')}]" if n.get("story_id") else ""
|
|
654
|
+
lines.append(f"• {n.get('content', '')[:120]}{sid}")
|
|
655
|
+
lines.append("")
|
|
656
|
+
|
|
657
|
+
lines.append("--- END CONTEXT ---\n")
|
|
658
|
+
return "\n".join(lines)
|
|
659
|
+
|
|
660
|
+
def to_html(self) -> str:
|
|
661
|
+
"""Generate self-contained interactive HTML graph viewer (vis.js CDN).
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
HTML string. Write to ref-reports/graph.html to open in browser.
|
|
665
|
+
"""
|
|
666
|
+
graph_data = self.to_json()
|
|
667
|
+
parsed = json.loads(graph_data)
|
|
668
|
+
|
|
669
|
+
vis_nodes = []
|
|
670
|
+
color_map = {
|
|
671
|
+
"decision": "#4A90E2",
|
|
672
|
+
"pattern": "#7ED321",
|
|
673
|
+
"issue": "#D0021B",
|
|
674
|
+
"story": "#9B59B6",
|
|
675
|
+
"agent_session": "#F5A623",
|
|
676
|
+
"module": "#50E3C2",
|
|
677
|
+
"class": "#B8E986",
|
|
678
|
+
"function": "#C8C8C8",
|
|
679
|
+
"file": "#8B572A",
|
|
680
|
+
}
|
|
681
|
+
for node in parsed.get("nodes", []):
|
|
682
|
+
vis_nodes.append(
|
|
683
|
+
{
|
|
684
|
+
"id": node["id"],
|
|
685
|
+
"label": node.get("content", node["id"])[:40],
|
|
686
|
+
"title": node.get("content", ""),
|
|
687
|
+
"color": color_map.get(node.get("node_type", ""), "#888888"),
|
|
688
|
+
"group": node.get("node_type", "unknown"),
|
|
689
|
+
}
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
vis_edges = []
|
|
693
|
+
for i, edge in enumerate(parsed.get("edges", [])):
|
|
694
|
+
vis_edges.append(
|
|
695
|
+
{
|
|
696
|
+
"id": i,
|
|
697
|
+
"from": edge["from_id"],
|
|
698
|
+
"to": edge["to_id"],
|
|
699
|
+
"label": edge.get("edge_type", ""),
|
|
700
|
+
"arrows": "to",
|
|
701
|
+
"color": {"opacity": float(edge.get("confidence", 1.0))},
|
|
702
|
+
}
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
nodes_json = json.dumps(vis_nodes)
|
|
706
|
+
edges_json = json.dumps(vis_edges)
|
|
707
|
+
|
|
708
|
+
return f"""<!DOCTYPE html>
|
|
709
|
+
<html lang="en">
|
|
710
|
+
<head>
|
|
711
|
+
<meta charset="UTF-8">
|
|
712
|
+
<title>REF Context Graph</title>
|
|
713
|
+
<script src="https://cdn.jsdelivr.net/npm/vis-network@9/standalone/umd/vis-network.min.js"></script>
|
|
714
|
+
<style>
|
|
715
|
+
body {{ margin: 0; background: #1a1a2e; font-family: monospace; color: #eee; }}
|
|
716
|
+
#graph {{ width: 100vw; height: 90vh; border: none; }}
|
|
717
|
+
#info {{ padding: 8px 16px; background: #16213e; font-size: 12px; }}
|
|
718
|
+
#legend {{ display: flex; gap: 12px; flex-wrap: wrap; padding: 6px 16px; background: #0f3460; font-size: 11px; }}
|
|
719
|
+
.dot {{ width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }}
|
|
720
|
+
</style>
|
|
721
|
+
</head>
|
|
722
|
+
<body>
|
|
723
|
+
<div id="info">REF Context Graph | Nodes: {len(vis_nodes)} | Edges: {len(vis_edges)}</div>
|
|
724
|
+
<div id="legend">
|
|
725
|
+
{"".join(f'<span><span class="dot" style="background:{v}"></span>{k}</span>' for k, v in color_map.items())}
|
|
726
|
+
</div>
|
|
727
|
+
<div id="graph"></div>
|
|
728
|
+
<script>
|
|
729
|
+
const nodes = new vis.DataSet({nodes_json});
|
|
730
|
+
const edges = new vis.DataSet({edges_json});
|
|
731
|
+
const container = document.getElementById("graph");
|
|
732
|
+
const network = new vis.Network(container, {{ nodes, edges }}, {{
|
|
733
|
+
physics: {{ stabilization: {{ iterations: 150 }}, barnesHut: {{ gravitationalConstant: -8000 }} }},
|
|
734
|
+
edges: {{ font: {{ size: 10, color: "#aaa" }}, smooth: {{ type: "curvedCW", roundness: 0.2 }} }},
|
|
735
|
+
nodes: {{ font: {{ color: "#fff", size: 12 }}, shape: "dot", size: 12 }},
|
|
736
|
+
interaction: {{ hover: true, tooltipDelay: 100 }},
|
|
737
|
+
}});
|
|
738
|
+
network.on("click", p => {{
|
|
739
|
+
if (p.nodes.length) {{
|
|
740
|
+
const n = nodes.get(p.nodes[0]);
|
|
741
|
+
document.getElementById("info").textContent = n.title || n.label;
|
|
742
|
+
}}
|
|
743
|
+
}});
|
|
744
|
+
</script>
|
|
745
|
+
</body>
|
|
746
|
+
</html>"""
|
|
747
|
+
|
|
748
|
+
def to_visualization_json(self) -> str:
|
|
749
|
+
"""Export graph in format suitable for react-flow visualization.
|
|
750
|
+
|
|
751
|
+
Returns:
|
|
752
|
+
JSON string with nodes and edges for frontend.
|
|
753
|
+
"""
|
|
754
|
+
viz_nodes = []
|
|
755
|
+
for node_id in self._graph.nodes:
|
|
756
|
+
data = self._graph.nodes[node_id]
|
|
757
|
+
viz_nodes.append(
|
|
758
|
+
{
|
|
759
|
+
"id": node_id,
|
|
760
|
+
"type": data.get("node_type", "decision"),
|
|
761
|
+
"data": {
|
|
762
|
+
"label": data.get("content", "")[:50],
|
|
763
|
+
"fullContent": data.get("content", ""),
|
|
764
|
+
"nodeType": data.get("node_type", ""),
|
|
765
|
+
"storyId": data.get("story_id", ""),
|
|
766
|
+
"timestamp": data.get("timestamp", ""),
|
|
767
|
+
},
|
|
768
|
+
"position": {"x": 0, "y": 0}, # Layout computed by frontend
|
|
769
|
+
}
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
viz_edges = []
|
|
773
|
+
for idx, (source, target) in enumerate(self._graph.edges):
|
|
774
|
+
data = self._graph.edges[(source, target)]
|
|
775
|
+
viz_edges.append(
|
|
776
|
+
{
|
|
777
|
+
"id": f"e{idx}",
|
|
778
|
+
"source": source,
|
|
779
|
+
"target": target,
|
|
780
|
+
"label": data.get("edge_type", ""),
|
|
781
|
+
"type": "smoothstep",
|
|
782
|
+
}
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
return json.dumps({"nodes": viz_nodes, "edges": viz_edges}, indent=2)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# Singleton instance for global access
|
|
789
|
+
_context_graph: ContextGraph | None = None
|
|
790
|
+
_watcher_started: bool = False
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def get_context_graph(root: Path | None = None) -> ContextGraph:
|
|
794
|
+
"""Get or create the global context graph instance.
|
|
795
|
+
|
|
796
|
+
On first initialisation, also auto-starts the AST file watcher (RC2) so
|
|
797
|
+
that incremental graph updates fire on every file save without requiring an
|
|
798
|
+
explicit context(action="graph_watch") call.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
root: Optional project root. Only used on first call.
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
Global ContextGraph instance.
|
|
805
|
+
"""
|
|
806
|
+
global _context_graph, _watcher_started
|
|
807
|
+
if _context_graph is None:
|
|
808
|
+
_context_graph = ContextGraph.load(root)
|
|
809
|
+
|
|
810
|
+
if not _watcher_started:
|
|
811
|
+
_watcher_started = True
|
|
812
|
+
try:
|
|
813
|
+
import threading
|
|
814
|
+
|
|
815
|
+
from ref_agents.server import _ast_observers, _context_graph_watch
|
|
816
|
+
|
|
817
|
+
_root_str = (
|
|
818
|
+
str(_context_graph._root)
|
|
819
|
+
if hasattr(_context_graph, "_root")
|
|
820
|
+
else ""
|
|
821
|
+
)
|
|
822
|
+
if _root_str and _root_str not in _ast_observers:
|
|
823
|
+
threading.Thread(
|
|
824
|
+
target=_context_graph_watch,
|
|
825
|
+
args=(_root_str,),
|
|
826
|
+
daemon=True,
|
|
827
|
+
name="ref-ast-watcher-auto",
|
|
828
|
+
).start()
|
|
829
|
+
except (ImportError, OSError, RuntimeError):
|
|
830
|
+
# Non-fatal: watcher is a best-effort optimisation
|
|
831
|
+
pass
|
|
832
|
+
|
|
833
|
+
return _context_graph
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def reset_context_graph() -> None:
|
|
837
|
+
"""Reset the global context graph instance. Primarily for testing."""
|
|
838
|
+
global _context_graph
|
|
839
|
+
_context_graph = None
|