strray-ai 1.18.1 → 1.18.2
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/dist/cli/commands/skill-install.d.ts.map +1 -1
- package/dist/cli/commands/skill-install.js +1 -0
- package/dist/cli/commands/skill-install.js.map +1 -1
- package/dist/integrations/base/README.md +446 -0
- package/dist/integrations/hermes-agent/__init__.py +732 -0
- package/dist/integrations/hermes-agent/after-install.md +71 -0
- package/dist/integrations/hermes-agent/conftest.py +14 -0
- package/dist/integrations/hermes-agent/plugin.yaml +12 -0
- package/dist/integrations/hermes-agent/schemas.py +100 -0
- package/dist/integrations/hermes-agent/test_plugin.py +1100 -0
- package/dist/integrations/hermes-agent/tools.py +317 -0
- package/dist/integrations/openclaw/README.md +134 -0
- package/dist/skills/api-design/SKILL.md +37 -0
- package/dist/skills/architect-tools/SKILL.md +37 -0
- package/dist/skills/architecture-patterns/SKILL.md +37 -0
- package/dist/skills/auto-format/SKILL.md +37 -0
- package/dist/skills/backend-engineer/SKILL.md +49 -0
- package/dist/skills/boot-orchestrator/SKILL.md +37 -0
- package/dist/skills/bug-triage/SKILL.md +43 -0
- package/dist/skills/code-analyzer/SKILL.md +45 -0
- package/dist/skills/code-review/SKILL.md +52 -0
- package/dist/skills/content-creator/SKILL.md +38 -0
- package/dist/skills/database-engineer/SKILL.md +46 -0
- package/dist/skills/devops-engineer/SKILL.md +49 -0
- package/dist/skills/enforcer/SKILL.md +37 -0
- package/dist/skills/framework-compliance-audit/SKILL.md +37 -0
- package/dist/skills/frontend-engineer/SKILL.md +49 -0
- package/dist/skills/frontend-ui-ux-engineer/SKILL.md +41 -0
- package/dist/skills/git-workflow/SKILL.md +37 -0
- package/dist/skills/growth-strategist/SKILL.md +48 -0
- package/dist/skills/hermes-agent/SKILL.md +212 -0
- package/dist/skills/inference-improve/SKILL.md +97 -0
- package/dist/skills/lint/SKILL.md +37 -0
- package/dist/skills/log-monitor/SKILL.md +44 -0
- package/dist/skills/mobile-developer/SKILL.md +42 -0
- package/dist/skills/model-health-check/SKILL.md +37 -0
- package/dist/skills/multimodal-looker/SKILL.md +45 -0
- package/dist/skills/orchestrator/SKILL.md +37 -0
- package/dist/skills/performance-analysis/SKILL.md +37 -0
- package/dist/skills/performance-engineer/SKILL.md +41 -0
- package/dist/skills/performance-optimization/SKILL.md +37 -0
- package/dist/skills/processor-pipeline/SKILL.md +37 -0
- package/dist/skills/project-analysis/SKILL.md +42 -0
- package/dist/skills/refactoring-strategies/SKILL.md +37 -0
- package/dist/skills/registry.json +66 -0
- package/dist/skills/researcher/SKILL.md +37 -0
- package/dist/skills/security-audit/SKILL.md +47 -0
- package/dist/skills/security-scan/SKILL.md +37 -0
- package/dist/skills/seo-consultant/SKILL.md +43 -0
- package/dist/skills/session-management/SKILL.md +36 -0
- package/dist/skills/state-manager/SKILL.md +37 -0
- package/dist/skills/storyteller/SKILL.md +130 -0
- package/dist/skills/strategist/SKILL.md +32 -0
- package/dist/skills/tech-writer/SKILL.md +37 -0
- package/dist/skills/testing-best-practices/SKILL.md +37 -0
- package/dist/skills/testing-strategy/SKILL.md +43 -0
- package/dist/skills/ui-ux-design/SKILL.md +603 -0
- package/package.json +2 -2
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"""StringRay Hermes Plugin — full framework pipeline integration.
|
|
2
|
+
|
|
3
|
+
Mirrors the OpenCode strray-codex-injection.ts behavior:
|
|
4
|
+
1. Captures ALL tool calls and logs to disk
|
|
5
|
+
2. Runs quality gates on code-producing tools
|
|
6
|
+
3. Runs pre/post processors via Node.js bridge
|
|
7
|
+
4. Persists activity to activity.log + plugin-tool-events.log
|
|
8
|
+
5. Tracks session statistics
|
|
9
|
+
|
|
10
|
+
Bridge protocol: JSON over stdin/stdout to bridge.mjs (Node.js).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from . import schemas, tools
|
|
23
|
+
except ImportError:
|
|
24
|
+
# Standalone import (e.g., pytest discovery) — modules loaded separately
|
|
25
|
+
import importlib
|
|
26
|
+
import types
|
|
27
|
+
_pkg_dir = Path(__file__).resolve().parent
|
|
28
|
+
sys.path.insert(0, str(_pkg_dir))
|
|
29
|
+
schemas = importlib.import_module("schemas")
|
|
30
|
+
tools = importlib.import_module("tools")
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("strray-hermes")
|
|
33
|
+
|
|
34
|
+
# ── Paths ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
PLUGIN_DIR = Path(__file__).resolve().parent
|
|
37
|
+
BRIDGE_PATH = PLUGIN_DIR / "bridge.mjs"
|
|
38
|
+
|
|
39
|
+
# Project root: find the StringRay project directory
|
|
40
|
+
# The plugin lives at ~/.hermes/plugins/ which is NOT inside any project tree,
|
|
41
|
+
# so walking up from PLUGIN_DIR will never find the project. Instead:
|
|
42
|
+
# 1. Check STRRAY_PROJECT_ROOT env var (explicit override)
|
|
43
|
+
# 2. Walk up from cwd looking for node_modules/strray-ai (consumer install)
|
|
44
|
+
# 3. Walk up from cwd looking for .opencode/strray/features.json (dev repo)
|
|
45
|
+
# 4. Walk up from cwd looking for package.json (skip home dir)
|
|
46
|
+
def _find_project_root():
|
|
47
|
+
env_root = os.environ.get("STRRAY_PROJECT_ROOT") or os.environ.get("HERMES_PROJECT_ROOT")
|
|
48
|
+
if env_root:
|
|
49
|
+
p = Path(env_root).resolve()
|
|
50
|
+
if p.is_dir():
|
|
51
|
+
return p
|
|
52
|
+
|
|
53
|
+
cwd = Path.cwd()
|
|
54
|
+
home = Path.home()
|
|
55
|
+
|
|
56
|
+
# Walk up from cwd
|
|
57
|
+
d = cwd
|
|
58
|
+
for _ in range(20):
|
|
59
|
+
# node_modules/strray-ai — consumer install marker
|
|
60
|
+
if (d / "node_modules" / "strray-ai" / "package.json").exists():
|
|
61
|
+
return d
|
|
62
|
+
# .opencode/strray — dev repo marker
|
|
63
|
+
if (d / ".opencode" / "strray" / "features.json").exists():
|
|
64
|
+
return d
|
|
65
|
+
# package.json but not home dir
|
|
66
|
+
if d != home and (d / "package.json").exists():
|
|
67
|
+
return d
|
|
68
|
+
d = d.parent
|
|
69
|
+
if d == d.parent:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
return cwd
|
|
73
|
+
|
|
74
|
+
PROJECT_ROOT = _find_project_root()
|
|
75
|
+
LOG_DIR = PROJECT_ROOT / "logs" / "framework"
|
|
76
|
+
|
|
77
|
+
# ── Constants ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
# Tools that produce/modify code — these get the full pipeline
|
|
80
|
+
_CODE_TOOLS = {"write_file", "patch", "execute_code", "write", "edit"}
|
|
81
|
+
|
|
82
|
+
# Map tool names to agent/skill for outcome tracking
|
|
83
|
+
_TOOL_AGENT_MAP = {
|
|
84
|
+
"write_file": ("code-reviewer", "write"),
|
|
85
|
+
"patch": ("code-reviewer", "patch"),
|
|
86
|
+
"execute_code": ("testing-lead", "execution"),
|
|
87
|
+
"write": ("code-reviewer", "write"),
|
|
88
|
+
"edit": ("code-reviewer", "edit"),
|
|
89
|
+
"terminal": ("testing-lead", "execution"),
|
|
90
|
+
"search_files": ("researcher", "search"),
|
|
91
|
+
"read_file": ("researcher", "read"),
|
|
92
|
+
"browser_*": ("researcher", "browser"),
|
|
93
|
+
"delegate_task": ("orchestrator", "delegation"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Tools where StringRay has a better alternative
|
|
97
|
+
# terminal: only nudge when the command looks lint/security/search related
|
|
98
|
+
_BETTER_WITH_STRRAY = {
|
|
99
|
+
"search_files": "Use mcp_strray_researcher_search_codebase for code pattern searches",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Patterns that suggest the terminal command should use an MCP tool instead
|
|
103
|
+
_TERMINAL_NUDGE_PATTERNS = {
|
|
104
|
+
"grep": "Use mcp_strray_researcher_search_codebase instead of grep",
|
|
105
|
+
"rg ": "Use mcp_strray_researcher_search_codebase instead of ripgrep",
|
|
106
|
+
"eslint": "Use mcp_strray_lint_lint instead of raw eslint",
|
|
107
|
+
"npx eslint": "Use mcp_strray_lint_lint instead of raw eslint",
|
|
108
|
+
"npm audit": "Use mcp_strray_security_scan_security_scan instead of npm audit",
|
|
109
|
+
"yarn audit": "Use mcp_strray_security_scan_security_scan instead of yarn audit",
|
|
110
|
+
"find ": "Use search_files(target='files') instead of find",
|
|
111
|
+
"sed ": "Use patch tool instead of sed",
|
|
112
|
+
"awk ": "Use patch tool instead of awk",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# ── Session stats ─────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
_INFERENCE_TUNE_INTERVAL = 100
|
|
118
|
+
_last_tune_tool_call_count = 0
|
|
119
|
+
|
|
120
|
+
_session_stats = {
|
|
121
|
+
"started_at": None,
|
|
122
|
+
"session_id": None,
|
|
123
|
+
"code_operations": 0,
|
|
124
|
+
"total_tool_calls": 0,
|
|
125
|
+
"strray_mcp_calls": 0,
|
|
126
|
+
"native_tool_calls": 0,
|
|
127
|
+
"quality_gate_runs": 0,
|
|
128
|
+
"quality_gate_blocks": 0,
|
|
129
|
+
"pre_processor_runs": 0,
|
|
130
|
+
"post_processor_runs": 0,
|
|
131
|
+
"bridge_calls": 0,
|
|
132
|
+
"bridge_errors": 0,
|
|
133
|
+
"subagent_dispatches": 0,
|
|
134
|
+
"subagent_validations": 0,
|
|
135
|
+
"subagent_blocks": 0,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# ── File logging ──────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _ensure_log_dir():
|
|
141
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _log_to_file(filename, message):
|
|
145
|
+
"""Append a timestamped line to a log file in logs/framework/."""
|
|
146
|
+
try:
|
|
147
|
+
_ensure_log_dir()
|
|
148
|
+
log_path = LOG_DIR / filename
|
|
149
|
+
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
150
|
+
entry = f"{timestamp} {message}\n"
|
|
151
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
152
|
+
f.write(entry)
|
|
153
|
+
except OSError:
|
|
154
|
+
pass # never break the agent over logging
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _log_tool_event(event_type, tool, args=None, duration=0, error=None):
|
|
158
|
+
"""Log tool events in the same format as the OpenCode plugin."""
|
|
159
|
+
import random
|
|
160
|
+
job_id = f"plugin-{int(datetime.now(timezone.utc).timestamp() * 1000)}-{random.randint(100000, 999999)}"
|
|
161
|
+
if event_type == "start":
|
|
162
|
+
args_keys = list((args or {}).keys())
|
|
163
|
+
msg = f"[{job_id}] [agent] tool-started - INFO | {{\"tool\":\"{tool}\",\"args\":{json.dumps(args_keys)}}}"
|
|
164
|
+
else:
|
|
165
|
+
level = "ERROR" if error else "SUCCESS"
|
|
166
|
+
err_part = f",\"error\":\"{error}\"" if error else ""
|
|
167
|
+
msg = f"[{job_id}] [agent] tool-complete - {level} | {{\"tool\":\"{tool}\",\"duration\":{duration}{err_part}}}"
|
|
168
|
+
_log_to_file("plugin-tool-events.log", msg)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Bridge calls ──────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def _call_bridge(command: dict, timeout: int = 10) -> dict:
|
|
174
|
+
"""Call bridge.mjs with a JSON command, return parsed response."""
|
|
175
|
+
_session_stats["bridge_calls"] += 1
|
|
176
|
+
try:
|
|
177
|
+
result = subprocess.run(
|
|
178
|
+
["node", str(BRIDGE_PATH), "--cwd", str(PROJECT_ROOT)],
|
|
179
|
+
input=json.dumps(command),
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
timeout=timeout,
|
|
183
|
+
)
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
_session_stats["bridge_errors"] += 1
|
|
186
|
+
return {"error": result.stderr[:300] if result.stderr else "bridge failed"}
|
|
187
|
+
return json.loads(result.stdout)
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
_session_stats["bridge_errors"] += 1
|
|
190
|
+
return {"error": "node not found"}
|
|
191
|
+
except subprocess.TimeoutExpired:
|
|
192
|
+
_session_stats["bridge_errors"] += 1
|
|
193
|
+
return {"error": f"bridge timed out after {timeout}s"}
|
|
194
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
195
|
+
_session_stats["bridge_errors"] += 1
|
|
196
|
+
return {"error": str(e)}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Hook: pre_tool_call ───────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def _is_strray_mcp(tool_name: str) -> bool:
|
|
202
|
+
return tool_name.startswith("mcp_strray_")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _on_pre_tool_call(tool_name: str, args: dict, task_id: str, **kwargs):
|
|
206
|
+
"""Fires before ANY tool executes.
|
|
207
|
+
|
|
208
|
+
Pipeline:
|
|
209
|
+
1. Track stats
|
|
210
|
+
2. Log tool-start event to disk
|
|
211
|
+
3. For code-producing tools: run quality gate + pre-processors via bridge
|
|
212
|
+
4. For non-code tools: nudge if StringRay alternative exists
|
|
213
|
+
"""
|
|
214
|
+
_session_stats["total_tool_calls"] += 1
|
|
215
|
+
|
|
216
|
+
# Log start event
|
|
217
|
+
_log_tool_event("start", tool_name, args)
|
|
218
|
+
|
|
219
|
+
# StringRay MCP tools — track but don't interfere
|
|
220
|
+
if _is_strray_mcp(tool_name):
|
|
221
|
+
_session_stats["strray_mcp_calls"] += 1
|
|
222
|
+
_log_to_file("activity.log", f"[quality-gate] SKIP (strray-mcp): {tool_name}")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# delegate_task: snapshot working tree so post_hook can validate changes
|
|
226
|
+
if tool_name == "delegate_task":
|
|
227
|
+
tid = kwargs.get("task_id", "") or args.get("task_id", "") or task_id
|
|
228
|
+
if tid:
|
|
229
|
+
_delegate_snapshots[tid] = _snapshot_working_tree()
|
|
230
|
+
_session_stats["subagent_dispatches"] += 1
|
|
231
|
+
_log_to_file("activity.log",
|
|
232
|
+
f"[pre-tool] SUBAGENT DISPATCH: task_id={tid}")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
_session_stats["native_tool_calls"] += 1
|
|
236
|
+
|
|
237
|
+
# Code-producing tools get the full pipeline
|
|
238
|
+
if tool_name in _CODE_TOOLS:
|
|
239
|
+
_session_stats["code_operations"] += 1
|
|
240
|
+
|
|
241
|
+
# Extract file path for logging
|
|
242
|
+
file_path = None
|
|
243
|
+
if isinstance(args, dict):
|
|
244
|
+
file_path = args.get("path") or args.get("filePath")
|
|
245
|
+
|
|
246
|
+
_log_to_file("activity.log",
|
|
247
|
+
f"[pre-tool] CODE OPERATION: tool={tool_name} file={file_path}")
|
|
248
|
+
|
|
249
|
+
# Run quality gate via bridge
|
|
250
|
+
_session_stats["quality_gate_runs"] += 1
|
|
251
|
+
bridge_result = _call_bridge({
|
|
252
|
+
"command": "pre-process",
|
|
253
|
+
"tool": tool_name,
|
|
254
|
+
"args": args or {},
|
|
255
|
+
}, timeout=15)
|
|
256
|
+
|
|
257
|
+
if "error" not in bridge_result:
|
|
258
|
+
quality = bridge_result.get("qualityGate", {})
|
|
259
|
+
processors = bridge_result.get("processors", {})
|
|
260
|
+
|
|
261
|
+
# Log quality gate results
|
|
262
|
+
if quality.get("passed") is False:
|
|
263
|
+
violations = quality.get("violations", [])
|
|
264
|
+
_session_stats["quality_gate_blocks"] += 1
|
|
265
|
+
violation_msg = "; ".join(violations)
|
|
266
|
+
_log_to_file("activity.log",
|
|
267
|
+
f"[quality-gate] BLOCKED: tool={tool_name} violations={violation_msg}")
|
|
268
|
+
logger.warning(
|
|
269
|
+
"[strray] Quality gate BLOCKED %s: %s",
|
|
270
|
+
tool_name, violation_msg,
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
_log_to_file("activity.log",
|
|
274
|
+
f"[quality-gate] PASSED: tool={tool_name}")
|
|
275
|
+
|
|
276
|
+
# Log processor results
|
|
277
|
+
if processors.get("ran"):
|
|
278
|
+
_session_stats["pre_processor_runs"] += 1
|
|
279
|
+
success = processors.get("success", True)
|
|
280
|
+
count = processors.get("processorCount", 0)
|
|
281
|
+
_log_to_file("activity.log",
|
|
282
|
+
f"[pre-processors] {'SUCCESS' if success else 'FAILED'}: "
|
|
283
|
+
f"{count} processors for {tool_name}")
|
|
284
|
+
if processors.get("details"):
|
|
285
|
+
for detail in processors["details"]:
|
|
286
|
+
status = "OK" if detail.get("success") else f"FAILED: {detail.get('error')}"
|
|
287
|
+
_log_to_file("activity.log",
|
|
288
|
+
f"[pre-processor] {detail.get('name', 'unknown')}: {status}")
|
|
289
|
+
else:
|
|
290
|
+
_log_to_file("activity.log",
|
|
291
|
+
f"[bridge] ERROR in pre-process: {bridge_result.get('error', 'unknown')}")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# Non-code tools: nudge for StringRay alternatives
|
|
295
|
+
if tool_name in _BETTER_WITH_STRRAY:
|
|
296
|
+
tip = _BETTER_WITH_STRRAY[tool_name]
|
|
297
|
+
logger.info("[strray] Tip: %s — %s", tool_name, tip)
|
|
298
|
+
_log_to_file("activity.log",
|
|
299
|
+
f"[nudge] {tool_name}: {tip}")
|
|
300
|
+
|
|
301
|
+
# Terminal: smart nudge based on command content
|
|
302
|
+
if tool_name == "terminal" and isinstance(args, dict):
|
|
303
|
+
cmd = args.get("command", "")
|
|
304
|
+
if isinstance(cmd, str):
|
|
305
|
+
for pattern, tip in _TERMINAL_NUDGE_PATTERNS.items():
|
|
306
|
+
if pattern in cmd:
|
|
307
|
+
logger.info("[strray] Tip: %s — %s", tool_name, tip)
|
|
308
|
+
_log_to_file("activity.log",
|
|
309
|
+
f"[nudge] {tool_name}: {tip}")
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── Hook: post_tool_call ──────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def _on_post_tool_call(tool_name: str, args: dict, result, task_id: str, **kwargs):
|
|
316
|
+
"""Fires after ANY tool returns.
|
|
317
|
+
|
|
318
|
+
Pipeline:
|
|
319
|
+
1. Log tool-complete event to disk
|
|
320
|
+
2. Extract file info from write/patch operations
|
|
321
|
+
3. For code-producing tools: run post-processors via bridge
|
|
322
|
+
4. Track file modifications for session context
|
|
323
|
+
"""
|
|
324
|
+
duration = 0
|
|
325
|
+
|
|
326
|
+
# Extract file path — BUG FIX: only when path key exists with truthy value
|
|
327
|
+
file_path = None
|
|
328
|
+
if isinstance(args, dict) and tool_name in ("write_file", "patch"):
|
|
329
|
+
file_path = args.get("path")
|
|
330
|
+
if not file_path:
|
|
331
|
+
# No path key or empty string — skip file logging
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
# Extract duration from result if available
|
|
335
|
+
if isinstance(result, dict):
|
|
336
|
+
duration = result.get("duration", 0)
|
|
337
|
+
|
|
338
|
+
# Log completion event
|
|
339
|
+
error = None
|
|
340
|
+
if isinstance(result, dict) and result.get("error"):
|
|
341
|
+
error = result["error"]
|
|
342
|
+
_log_tool_event("complete", tool_name, args, duration, error)
|
|
343
|
+
|
|
344
|
+
# Record outcome for the inference feedback loop
|
|
345
|
+
_record_tool_outcome(tool_name, args or {}, error is None)
|
|
346
|
+
|
|
347
|
+
# delegate_task: validate all files the subagent changed
|
|
348
|
+
if tool_name == "delegate_task":
|
|
349
|
+
tid = kwargs.get("task_id", "") or args.get("task_id", "") or task_id
|
|
350
|
+
if tid:
|
|
351
|
+
_validate_subagent_changes(tid)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
# Track file modifications
|
|
355
|
+
if file_path:
|
|
356
|
+
_log_to_file("activity.log",
|
|
357
|
+
f"[post-tool] file-written: tool={tool_name} path={file_path}")
|
|
358
|
+
|
|
359
|
+
# Code-producing tools get post-processors
|
|
360
|
+
if tool_name in _CODE_TOOLS:
|
|
361
|
+
# Run post-processors via bridge
|
|
362
|
+
bridge_result = _call_bridge({
|
|
363
|
+
"command": "post-process",
|
|
364
|
+
"tool": tool_name,
|
|
365
|
+
"args": args or {},
|
|
366
|
+
"result": result,
|
|
367
|
+
"error": error,
|
|
368
|
+
}, timeout=15)
|
|
369
|
+
|
|
370
|
+
if "error" not in bridge_result:
|
|
371
|
+
processors = bridge_result.get("processors", {})
|
|
372
|
+
if processors.get("ran"):
|
|
373
|
+
_session_stats["post_processor_runs"] += 1
|
|
374
|
+
success = processors.get("success", True)
|
|
375
|
+
count = processors.get("processorCount", 0)
|
|
376
|
+
_log_to_file("activity.log",
|
|
377
|
+
f"[post-processors] {'SUCCESS' if success else 'FAILED'}: "
|
|
378
|
+
f"{count} processors for {tool_name}")
|
|
379
|
+
if processors.get("details"):
|
|
380
|
+
for detail in processors["details"]:
|
|
381
|
+
status = "OK" if detail.get("success") else f"FAILED: {detail.get('error')}"
|
|
382
|
+
_log_to_file("activity.log",
|
|
383
|
+
f"[post-processor] {detail.get('name', 'unknown')}: {status}")
|
|
384
|
+
else:
|
|
385
|
+
_log_to_file("activity.log",
|
|
386
|
+
f"[bridge] ERROR in post-process: {bridge_result.get('error', 'unknown')}")
|
|
387
|
+
|
|
388
|
+
# Auto inference tuning: every _INFERENCE_TUNE_INTERVAL tool calls,
|
|
389
|
+
# shell out to the inference tuner to close the feedback loop.
|
|
390
|
+
global _last_tune_tool_call_count
|
|
391
|
+
calls = _session_stats["total_tool_calls"]
|
|
392
|
+
if calls - _last_tune_tool_call_count >= _INFERENCE_TUNE_INTERVAL:
|
|
393
|
+
_last_tune_tool_call_count = calls
|
|
394
|
+
logger.info(
|
|
395
|
+
"[strray] Triggering inference tuning cycle (tool call #%d)", calls
|
|
396
|
+
)
|
|
397
|
+
_log_to_file("activity.log",
|
|
398
|
+
f"[inference-tune] auto-cycle at tool call #{calls}")
|
|
399
|
+
try:
|
|
400
|
+
_run_inference_tune()
|
|
401
|
+
except Exception as e:
|
|
402
|
+
logger.warning("[strray] Inference tuning failed: %s", e)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ── Hook: session_start ───────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
def _on_session_start(session_id: str, platform: str, **kwargs):
|
|
408
|
+
"""Fires when a new session starts. Resets stats, logs to disk."""
|
|
409
|
+
_session_stats["started_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
410
|
+
_session_stats["session_id"] = session_id
|
|
411
|
+
for key in ("code_operations", "total_tool_calls", "strray_mcp_calls",
|
|
412
|
+
"native_tool_calls", "quality_gate_runs", "quality_gate_blocks",
|
|
413
|
+
"pre_processor_runs", "post_processor_runs",
|
|
414
|
+
"bridge_calls", "bridge_errors",
|
|
415
|
+
"subagent_dispatches", "subagent_validations", "subagent_blocks"):
|
|
416
|
+
_session_stats[key] = 0
|
|
417
|
+
global _last_tune_tool_call_count
|
|
418
|
+
_last_tune_tool_call_count = 0
|
|
419
|
+
|
|
420
|
+
_ensure_log_dir()
|
|
421
|
+
_log_to_file("activity.log",
|
|
422
|
+
f"[session-start] session={session_id} platform={platform}")
|
|
423
|
+
logger.info("[strray] Session %s started on %s", session_id, platform)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ── Slash command ─────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
def _strray_command(args: str) -> str:
|
|
429
|
+
"""Slash command handler: /strray [status|stats|help]"""
|
|
430
|
+
cmd = (args or "status").strip().lower()
|
|
431
|
+
|
|
432
|
+
if cmd == "stats":
|
|
433
|
+
return (
|
|
434
|
+
f"StringRay Session Stats\n"
|
|
435
|
+
f" Session: {_session_stats['session_id'] or 'N/A'}\n"
|
|
436
|
+
f" Started: {_session_stats['started_at'] or 'N/A'}\n"
|
|
437
|
+
f" Tool calls: {_session_stats['total_tool_calls']}\n"
|
|
438
|
+
f" Code operations: {_session_stats['code_operations']}\n"
|
|
439
|
+
f" StringRay MCP: {_session_stats['strray_mcp_calls']}\n"
|
|
440
|
+
f" Native tools: {_session_stats['native_tool_calls']}\n"
|
|
441
|
+
f" Quality gate runs: {_session_stats['quality_gate_runs']}\n"
|
|
442
|
+
f" Quality gate blocks: {_session_stats['quality_gate_blocks']}\n"
|
|
443
|
+
f" Pre-processor runs: {_session_stats['pre_processor_runs']}\n"
|
|
444
|
+
f" Post-processor runs: {_session_stats['post_processor_runs']}\n"
|
|
445
|
+
f" Bridge calls: {_session_stats['bridge_calls']}\n"
|
|
446
|
+
f" Bridge errors: {_session_stats['bridge_errors']}\n"
|
|
447
|
+
f" Subagent dispatches: {_session_stats['subagent_dispatches']}\n"
|
|
448
|
+
f" Subagent validations: {_session_stats['subagent_validations']}\n"
|
|
449
|
+
f" Subagent blocks: {_session_stats['subagent_blocks']}"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if cmd == "help":
|
|
453
|
+
return (
|
|
454
|
+
"StringRay Commands:\n"
|
|
455
|
+
" /strray status — Plugin and framework health\n"
|
|
456
|
+
" /strray stats — Session pipeline statistics\n"
|
|
457
|
+
" /strray help — This message"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Default: status (calls bridge health)
|
|
461
|
+
bridge_result = _call_bridge({"command": "health"}, timeout=10)
|
|
462
|
+
if "error" in bridge_result:
|
|
463
|
+
return f"StringRay plugin loaded. Bridge: {bridge_result['error']}"
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
f"StringRay Hermes Plugin Status\n"
|
|
467
|
+
f" Framework: {bridge_result.get('framework', 'unknown')}\n"
|
|
468
|
+
f" Version: {bridge_result.get('version', 'unknown')}\n"
|
|
469
|
+
f" Quality Gate: {'ready' if bridge_result.get('components', {}).get('qualityGate') else 'not loaded'}\n"
|
|
470
|
+
f" Processors: {'ready' if bridge_result.get('components', {}).get('processorManager') else 'not loaded'}\n"
|
|
471
|
+
f" Project: {bridge_result.get('projectRoot', 'unknown')}\n"
|
|
472
|
+
f" Bridge calls: {_session_stats['bridge_calls']} (errors: {_session_stats['bridge_errors']})"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ── Outcome tracking (feeds inference tuner) ──────────────────
|
|
477
|
+
|
|
478
|
+
_OUTCOMES_PATH = PROJECT_ROOT / "logs" / "framework" / "routing-outcomes.json"
|
|
479
|
+
_MAX_OUTCOMES = 1000
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _record_tool_outcome(tool_name: str, args: dict, success: bool):
|
|
483
|
+
"""Append a routing outcome to routing-outcomes.json.
|
|
484
|
+
|
|
485
|
+
Writes directly to the JSON file (same format the TS outcome tracker uses)
|
|
486
|
+
so both OpenCode and Hermes plugin outcomes are visible to the tuner.
|
|
487
|
+
"""
|
|
488
|
+
call_num = _session_stats.get("total_tool_calls", 0)
|
|
489
|
+
|
|
490
|
+
# Look up agent/skill mapping
|
|
491
|
+
agent, skill = "direct", tool_name
|
|
492
|
+
for pattern, mapped in _TOOL_AGENT_MAP.items():
|
|
493
|
+
if pattern.endswith("*"):
|
|
494
|
+
if tool_name.startswith(pattern[:-1]):
|
|
495
|
+
agent, skill = mapped
|
|
496
|
+
break
|
|
497
|
+
elif tool_name == pattern:
|
|
498
|
+
agent, skill = mapped
|
|
499
|
+
break
|
|
500
|
+
|
|
501
|
+
# Build description from args
|
|
502
|
+
if isinstance(args, dict):
|
|
503
|
+
content = args.get("content") or args.get("path") or args.get("filePath") or ""
|
|
504
|
+
description = str(content)[:200] if content else f"tool call: {tool_name}"
|
|
505
|
+
else:
|
|
506
|
+
description = f"tool call: {tool_name}"
|
|
507
|
+
|
|
508
|
+
outcome = {
|
|
509
|
+
"taskId": f"hermes-{call_num}",
|
|
510
|
+
"taskDescription": description,
|
|
511
|
+
"routedAgent": agent,
|
|
512
|
+
"routedSkill": skill,
|
|
513
|
+
"confidence": 0.8 if agent != "direct" else 0.5,
|
|
514
|
+
"success": success,
|
|
515
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
516
|
+
"routingMethod": "keyword" if agent != "direct" else "default",
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
_OUTCOMES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
521
|
+
if _OUTCOMES_PATH.exists():
|
|
522
|
+
with open(_OUTCOMES_PATH, "r") as f:
|
|
523
|
+
outcomes = json.load(f)
|
|
524
|
+
else:
|
|
525
|
+
outcomes = []
|
|
526
|
+
|
|
527
|
+
outcomes.append(outcome)
|
|
528
|
+
# Circular buffer — keep last N outcomes
|
|
529
|
+
if len(outcomes) > _MAX_OUTCOMES:
|
|
530
|
+
outcomes = outcomes[-_MAX_OUTCOMES:]
|
|
531
|
+
|
|
532
|
+
with open(_OUTCOMES_PATH, "w") as f:
|
|
533
|
+
json.dump(outcomes, f, indent=2)
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.debug("[strray] outcome recording failed: %s", e)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ── Inference tuning (auto-calibration) ────────────────────────
|
|
539
|
+
|
|
540
|
+
def _run_inference_tune():
|
|
541
|
+
"""Shell out to strray-ai inference:tuner --run-once.
|
|
542
|
+
|
|
543
|
+
Runs in a background thread so it doesn't block the tool call pipeline.
|
|
544
|
+
The tuner reads routing outcomes, runs the analytics pipeline, and
|
|
545
|
+
writes back refined keyword mappings to routing-mappings.json.
|
|
546
|
+
"""
|
|
547
|
+
import threading
|
|
548
|
+
|
|
549
|
+
def _tune():
|
|
550
|
+
try:
|
|
551
|
+
result = subprocess.run(
|
|
552
|
+
["npx", "strray-ai", "inference:tuner", "--run-once"],
|
|
553
|
+
capture_output=True, text=True, timeout=30,
|
|
554
|
+
cwd=os.getcwd(),
|
|
555
|
+
)
|
|
556
|
+
if result.returncode == 0:
|
|
557
|
+
logger.info("[strray] Inference tuning cycle completed")
|
|
558
|
+
_log_to_file("activity.log",
|
|
559
|
+
"[inference-tune] cycle completed successfully")
|
|
560
|
+
else:
|
|
561
|
+
_log_to_file("activity.log",
|
|
562
|
+
f"[inference-tune] cycle failed (rc={result.returncode}): "
|
|
563
|
+
f"{result.stderr.strip()[:200]}")
|
|
564
|
+
except subprocess.TimeoutExpired:
|
|
565
|
+
_log_to_file("activity.log",
|
|
566
|
+
"[inference-tune] cycle timed out after 30s")
|
|
567
|
+
except Exception as e:
|
|
568
|
+
_log_to_file("activity.log",
|
|
569
|
+
f"[inference-tune] cycle error: {e}")
|
|
570
|
+
|
|
571
|
+
threading.Thread(target=_tune, daemon=True).start()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ── Registration ──────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
# ── Subagent (delegate_task) enforcement ────────────────────
|
|
577
|
+
# Subagents bypass all StringRay hooks because they run in isolated
|
|
578
|
+
# contexts. We enforce by snapshotting the working tree before dispatch
|
|
579
|
+
# and validating all changed files after return.
|
|
580
|
+
|
|
581
|
+
_delegate_snapshots: dict = {} # task_id → set of (path, mtime)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _snapshot_working_tree() -> dict:
|
|
585
|
+
"""Snapshot file mtimes under project root for subagent change detection."""
|
|
586
|
+
try:
|
|
587
|
+
result = subprocess.run(
|
|
588
|
+
["git", "-C", str(PROJECT_ROOT), "diff", "--name-only", "HEAD"],
|
|
589
|
+
capture_output=True, text=True, timeout=5,
|
|
590
|
+
)
|
|
591
|
+
if result.returncode == 0:
|
|
592
|
+
changed = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
|
|
593
|
+
else:
|
|
594
|
+
changed = set()
|
|
595
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
596
|
+
changed = set()
|
|
597
|
+
return {"changed_before": changed}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _validate_subagent_changes(task_id: str, **kwargs):
|
|
601
|
+
"""After delegate_task returns, find what the subagent changed and validate."""
|
|
602
|
+
snapshot = _delegate_snapshots.pop(task_id, None)
|
|
603
|
+
if not snapshot:
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
before = snapshot.get("changed_before", set())
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
result = subprocess.run(
|
|
610
|
+
["git", "-C", str(PROJECT_ROOT), "diff", "--name-only", "HEAD"],
|
|
611
|
+
capture_output=True, text=True, timeout=5,
|
|
612
|
+
)
|
|
613
|
+
if result.returncode != 0:
|
|
614
|
+
return
|
|
615
|
+
after = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
|
|
616
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
new_changes = after - before
|
|
620
|
+
if not new_changes:
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Filter to source files only (skip dist, node_modules, logs, etc.)
|
|
624
|
+
source_files = sorted(f for f in new_changes if any(
|
|
625
|
+
f.startswith(prefix) for prefix in ("src/", "dist/", "scripts/", ".opencode/plugins/")
|
|
626
|
+
) and not any(
|
|
627
|
+
skip in f for skip in ("node_modules/", ".log", "__pycache__", ".map")
|
|
628
|
+
))
|
|
629
|
+
|
|
630
|
+
if not source_files:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
_session_stats["code_operations"] += len(source_files)
|
|
634
|
+
_session_stats["subagent_validations"] += 1
|
|
635
|
+
|
|
636
|
+
# Resolve to absolute paths for validation
|
|
637
|
+
abs_files = [str(PROJECT_ROOT / f) for f in source_files]
|
|
638
|
+
|
|
639
|
+
# Run validation on all changed files via bridge
|
|
640
|
+
bridge_result = _call_bridge({
|
|
641
|
+
"command": "validate",
|
|
642
|
+
"files": abs_files,
|
|
643
|
+
"operation": "modify",
|
|
644
|
+
}, timeout=30)
|
|
645
|
+
|
|
646
|
+
if "error" in bridge_result:
|
|
647
|
+
_log_to_file("activity.log",
|
|
648
|
+
f"[subagent-validate] BRIDGE ERROR: {bridge_result['error']}")
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
results = bridge_result.get("fileResults", bridge_result.get("results", {}))
|
|
652
|
+
for rel_path in source_files:
|
|
653
|
+
file_result = results.get(rel_path, results.get(str(PROJECT_ROOT / rel_path), {}))
|
|
654
|
+
passed = file_result.get("passed", True)
|
|
655
|
+
violations = file_result.get("violations", [])
|
|
656
|
+
|
|
657
|
+
if not passed:
|
|
658
|
+
_session_stats["quality_gate_blocks"] += 1
|
|
659
|
+
_session_stats["subagent_blocks"] += 1
|
|
660
|
+
_log_to_file("activity.log",
|
|
661
|
+
f"[subagent-validate] BLOCKED: {rel_path} "
|
|
662
|
+
f"violations={'; '.join(str(v) for v in violations[:3])}")
|
|
663
|
+
logger.warning(
|
|
664
|
+
"[strray] Subagent BLOCKED %s: %s",
|
|
665
|
+
rel_path, violations[:3],
|
|
666
|
+
)
|
|
667
|
+
else:
|
|
668
|
+
_log_to_file("activity.log",
|
|
669
|
+
f"[subagent-validate] PASSED: {rel_path}")
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def register(ctx):
|
|
673
|
+
"""Wire schemas to handlers and register lifecycle hooks."""
|
|
674
|
+
# ── Register tools ────────────────────────────────────────
|
|
675
|
+
ctx.register_tool(
|
|
676
|
+
name="strray_validate",
|
|
677
|
+
toolset="strray-hermes",
|
|
678
|
+
schema=schemas.STRRAY_VALIDATE,
|
|
679
|
+
handler=tools.strray_validate,
|
|
680
|
+
)
|
|
681
|
+
ctx.register_tool(
|
|
682
|
+
name="strray_codex_check",
|
|
683
|
+
toolset="strray-hermes",
|
|
684
|
+
schema=schemas.STRRAY_CODEX_CHECK,
|
|
685
|
+
handler=tools.strray_codex_check,
|
|
686
|
+
)
|
|
687
|
+
ctx.register_tool(
|
|
688
|
+
name="strray_health",
|
|
689
|
+
toolset="strray-hermes",
|
|
690
|
+
schema=schemas.STRRAY_HEALTH,
|
|
691
|
+
handler=tools.strray_health,
|
|
692
|
+
)
|
|
693
|
+
ctx.register_tool(
|
|
694
|
+
name="strray_hooks",
|
|
695
|
+
toolset="strray-hermes",
|
|
696
|
+
schema=schemas.STRRAY_HOOKS,
|
|
697
|
+
handler=tools.strray_hooks,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# ── Register hooks ────────────────────────────────────────
|
|
701
|
+
ctx.register_hook("pre_tool_call", _on_pre_tool_call)
|
|
702
|
+
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
|
703
|
+
|
|
704
|
+
# Try to register session hooks
|
|
705
|
+
try:
|
|
706
|
+
ctx.register_hook("on_session_start", _on_session_start)
|
|
707
|
+
except (AttributeError, TypeError):
|
|
708
|
+
logger.debug("[strray] on_session_start hook not yet available")
|
|
709
|
+
|
|
710
|
+
# ── Register slash command ────────────────────────────────
|
|
711
|
+
try:
|
|
712
|
+
ctx.register_command(
|
|
713
|
+
name="strray",
|
|
714
|
+
handler=_strray_command,
|
|
715
|
+
description="StringRay status, stats, hooks, and enforcement info",
|
|
716
|
+
args_hint="[status|stats|help]",
|
|
717
|
+
aliases=("sr",),
|
|
718
|
+
)
|
|
719
|
+
except (AttributeError, TypeError):
|
|
720
|
+
logger.debug("[strray] Slash command registration not yet available")
|
|
721
|
+
|
|
722
|
+
# ── Bootstrap ─────────────────────────────────────────────
|
|
723
|
+
_ensure_log_dir()
|
|
724
|
+
_log_to_file("activity.log",
|
|
725
|
+
f"[plugin-loaded] StringRay Hermes Plugin v2.2 — "
|
|
726
|
+
f"4 tools, 2 hooks, subagent enforcement, bridge={BRIDGE_PATH.exists()}")
|
|
727
|
+
|
|
728
|
+
logger.info(
|
|
729
|
+
"[strray] Plugin v2.2 loaded: 4 tools, 2 hooks, "
|
|
730
|
+
"subagent enforcement active, bridge=%s",
|
|
731
|
+
BRIDGE_PATH.exists(),
|
|
732
|
+
)
|