@0dai-dev/cli 4.3.5 → 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 +214 -40
- 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 +55 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +545 -26
- package/lib/commands/experience.js +40 -5
- package/lib/commands/export.js +73 -0
- 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 +222 -30
- package/lib/commands/mcp.js +129 -21
- 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 +18 -7
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +44 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +286 -0
- 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 +46 -9
- 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 +97 -14
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +230 -11
- package/lib/utils/constants.js +7 -1
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +198 -1
- 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,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure helpers extracted from ``graph_legacy.py`` (#757 / #1069 LOC split).
|
|
3
|
+
|
|
4
|
+
These functions have no I/O, no network, no graph mutation, and no
|
|
5
|
+
``record_graph_usage`` side effects. They depend only on the standard
|
|
6
|
+
library. ``graph_legacy.py`` re-imports them for back-compat so callers and
|
|
7
|
+
tests referencing ``graph_legacy.<helper>`` (and the ``graph`` facade that
|
|
8
|
+
sources ``_parse_constraints_yaml`` / ``_extract_violation_pattern`` from
|
|
9
|
+
``graph_legacy``) keep resolving unchanged.
|
|
10
|
+
|
|
11
|
+
Behavior-preserving move only — no logic, signature, or control-flow change.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _extract_keywords(text: str, min_len: int = 3) -> set[str]:
|
|
21
|
+
"""Extract lowercased keyword tokens from free-form text.
|
|
22
|
+
|
|
23
|
+
Used by find_similar_outcomes for semantic matching. Strips
|
|
24
|
+
punctuation, drops stopwords (a coarse English list is enough —
|
|
25
|
+
role prompts dominate the vocabulary anyway).
|
|
26
|
+
"""
|
|
27
|
+
stopwords = frozenset({
|
|
28
|
+
"the", "a", "an", "and", "or", "but", "if", "in", "on", "at",
|
|
29
|
+
"to", "for", "of", "with", "by", "from", "as", "is", "was",
|
|
30
|
+
"are", "be", "been", "being", "this", "that", "these", "those",
|
|
31
|
+
"i", "we", "you", "they", "it", "its", "our", "your", "their",
|
|
32
|
+
"how", "what", "when", "where", "why", "which", "who", "should",
|
|
33
|
+
"would", "could", "can", "will", "do", "does", "did", "have",
|
|
34
|
+
"has", "had", "not", "no", "yes", "than", "then", "so", "just",
|
|
35
|
+
"about", "into", "over", "under", "above", "below",
|
|
36
|
+
})
|
|
37
|
+
tokens = re.findall(r"[a-zA-Z][a-zA-Z0-9_]{%d,}" % (min_len - 1), text.lower())
|
|
38
|
+
return {t for t in tokens if t not in stopwords and len(t) >= min_len}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_constraints_yaml(raw: str) -> list[dict[str, Any]]:
|
|
42
|
+
"""Minimal YAML parser for constraints files.
|
|
43
|
+
|
|
44
|
+
Handles the common subset:
|
|
45
|
+
- top-level `constraints:` key
|
|
46
|
+
- list entries starting with ` - constraint_id:`
|
|
47
|
+
- string scalars (quoted or unquoted)
|
|
48
|
+
- list values (implies, forbids) as ` - "item"`
|
|
49
|
+
|
|
50
|
+
No PyYAML dependency needed for this limited grammar.
|
|
51
|
+
"""
|
|
52
|
+
entries: list[dict[str, Any]] = []
|
|
53
|
+
current: Optional[dict[str, Any]] = None
|
|
54
|
+
current_list_key: Optional[str] = None
|
|
55
|
+
|
|
56
|
+
for line in raw.splitlines():
|
|
57
|
+
stripped = line.strip()
|
|
58
|
+
|
|
59
|
+
# Skip empty lines and comments
|
|
60
|
+
if not stripped or stripped.startswith("#"):
|
|
61
|
+
current_list_key = None
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Top-level constraints key
|
|
65
|
+
if stripped == "constraints:":
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# New list entry
|
|
69
|
+
if stripped.startswith("- constraint_id:") or stripped.startswith("-constraint_id:"):
|
|
70
|
+
if current is not None:
|
|
71
|
+
entries.append(current)
|
|
72
|
+
current = {}
|
|
73
|
+
current_list_key = None
|
|
74
|
+
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
|
75
|
+
current["constraint_id"] = val
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# We're inside an entry
|
|
79
|
+
if current is not None:
|
|
80
|
+
# List item (indented under implies/forbids/constrains)
|
|
81
|
+
if stripped.startswith("- ") and current_list_key:
|
|
82
|
+
val = stripped[2:].strip().strip('"').strip("'")
|
|
83
|
+
current.setdefault(current_list_key, []).append(val)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
# Key-value pair
|
|
87
|
+
if ":" in stripped:
|
|
88
|
+
key, _, val = stripped.partition(":")
|
|
89
|
+
key = key.strip()
|
|
90
|
+
val = val.strip().strip('"').strip("'")
|
|
91
|
+
|
|
92
|
+
if key in ("implies", "forbids", "constrains"):
|
|
93
|
+
if val:
|
|
94
|
+
# Inline single value: `implies: some rule`
|
|
95
|
+
current[key] = [val]
|
|
96
|
+
else:
|
|
97
|
+
# Multi-line list follows
|
|
98
|
+
current[key] = []
|
|
99
|
+
current_list_key = key
|
|
100
|
+
else:
|
|
101
|
+
current[key] = val
|
|
102
|
+
current_list_key = None
|
|
103
|
+
|
|
104
|
+
if current is not None:
|
|
105
|
+
entries.append(current)
|
|
106
|
+
|
|
107
|
+
return entries
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _path_patterns_overlap(requested: list[str], scoped: list[str]) -> bool:
|
|
111
|
+
if not scoped:
|
|
112
|
+
return True
|
|
113
|
+
normalized_requested = [p.replace("\\", "/") for p in requested if p]
|
|
114
|
+
normalized_scoped = [p.replace("\\", "/") for p in scoped if p]
|
|
115
|
+
for req in normalized_requested:
|
|
116
|
+
if req in {"*", "**"}:
|
|
117
|
+
return True
|
|
118
|
+
for scope in normalized_scoped:
|
|
119
|
+
if scope in {"*", "**"}:
|
|
120
|
+
return True
|
|
121
|
+
if fnmatch.fnmatch(req, scope) or fnmatch.fnmatch(scope, req):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_diff_lines(diff_content: str) -> tuple[str, list[tuple[int, str]]]:
|
|
127
|
+
file_path = ""
|
|
128
|
+
parsed: list[tuple[int, str]] = []
|
|
129
|
+
current_line = 0
|
|
130
|
+
|
|
131
|
+
for raw_line in diff_content.splitlines():
|
|
132
|
+
if raw_line.startswith("FILE: "):
|
|
133
|
+
file_path = raw_line[6:].strip()
|
|
134
|
+
continue
|
|
135
|
+
if raw_line.startswith("@@"):
|
|
136
|
+
match = re.search(r"\+(\d+)", raw_line)
|
|
137
|
+
if match:
|
|
138
|
+
current_line = int(match.group(1))
|
|
139
|
+
continue
|
|
140
|
+
if raw_line.startswith("+++"):
|
|
141
|
+
continue
|
|
142
|
+
if raw_line.startswith("+") and not raw_line.startswith("++"):
|
|
143
|
+
numbered = re.match(r"^\+(\d+):(.*)$", raw_line)
|
|
144
|
+
if numbered:
|
|
145
|
+
parsed.append((int(numbered.group(1)), numbered.group(2)))
|
|
146
|
+
continue
|
|
147
|
+
current_line = current_line or 1
|
|
148
|
+
parsed.append((current_line, raw_line[1:]))
|
|
149
|
+
current_line += 1
|
|
150
|
+
return file_path, parsed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_violation_pattern(rule: str) -> str:
|
|
154
|
+
"""Extract a concrete search pattern from a natural-language forbids rule.
|
|
155
|
+
|
|
156
|
+
Strategy:
|
|
157
|
+
- If the rule contains quoted text (e.g. "localhost"), use that.
|
|
158
|
+
- If the rule contains a technical term (camelCase, snake_case, or
|
|
159
|
+
path-like), extract it.
|
|
160
|
+
- Otherwise use the first content word.
|
|
161
|
+
"""
|
|
162
|
+
# Check for quoted text
|
|
163
|
+
import re as _re
|
|
164
|
+
quoted = _re.findall(r'["\']([^"\']+)["\']', rule)
|
|
165
|
+
if quoted:
|
|
166
|
+
return quoted[0]
|
|
167
|
+
|
|
168
|
+
# Check for technical terms (paths, identifiers)
|
|
169
|
+
tech = _re.findall(r'[a-zA-Z_][a-zA-Z0-9_./:-]+', rule)
|
|
170
|
+
if tech:
|
|
171
|
+
# Skip common non-pattern words
|
|
172
|
+
skip = {"in", "the", "a", "an", "and", "or", "of", "for", "to", "with", "not", "no"}
|
|
173
|
+
for t in tech:
|
|
174
|
+
if t.lower() not in skip and len(t) >= 3:
|
|
175
|
+
return t
|
|
176
|
+
|
|
177
|
+
return ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _line_matches_constraint_rule(rule: str, line: str) -> bool:
|
|
181
|
+
lower_line = line.lower()
|
|
182
|
+
if rule == "no-localhost-on-service-bound":
|
|
183
|
+
if lower_line.lstrip().startswith(("#", "//")):
|
|
184
|
+
return False
|
|
185
|
+
return "localhost" in lower_line or "127.0.0.1" in lower_line
|
|
186
|
+
if rule == "connection-string-consistency":
|
|
187
|
+
if not re.search(r"(database_url|db_url|postgres(?:ql)?://|sqlalchemy\.url)", lower_line):
|
|
188
|
+
return False
|
|
189
|
+
return any(
|
|
190
|
+
token in lower_line
|
|
191
|
+
for token in ("mysql://", "sqlite://", "mongodb://", "redis://")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
pattern = _extract_violation_pattern(rule).lower()
|
|
195
|
+
return bool(pattern and pattern in lower_line)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def matches_constraint_diff(constraint: dict, diff_content: str) -> list[dict[str, Any]]:
|
|
199
|
+
"""Return concrete line-level violations for a constraint within diff content."""
|
|
200
|
+
file_path, parsed_lines = _parse_diff_lines(diff_content)
|
|
201
|
+
rule_names = list(constraint.get("diff_rules") or [])
|
|
202
|
+
if not rule_names:
|
|
203
|
+
rule_names = list(constraint.get("forbids") or [])
|
|
204
|
+
|
|
205
|
+
matches: list[dict[str, Any]] = []
|
|
206
|
+
for line_no, line in parsed_lines:
|
|
207
|
+
for rule in rule_names:
|
|
208
|
+
if not _line_matches_constraint_rule(str(rule), line):
|
|
209
|
+
continue
|
|
210
|
+
matches.append(
|
|
211
|
+
{
|
|
212
|
+
"constraint_id": constraint.get("constraint_id", constraint.get("id", "")),
|
|
213
|
+
"constraint_name": constraint.get("name", constraint.get("constraint_id", constraint.get("id", ""))),
|
|
214
|
+
"enforcement": constraint.get("enforcement", "hard"),
|
|
215
|
+
"rule": str(rule),
|
|
216
|
+
"file": file_path,
|
|
217
|
+
"line": line_no,
|
|
218
|
+
"content": line.strip(),
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
return matches
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared outcome-ranking helpers used by the context slice."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from graph_core import nodes_by_type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _extract_keywords(text: str, min_len: int = 3) -> set[str]:
|
|
12
|
+
stopwords = frozenset({
|
|
13
|
+
"the", "a", "an", "and", "or", "but", "if", "in", "on", "at",
|
|
14
|
+
"to", "for", "of", "with", "by", "from", "as", "is", "was",
|
|
15
|
+
"are", "be", "been", "being", "this", "that", "these", "those",
|
|
16
|
+
"i", "we", "you", "they", "it", "its", "our", "your", "their",
|
|
17
|
+
"how", "what", "when", "where", "why", "which", "who", "should",
|
|
18
|
+
"would", "could", "can", "will", "do", "does", "did", "have",
|
|
19
|
+
"has", "had", "not", "no", "yes", "than", "then", "so", "just",
|
|
20
|
+
"about", "into", "over", "under", "above", "below",
|
|
21
|
+
})
|
|
22
|
+
tokens = re.findall(r"[a-zA-Z][a-zA-Z0-9_]{%d,}" % (min_len - 1), text.lower())
|
|
23
|
+
return {t for t in tokens if t not in stopwords and len(t) >= min_len}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_similar_outcomes(
|
|
27
|
+
graph: dict,
|
|
28
|
+
task_text: str,
|
|
29
|
+
limit: int = 3,
|
|
30
|
+
) -> list[dict]:
|
|
31
|
+
if not task_text.strip():
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
keywords = _extract_keywords(task_text)
|
|
35
|
+
if not keywords:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
scored: list[tuple[int, str, dict]] = []
|
|
39
|
+
for node in nodes_by_type(graph, "Outcome"):
|
|
40
|
+
if node.get("outcome_status") == "confirmed":
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
node_tags = {str(t).lower() for t in node.get("tags", [])}
|
|
44
|
+
tag_overlap = len(node_tags & keywords)
|
|
45
|
+
|
|
46
|
+
decision_id = node.get("decision_id", "")
|
|
47
|
+
decision = graph["nodes"].get(decision_id, {})
|
|
48
|
+
decision_name = decision.get("name", "").lower()
|
|
49
|
+
decision_match = sum(1 for kw in keywords if kw in decision_name)
|
|
50
|
+
|
|
51
|
+
score = tag_overlap * 2 + decision_match
|
|
52
|
+
if score > 0:
|
|
53
|
+
scored.append((score, node.get("updated_at", ""), node))
|
|
54
|
+
|
|
55
|
+
scored.sort(key=lambda x: (-x[0], x[1]), reverse=False)
|
|
56
|
+
scored.sort(key=lambda x: (-x[0], -ord(x[1][0]) if x[1] else 0))
|
|
57
|
+
return [outcome for _score, _ts, outcome in scored[:limit]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_lessons_block(outcomes: list[dict], graph: dict) -> str:
|
|
61
|
+
if not outcomes:
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
lines = ["<lessons_learned>"]
|
|
65
|
+
for outcome in outcomes:
|
|
66
|
+
decision_id = outcome.get("decision_id", "")
|
|
67
|
+
decision = graph["nodes"].get(decision_id, {})
|
|
68
|
+
decision_name = decision.get("name", decision_id)
|
|
69
|
+
status = outcome.get("outcome_status", "unknown")
|
|
70
|
+
|
|
71
|
+
lines.append(
|
|
72
|
+
f'Similar past decision "{decision_id}: {decision_name}" was {status}.'
|
|
73
|
+
)
|
|
74
|
+
actual = outcome.get("actual_result", "").strip()
|
|
75
|
+
if actual:
|
|
76
|
+
lines.append(f"Reason: {actual}")
|
|
77
|
+
lesson = outcome.get("lessons_learned", "").strip()
|
|
78
|
+
if lesson:
|
|
79
|
+
lines.append(f"Lesson: {lesson}")
|
|
80
|
+
lines.append("")
|
|
81
|
+
|
|
82
|
+
while lines and not lines[-1]:
|
|
83
|
+
lines.pop()
|
|
84
|
+
lines.append("</lessons_learned>")
|
|
85
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Graph query helpers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
|
|
8
|
+
from graph_core import nodes_by_type
|
|
9
|
+
from graph_io import record_graph_usage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decisions_for(graph: dict, node_id: str) -> list[dict]:
|
|
13
|
+
record_graph_usage(graph, "decisions_for", kind="queries")
|
|
14
|
+
results: list[dict] = []
|
|
15
|
+
for edge in graph["edges"]:
|
|
16
|
+
if edge["to"] != node_id:
|
|
17
|
+
continue
|
|
18
|
+
if edge["type"] not in ("affects", "satisfies", "chose"):
|
|
19
|
+
continue
|
|
20
|
+
dec = graph["nodes"].get(edge["from"])
|
|
21
|
+
if dec and dec.get("type") == "Decision":
|
|
22
|
+
results.append(dec)
|
|
23
|
+
return sorted(results, key=lambda n: n.get("created_at", ""))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ancestors_of(graph: dict, node_id: str, max_depth: int = 10) -> list[dict]:
|
|
27
|
+
record_graph_usage(graph, "ancestors_of", kind="queries")
|
|
28
|
+
visited: set[str] = {node_id}
|
|
29
|
+
results: list[dict] = []
|
|
30
|
+
queue: list[tuple[str, int, str]] = [(node_id, 0, "self")]
|
|
31
|
+
|
|
32
|
+
while queue:
|
|
33
|
+
current_id, depth, edge_type = queue.pop(0)
|
|
34
|
+
if depth >= max_depth:
|
|
35
|
+
continue
|
|
36
|
+
for edge in graph["edges"]:
|
|
37
|
+
if edge["from"] == current_id and edge["type"] == "decision_ancestry":
|
|
38
|
+
parent_id = edge["to"]
|
|
39
|
+
if parent_id not in visited:
|
|
40
|
+
visited.add(parent_id)
|
|
41
|
+
parent = graph["nodes"].get(parent_id)
|
|
42
|
+
if parent and parent.get("type") == "Decision":
|
|
43
|
+
results.append({
|
|
44
|
+
"node": parent,
|
|
45
|
+
"depth": depth + 1,
|
|
46
|
+
"edge_type": "decision_ancestry",
|
|
47
|
+
"edge_reason": edge.get("reason", ""),
|
|
48
|
+
})
|
|
49
|
+
queue.append((parent_id, depth + 1, "decision_ancestry"))
|
|
50
|
+
if edge["from"] == current_id and edge["type"] == "supersedes":
|
|
51
|
+
old_id = edge["to"]
|
|
52
|
+
if old_id not in visited:
|
|
53
|
+
visited.add(old_id)
|
|
54
|
+
old = graph["nodes"].get(old_id)
|
|
55
|
+
if old and old.get("type") == "Decision":
|
|
56
|
+
results.append({
|
|
57
|
+
"node": old,
|
|
58
|
+
"depth": depth + 1,
|
|
59
|
+
"edge_type": "supersedes",
|
|
60
|
+
"edge_reason": edge.get("reason", ""),
|
|
61
|
+
})
|
|
62
|
+
queue.append((old_id, depth + 1, "supersedes"))
|
|
63
|
+
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def descendants_of(graph: dict, node_id: str, max_depth: int = 10) -> list[dict]:
|
|
68
|
+
record_graph_usage(graph, "descendants_of", kind="queries")
|
|
69
|
+
visited: set[str] = {node_id}
|
|
70
|
+
results: list[dict] = []
|
|
71
|
+
queue: list[tuple[str, int, str]] = [(node_id, 0, "self")]
|
|
72
|
+
|
|
73
|
+
while queue:
|
|
74
|
+
current_id, depth, edge_type = queue.pop(0)
|
|
75
|
+
if depth >= max_depth:
|
|
76
|
+
continue
|
|
77
|
+
for edge in graph["edges"]:
|
|
78
|
+
if edge["to"] == current_id and edge["type"] == "decision_ancestry":
|
|
79
|
+
child_id = edge["from"]
|
|
80
|
+
if child_id not in visited:
|
|
81
|
+
visited.add(child_id)
|
|
82
|
+
child = graph["nodes"].get(child_id)
|
|
83
|
+
if child and child.get("type") == "Decision":
|
|
84
|
+
results.append({
|
|
85
|
+
"node": child,
|
|
86
|
+
"depth": depth + 1,
|
|
87
|
+
"edge_type": "decision_ancestry",
|
|
88
|
+
"edge_reason": edge.get("reason", ""),
|
|
89
|
+
})
|
|
90
|
+
queue.append((child_id, depth + 1, "decision_ancestry"))
|
|
91
|
+
if edge["to"] == current_id and edge["type"] == "supersedes":
|
|
92
|
+
new_id = edge["from"]
|
|
93
|
+
if new_id not in visited:
|
|
94
|
+
visited.add(new_id)
|
|
95
|
+
new = graph["nodes"].get(new_id)
|
|
96
|
+
if new and new.get("type") == "Decision":
|
|
97
|
+
results.append({
|
|
98
|
+
"node": new,
|
|
99
|
+
"depth": depth + 1,
|
|
100
|
+
"edge_type": "supersedes",
|
|
101
|
+
"edge_reason": edge.get("reason", ""),
|
|
102
|
+
})
|
|
103
|
+
queue.append((new_id, depth + 1, "supersedes"))
|
|
104
|
+
|
|
105
|
+
return results
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def tech_context(graph: dict) -> list[dict]:
|
|
109
|
+
record_graph_usage(graph, "tech_context", kind="queries")
|
|
110
|
+
return nodes_by_type(graph, "Technology")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def open_risks(graph: dict) -> list[dict]:
|
|
114
|
+
record_graph_usage(graph, "open_risks", kind="queries")
|
|
115
|
+
mitigated: set[str] = set()
|
|
116
|
+
for edge in graph["edges"]:
|
|
117
|
+
if edge["type"] == "mitigates":
|
|
118
|
+
mitigated.add(edge["to"])
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
n for n in nodes_by_type(graph, "Risk")
|
|
122
|
+
if n["id"] not in mitigated and n.get("status", "active") != "resolved"
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def impact(graph: dict, node_id: str) -> dict[str, list[str]]:
|
|
127
|
+
record_graph_usage(graph, "impact", kind="queries")
|
|
128
|
+
result: dict[str, list[str]] = {}
|
|
129
|
+
for edge in graph["edges"]:
|
|
130
|
+
if edge["to"] != node_id:
|
|
131
|
+
continue
|
|
132
|
+
result.setdefault(edge["type"], []).append(edge["from"])
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def stale_tech(graph: dict, max_age_days: int = 7) -> list[dict]:
|
|
137
|
+
record_graph_usage(graph, "stale_tech", kind="queries")
|
|
138
|
+
cutoff = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(days=max_age_days)
|
|
139
|
+
result: list[dict] = []
|
|
140
|
+
for node in nodes_by_type(graph, "Technology"):
|
|
141
|
+
checked_at_raw = node.get("scout_checked_at")
|
|
142
|
+
if not checked_at_raw:
|
|
143
|
+
result.append(node)
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
checked_at = _dt.datetime.strptime(
|
|
147
|
+
checked_at_raw.replace("Z", "+0000"),
|
|
148
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
149
|
+
)
|
|
150
|
+
except (ValueError, AttributeError):
|
|
151
|
+
result.append(node)
|
|
152
|
+
continue
|
|
153
|
+
if checked_at < cutoff:
|
|
154
|
+
result.append(node)
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def unsatisfied_reqs(graph: dict) -> list[dict]:
|
|
159
|
+
record_graph_usage(graph, "unsatisfied_reqs", kind="queries")
|
|
160
|
+
satisfied: set[str] = set()
|
|
161
|
+
violated: set[str] = set()
|
|
162
|
+
for edge in graph["edges"]:
|
|
163
|
+
if edge["type"] == "satisfies":
|
|
164
|
+
satisfied.add(edge["to"])
|
|
165
|
+
elif edge["type"] == "violates":
|
|
166
|
+
violated.add(edge["to"])
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
n for n in nodes_by_type(graph, "Requirement")
|
|
170
|
+
if n["id"] not in satisfied or n["id"] in violated
|
|
171
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Traversal helpers for prompt-context slices."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from graph_core import (
|
|
9
|
+
CHARS_PER_TOKEN,
|
|
10
|
+
DEFAULT_EDGE_WEIGHT,
|
|
11
|
+
DEFAULT_SLICE_TOKEN_BUDGET,
|
|
12
|
+
HOP2_WEIGHT_THRESHOLD,
|
|
13
|
+
ROLE_TYPE_INTERESTS,
|
|
14
|
+
_slug,
|
|
15
|
+
)
|
|
16
|
+
from graph_io import record_graph_usage
|
|
17
|
+
from graph_outcomes_core import find_similar_outcomes, format_lessons_block
|
|
18
|
+
from graph_queries import open_risks, tech_context
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_anchors(graph: dict, task_text: str, max_anchors: int = 8) -> list[str]:
|
|
22
|
+
record_graph_usage(graph, "extract_anchors", kind="queries")
|
|
23
|
+
if not task_text.strip():
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
text_lower = task_text.lower()
|
|
27
|
+
scores: dict[str, int] = {}
|
|
28
|
+
|
|
29
|
+
for node_id, node in graph["nodes"].items():
|
|
30
|
+
if node_id in task_text:
|
|
31
|
+
scores[node_id] = max(scores.get(node_id, 0), 3)
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
name = node.get("name", "")
|
|
35
|
+
name_lower = name.lower()
|
|
36
|
+
if name_lower and name_lower in text_lower:
|
|
37
|
+
scores[node_id] = max(scores.get(node_id, 0), 2)
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
slug = _slug(name)
|
|
41
|
+
if slug and len(slug) >= 3 and slug in text_lower:
|
|
42
|
+
scores[node_id] = max(scores.get(node_id, 0), 1)
|
|
43
|
+
|
|
44
|
+
ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
45
|
+
return [node_id for node_id, _score in ranked[:max_anchors]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def expand_bfs(
|
|
49
|
+
graph: dict,
|
|
50
|
+
anchors: Iterable[str],
|
|
51
|
+
*,
|
|
52
|
+
hops: int = 2,
|
|
53
|
+
hop2_weight_threshold: float = HOP2_WEIGHT_THRESHOLD,
|
|
54
|
+
) -> set[str]:
|
|
55
|
+
record_graph_usage(graph, "expand_bfs", kind="queries")
|
|
56
|
+
if hops < 1:
|
|
57
|
+
raise ValueError(f"hops must be >= 1, got {hops}")
|
|
58
|
+
|
|
59
|
+
visited: set[str] = set()
|
|
60
|
+
frontier: set[str] = set()
|
|
61
|
+
|
|
62
|
+
for anchor in anchors:
|
|
63
|
+
if anchor in graph["nodes"]:
|
|
64
|
+
visited.add(anchor)
|
|
65
|
+
frontier.add(anchor)
|
|
66
|
+
|
|
67
|
+
for hop in range(1, hops + 1):
|
|
68
|
+
next_frontier: set[str] = set()
|
|
69
|
+
for node_id in frontier:
|
|
70
|
+
for edge in graph["edges"]:
|
|
71
|
+
if edge["from"] != node_id and edge["to"] != node_id:
|
|
72
|
+
continue
|
|
73
|
+
if hop >= 2 and edge.get("weight", DEFAULT_EDGE_WEIGHT) < hop2_weight_threshold:
|
|
74
|
+
continue
|
|
75
|
+
neighbor = edge["to"] if edge["from"] == node_id else edge["from"]
|
|
76
|
+
if neighbor not in visited and neighbor in graph["nodes"]:
|
|
77
|
+
next_frontier.add(neighbor)
|
|
78
|
+
visited.add(neighbor)
|
|
79
|
+
if not next_frontier:
|
|
80
|
+
break
|
|
81
|
+
frontier = next_frontier
|
|
82
|
+
|
|
83
|
+
return visited
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def filter_by_role(
|
|
87
|
+
graph: dict,
|
|
88
|
+
node_ids: Iterable[str],
|
|
89
|
+
role: str,
|
|
90
|
+
) -> set[str]:
|
|
91
|
+
record_graph_usage(graph, "filter_by_role", kind="queries")
|
|
92
|
+
interests = ROLE_TYPE_INTERESTS.get(role)
|
|
93
|
+
if interests is None:
|
|
94
|
+
return set(node_ids)
|
|
95
|
+
|
|
96
|
+
result: set[str] = set()
|
|
97
|
+
for node_id in node_ids:
|
|
98
|
+
node = graph["nodes"].get(node_id)
|
|
99
|
+
if node and node.get("type") in interests:
|
|
100
|
+
result.add(node_id)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def serialize_slice(
|
|
105
|
+
graph: dict,
|
|
106
|
+
node_ids: Iterable[str],
|
|
107
|
+
*,
|
|
108
|
+
token_budget: int = DEFAULT_SLICE_TOKEN_BUDGET,
|
|
109
|
+
) -> str:
|
|
110
|
+
record_graph_usage(graph, "serialize_slice", kind="queries")
|
|
111
|
+
selected = [graph["nodes"][nid] for nid in node_ids if nid in graph["nodes"]]
|
|
112
|
+
if not selected:
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
char_budget = token_budget * CHARS_PER_TOKEN
|
|
116
|
+
|
|
117
|
+
by_type: dict[str, list[dict]] = {}
|
|
118
|
+
for node in selected:
|
|
119
|
+
by_type.setdefault(node["type"], []).append(node)
|
|
120
|
+
|
|
121
|
+
selected_ids = {n["id"] for n in selected}
|
|
122
|
+
edges_by_source: dict[str, list[str]] = {}
|
|
123
|
+
for edge in graph["edges"]:
|
|
124
|
+
src, tgt = edge["from"], edge["to"]
|
|
125
|
+
if src in selected_ids and tgt in selected_ids:
|
|
126
|
+
edges_by_source.setdefault(src, []).append(f"{edge['type']}->{tgt}")
|
|
127
|
+
|
|
128
|
+
lines: list[str] = []
|
|
129
|
+
for node_type in sorted(by_type.keys()):
|
|
130
|
+
lines.append(f"[{node_type}]")
|
|
131
|
+
for node in sorted(by_type[node_type], key=lambda n: n["id"]):
|
|
132
|
+
parts = [f" {node['id']}: {node.get('name', '')}"]
|
|
133
|
+
status = node.get("status", "")
|
|
134
|
+
if status and status != "active":
|
|
135
|
+
parts.append(f"({status})")
|
|
136
|
+
desc = node.get("description", "")
|
|
137
|
+
if desc:
|
|
138
|
+
parts.append(f"— {desc[:100]}")
|
|
139
|
+
out_edges = edges_by_source.get(node["id"], [])
|
|
140
|
+
if out_edges:
|
|
141
|
+
parts.append(f"[{', '.join(out_edges[:3])}]")
|
|
142
|
+
lines.append(" ".join(parts))
|
|
143
|
+
|
|
144
|
+
text = "\n".join(lines)
|
|
145
|
+
if len(text) > char_budget:
|
|
146
|
+
truncated_lines: list[str] = []
|
|
147
|
+
running = 0
|
|
148
|
+
for line in lines:
|
|
149
|
+
if running + len(line) + 1 > char_budget:
|
|
150
|
+
remaining = len(lines) - len(truncated_lines)
|
|
151
|
+
truncated_lines.append(f"... ({remaining} more)")
|
|
152
|
+
break
|
|
153
|
+
truncated_lines.append(line)
|
|
154
|
+
running += len(line) + 1
|
|
155
|
+
text = "\n".join(truncated_lines)
|
|
156
|
+
return text
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_context_slice(
|
|
160
|
+
graph: dict,
|
|
161
|
+
task_text: str,
|
|
162
|
+
role: str,
|
|
163
|
+
*,
|
|
164
|
+
hops: int = 2,
|
|
165
|
+
token_budget: int = DEFAULT_SLICE_TOKEN_BUDGET,
|
|
166
|
+
) -> str:
|
|
167
|
+
record_graph_usage(graph, "build_context_slice", kind="queries")
|
|
168
|
+
anchors = extract_anchors(graph, task_text)
|
|
169
|
+
if not anchors:
|
|
170
|
+
return ""
|
|
171
|
+
expanded = expand_bfs(graph, anchors, hops=hops)
|
|
172
|
+
filtered = filter_by_role(graph, expanded, role)
|
|
173
|
+
text = serialize_slice(graph, filtered, token_budget=token_budget)
|
|
174
|
+
|
|
175
|
+
sections: list[str] = []
|
|
176
|
+
if text:
|
|
177
|
+
sections.append(text)
|
|
178
|
+
|
|
179
|
+
lessons = find_similar_outcomes(graph, task_text, limit=2)
|
|
180
|
+
if lessons:
|
|
181
|
+
sections.append(format_lessons_block(lessons, graph))
|
|
182
|
+
|
|
183
|
+
risks = open_risks(graph)
|
|
184
|
+
if risks:
|
|
185
|
+
risk_lines = ["[open_risks]"]
|
|
186
|
+
for r in risks[:5]:
|
|
187
|
+
sev = r.get("severity", "unknown")
|
|
188
|
+
risk_lines.append(f" {r['id']} [{sev}] {r.get('name', '')}")
|
|
189
|
+
sections.append("\n".join(risk_lines))
|
|
190
|
+
|
|
191
|
+
tech = tech_context(graph)
|
|
192
|
+
if tech:
|
|
193
|
+
tech_lines = ["[technology]"]
|
|
194
|
+
for t in tech[:15]:
|
|
195
|
+
tech_lines.append(f" {t['id']}: {t.get('name', '')}")
|
|
196
|
+
sections.append("\n".join(tech_lines))
|
|
197
|
+
|
|
198
|
+
return "\n\n".join(sections) if sections else ""
|