@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.
Files changed (75) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +127 -30
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +506 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +209 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/provider.js +30 -59
  18. package/lib/commands/quota.js +1 -1
  19. package/lib/commands/receipt.js +1 -1
  20. package/lib/commands/run.js +14 -6
  21. package/lib/commands/runner.js +31 -1
  22. package/lib/commands/status.js +38 -10
  23. package/lib/commands/swarm.js +130 -12
  24. package/lib/commands/update.js +184 -38
  25. package/lib/commands/usage.js +1 -1
  26. package/lib/commands/validate.js +32 -3
  27. package/lib/commands/vault.js +43 -8
  28. package/lib/python/__init__.py +0 -0
  29. package/lib/python/agent_quotas.py +525 -0
  30. package/lib/python/anomaly_alert.py +397 -0
  31. package/lib/python/anti_pattern_detector.py +799 -0
  32. package/lib/python/auth.py +443 -0
  33. package/lib/python/capi_profile_guard.py +477 -0
  34. package/lib/python/compliance_report.py +581 -0
  35. package/lib/python/drift_detector.py +388 -0
  36. package/lib/python/experience_pipeline.py +1130 -0
  37. package/lib/python/graph.py +19 -0
  38. package/lib/python/graph_core.py +293 -0
  39. package/lib/python/graph_io.py +179 -0
  40. package/lib/python/graph_legacy.py +2052 -0
  41. package/lib/python/graph_legacy_helpers.py +221 -0
  42. package/lib/python/graph_outcomes_core.py +85 -0
  43. package/lib/python/graph_queries.py +171 -0
  44. package/lib/python/graph_slice.py +198 -0
  45. package/lib/python/graph_slicer.py +576 -0
  46. package/lib/python/graph_slicer_cli.py +60 -0
  47. package/lib/python/graph_validation.py +64 -0
  48. package/lib/python/heatmap.py +934 -0
  49. package/lib/python/json_utils.py +193 -0
  50. package/lib/python/mcp_exposure_check.py +247 -0
  51. package/lib/python/model_router.py +1434 -0
  52. package/lib/python/project_manager.py +621 -0
  53. package/lib/python/provider_profiles.py +1618 -0
  54. package/lib/python/provider_registry.py +1211 -0
  55. package/lib/python/provider_registry_cli.py +125 -0
  56. package/lib/python/receipt_png.py +727 -0
  57. package/lib/python/structural_memory.py +325 -0
  58. package/lib/python/swarm_cost.py +177 -0
  59. package/lib/python/usage_ledger.py +569 -0
  60. package/lib/scripts/mcp_tier_config.py +240 -0
  61. package/lib/shared.js +95 -12
  62. package/lib/tui/index.mjs +35174 -0
  63. package/lib/utils/activation_telemetry.js +1 -4
  64. package/lib/utils/constants.js +7 -1
  65. package/lib/utils/identity.js +184 -0
  66. package/lib/utils/mcp-auth.js +81 -15
  67. package/lib/utils/plan.js +1 -1
  68. package/lib/vault/index.js +19 -3
  69. package/lib/vault/storage.js +21 -2
  70. package/lib/wizard.js +5 -2
  71. package/package.json +9 -3
  72. package/scripts/build-python-bundle.js +106 -0
  73. package/scripts/build-tui.js +14 -1
  74. package/scripts/harvest_experience.py +523 -0
  75. 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 ""