subcortex 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. subcortex-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,115 @@
1
+ """Docker Agent (formerly cagent, >= 1.137).
2
+
3
+ snake_case payloads and responses — Claude's camelCase ``hookSpecificOutput``
4
+ is silently ignored here, so everything goes under ``hook_specific_output``.
5
+
6
+ - ``user_prompt_submit`` plus the steering / follow-up events (messages sent
7
+ while the agent is busy) → ``additional_context``.
8
+ - ``tool_response_transform`` on ``shell`` → ``updated_tool_response`` replaces
9
+ what the model sees *and* what is persisted (never an empty string: that
10
+ would erase the output). Synthesized errors (``tool_error``) are left alone.
11
+ - ``before_compaction`` gets no transcript; recent messages are read
12
+ read-only from the session database. ``after_compaction`` marks the
13
+ snapshot ready and it is delivered with the next prompt. Never returns
14
+ ``summary``/``decision``/``continue`` (they would replace the summary or block).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sqlite3
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from ..transcript import message_from_entry
26
+ from .base import POST_COMPACT, PRE_COMPACT, PROMPT, TOOL_OUTPUT, HookAdapter, HookEvent, Response
27
+
28
+ SNAPSHOT_ROWS = 40
29
+
30
+
31
+ def session_db() -> Path:
32
+ return Path.home() / ".cagent" / "session.db"
33
+
34
+
35
+ def recent_messages(session_id: str, limit: int = SNAPSHOT_ROWS) -> List[Dict[str, str]]:
36
+ """Latest user/assistant messages of a session, read-only; [] on any problem."""
37
+ path = session_db()
38
+ if not session_id or not path.is_file():
39
+ return []
40
+ try:
41
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=2.0)
42
+ except sqlite3.Error:
43
+ return []
44
+ try:
45
+ rows = conn.execute(
46
+ "SELECT message_json FROM session_items WHERE session_id = ? AND item_type = 'message' "
47
+ "ORDER BY position DESC LIMIT ?", (session_id, limit)).fetchall()
48
+ except sqlite3.Error:
49
+ return []
50
+ finally:
51
+ conn.close()
52
+ messages = []
53
+ for (raw,) in reversed(rows):
54
+ try:
55
+ msg = message_from_entry(json.loads(raw))
56
+ except (TypeError, ValueError):
57
+ continue
58
+ if msg:
59
+ messages.append(msg)
60
+ return messages
61
+
62
+
63
+ class DockerAgentAdapter(HookAdapter):
64
+ name = "docker-agent"
65
+ display_name = "Docker Agent"
66
+ events = {
67
+ "user_prompt_submit": PROMPT,
68
+ "user_steering_messages_submit": PROMPT,
69
+ "user_followup_submit": PROMPT,
70
+ "tool_response_transform": TOOL_OUTPUT,
71
+ "before_compaction": PRE_COMPACT,
72
+ "after_compaction": POST_COMPACT,
73
+ }
74
+ restore_on_prompt = True
75
+
76
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
77
+ event = HookEvent(kind=kind, name=name, payload=payload,
78
+ session_id=str(payload.get("session_id") or ""),
79
+ trigger=str(payload.get("compaction_reason") or ""))
80
+ if kind == PROMPT:
81
+ steering = payload.get("steering_messages")
82
+ if isinstance(steering, list):
83
+ event.prompt = "\n".join(m for m in steering if isinstance(m, str))
84
+ elif isinstance(payload.get("prompt"), str):
85
+ event.prompt = payload["prompt"]
86
+ elif kind == TOOL_OUTPUT:
87
+ response = payload.get("tool_response")
88
+ event.output = response if isinstance(response, str) else None
89
+ event.failed = bool(payload.get("tool_error"))
90
+ event.tool = str(payload.get("tool_name") or "shell")
91
+ event.tool_input = payload.get("tool_input")
92
+ elif kind == PRE_COMPACT:
93
+ event.messages = recent_messages(event.session_id)
94
+ return event
95
+
96
+ def render_prompt(self, event: HookEvent, text: str) -> Response:
97
+ return {"hook_specific_output": {"additional_context": text}}
98
+
99
+ def render_tool_output(self, event: HookEvent, replacement: Optional[str]) -> Response:
100
+ if not replacement:
101
+ return None # an empty string would erase the output
102
+ return {"hook_specific_output": {"updated_tool_response": replacement}}
103
+
104
+ def guard(self, kind: str, response: Response) -> Response:
105
+ if isinstance(response, dict) and set(response) - {"hook_specific_output"}:
106
+ return None
107
+ return super().guard(kind, response)
108
+
109
+
110
+ def config_dir() -> Path:
111
+ for var in ("DOCKER_AGENT_CONFIG_DIR", "CAGENT_CONFIG_DIR"):
112
+ value = os.environ.get(var, "").strip()
113
+ if value:
114
+ return Path(value)
115
+ return Path.home() / ".config" / "cagent"
@@ -0,0 +1,60 @@
1
+ """Gemini CLI and its fork Qwen Code.
2
+
3
+ Both use Claude-like ``hookSpecificOutput`` envelopes but their own event
4
+ names and semantics:
5
+
6
+ Gemini CLI (>= 0.27): ``BeforeAgent`` injects context; ``AfterTool`` can only
7
+ append or *block* (no replacement — Gemini truncates at 40k chars itself), so
8
+ it isn't registered; ``PreCompress`` fires before every model request (the
9
+ installer matches only ``manual`` triggers, i.e. a real ``/compress``) and there
10
+ is no post-compaction SessionStart, so the snapshot is delivered with the next
11
+ prompt. Any exit status >= 2 with text output BLOCKS in Gemini — the runner
12
+ and the installer's shell guard make that impossible.
13
+
14
+ Qwen Code (>= 0.16): ``UserPromptSubmit`` also fires on tool-result
15
+ continuations, so only prompts carrying ``submitted_prompt`` are classified;
16
+ ``PostToolUse`` is append-only (not registered); ``PreCompact`` fires only when
17
+ compaction will run and ``SessionStart(compact)`` puts context into the system
18
+ instruction.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Any, Dict, Optional
25
+
26
+ from .base import PRE_COMPACT, PROMPT, SESSION_START
27
+ from .claude_family import ClaudeStyleAdapter
28
+ from .base import HookEvent
29
+
30
+ _HOOK_CONTEXT_RE = re.compile(r"<hook_context>.*?</hook_context>\s*", re.S)
31
+
32
+
33
+ class GeminiCliAdapter(ClaudeStyleAdapter):
34
+ name = "gemini-cli"
35
+ display_name = "Gemini CLI"
36
+ events = {"BeforeAgent": PROMPT, "PreCompress": PRE_COMPACT}
37
+ restore_on_prompt = True
38
+
39
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
40
+ event = super().parse(name, kind, payload)
41
+ if kind == PROMPT:
42
+ # In -p mode SessionStart context is prefixed to the prompt.
43
+ event.prompt = _HOOK_CONTEXT_RE.sub("", event.prompt)
44
+ elif kind == PRE_COMPACT and event.trigger == "manual":
45
+ event.extra["compacted"] = True # /compress always compresses
46
+ return event
47
+
48
+
49
+ class QwenCodeAdapter(ClaudeStyleAdapter):
50
+ name = "qwen-code"
51
+ display_name = "Qwen Code"
52
+ events = {"UserPromptSubmit": PROMPT, "PreCompact": PRE_COMPACT, "SessionStart": SESSION_START}
53
+
54
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
55
+ event = super().parse(name, kind, payload)
56
+ if kind == PROMPT:
57
+ submitted = payload.get("submitted_prompt")
58
+ # Absent on tool-result continuations: nothing to classify there.
59
+ event.prompt = submitted if isinstance(submitted, str) else ""
60
+ return event
@@ -0,0 +1,98 @@
1
+ """Grok Build (xAI ``grok`` >= 1.0.40), verified end to end against 1.0.40.
2
+
3
+ - Prompt-submit output is discarded by Grok (no hint possible).
4
+ - ``PostToolUse`` can genuinely replace a shell result — but only with the
5
+ complete tagged ``ToolOutput`` object: the received ``toolResult`` with
6
+ ``output_for_prompt`` replaced and ``output: []`` (Grok's own doc example is
7
+ rejected). The ``exit: N …`` header line is kept verbatim; truncated,
8
+ backgrounded or non-Bash results are left alone.
9
+ - ``PreCompact`` is observe-only; the model's view lives in
10
+ ``chat_history.jsonl`` next to ``transcriptPath``. ``PostCompact`` output is
11
+ ignored, so the snapshot rides the next shell call as ``additionalContext``
12
+ (the only model-visible channel Grok gives command hooks).
13
+ - Any ``decision``/``permissionDecision``/``continue:false`` would block or
14
+ steer; subcortex only ever writes ``updatedToolOutput`` and ``additionalContext``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any, Dict, Optional
22
+
23
+ from .. import policy
24
+ from .base import POST_COMPACT, PRE_COMPACT, PROMPT, TOOL_OUTPUT, HookAdapter, HookEvent, Response
25
+
26
+ MAX_CONTEXT_CHARS = 9000
27
+
28
+
29
+ def _split_header(text: str):
30
+ """(``exit: N …`` header incl. newline or "", body)."""
31
+ if text.startswith("exit: "):
32
+ newline = text.find("\n")
33
+ if newline != -1:
34
+ return text[:newline + 1], text[newline + 1:]
35
+ return "", text
36
+
37
+
38
+ class GrokBuildAdapter(HookAdapter):
39
+ name = "grok-build"
40
+ display_name = "Grok Build"
41
+ events = {"UserPromptSubmit": PROMPT, "PostToolUse": TOOL_OUTPUT, "PreCompact": PRE_COMPACT,
42
+ "PostCompact": POST_COMPACT}
43
+ delivers_hints = False # prompt-submit output is discarded; the event only records the request
44
+
45
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
46
+ event = HookEvent(kind=kind, name=name, payload=payload,
47
+ session_id=str(payload.get("sessionId") or payload.get("session_id") or ""),
48
+ transcript_path=str(payload.get("transcriptPath") or ""),
49
+ trigger=str(payload.get("source") or ""))
50
+ if kind == PROMPT:
51
+ prompt = payload.get("prompt")
52
+ event.prompt = prompt if isinstance(prompt, str) else ""
53
+ elif kind == PRE_COMPACT and event.transcript_path:
54
+ chat = Path(event.transcript_path).parent / "chat_history.jsonl"
55
+ if chat.is_file():
56
+ event.transcript_path = str(chat)
57
+ elif kind == TOOL_OUTPUT:
58
+ result = payload.get("toolResult")
59
+ if payload.get("toolResultTruncated") or not isinstance(result, dict) \
60
+ or result.get("type") != "Bash" or result.get("signal") == "backgrounded":
61
+ return event
62
+ text = result.get("output_for_prompt")
63
+ if not isinstance(text, str):
64
+ return event
65
+ header, body = _split_header(text)
66
+ event.extra["header"] = header
67
+ event.output = body
68
+ event.tool = str(payload.get("toolName") or "run_terminal_command")
69
+ event.tool_input = payload.get("toolInput")
70
+ code = result.get("exit_code")
71
+ event.failed = bool(result.get("timed_out")) or (isinstance(code, int) and code != 0)
72
+ return event
73
+
74
+ def output_context(self, event: HookEvent, cfg: Dict[str, Any]) -> Optional[str]:
75
+ # PostCompact output is ignored, so the snapshot rides the next shell call.
76
+ restored = policy.restore_snapshot(event.session_id, cfg, require_ready=True, tui=self.name,
77
+ commits=event.commits)
78
+ return restored[:MAX_CONTEXT_CHARS] if restored else None
79
+
80
+ def render_tool_output(self, event: HookEvent, replacement: Optional[str]) -> Response:
81
+ specific: Dict[str, Any] = {"hookEventName": "PostToolUse"}
82
+ result = event.payload.get("toolResult")
83
+ if replacement and isinstance(result, dict):
84
+ specific["updatedToolOutput"] = {**result, "output": [],
85
+ "output_for_prompt": event.extra.get("header", "") + replacement}
86
+ if event.extra.get("context"):
87
+ specific["additionalContext"] = event.extra["context"]
88
+ return {"hookSpecificOutput": specific} if len(specific) > 1 else None
89
+
90
+ def guard(self, kind: str, response: Response) -> Response:
91
+ if isinstance(response, dict) and set(response) - {"hookSpecificOutput"}:
92
+ return None # only hookSpecificOutput is ever safe in Grok
93
+ return super().guard(kind, response)
94
+
95
+
96
+ def grok_home() -> Path:
97
+ override = os.environ.get("GROK_HOME", "").strip()
98
+ return Path(override) if override else Path.home() / ".grok"
@@ -0,0 +1,138 @@
1
+ """Kimi Code CLI (MoonshotAI/kimi-code >= 0.33) hook adapter.
2
+
3
+ Kimi's hook contract differs from Claude Code's in ways that matter:
4
+
5
+ - ``UserPromptSubmit.prompt`` is a list of content parts, not a string.
6
+ - On exit 0 the hook's *message* is injected as context: ``{"message": ...}``
7
+ or, failing that, the whole stdout verbatim — so Claude-style JSON (or even
8
+ ``{}``) would be injected as literal text. We emit ``{"message": ...}`` only.
9
+ - ``PostToolUse`` is fire-and-forget: its output is discarded, so tool output
10
+ can't be replaced (Kimi externalizes huge results itself). Not registered.
11
+ - ``SessionStart`` output is ignored and it never fires after compaction, so
12
+ the snapshot is delivered with the first prompt after ``PostCompact``.
13
+ - Payloads carry no transcript path: the session's ``wire.jsonl`` is found via
14
+ ``$KIMI_CODE_HOME/session_index.jsonl``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from ..transcript import read_tail
25
+ from .base import POST_COMPACT, PRE_COMPACT, PROMPT, HookAdapter, HookEvent, Response
26
+
27
+ SNAPSHOT_LIMIT = 5
28
+ SNAPSHOT_CHARS = 500
29
+
30
+
31
+ def kimi_home() -> Path:
32
+ override = os.environ.get("KIMI_CODE_HOME", "").strip()
33
+ return Path(override).expanduser() if override else Path.home() / ".kimi-code"
34
+
35
+
36
+ def session_wire(session_id: str) -> Optional[Path]:
37
+ """``<sessionDir>/agents/main/wire.jsonl`` for a Kimi session id."""
38
+ if not session_id or "/" in session_id:
39
+ return None
40
+ home = kimi_home()
41
+ session_dir = None
42
+ try:
43
+ with open(home / "session_index.jsonl", encoding="utf-8") as fh:
44
+ for line in fh:
45
+ try:
46
+ record = json.loads(line)
47
+ except ValueError:
48
+ continue
49
+ if isinstance(record, dict) and record.get("sessionId") == session_id:
50
+ session_dir = None if record.get("deleted") else record.get("sessionDir")
51
+ except OSError:
52
+ pass
53
+ if isinstance(session_dir, str):
54
+ wire = Path(session_dir) / "agents" / "main" / "wire.jsonl"
55
+ if wire.is_file():
56
+ return wire
57
+ matches = sorted((home / "sessions").glob(f"*/{session_id}/agents/main/wire.jsonl"))
58
+ return matches[-1] if matches else None
59
+
60
+
61
+ def wire_messages(path: Path, limit: int = SNAPSHOT_LIMIT,
62
+ max_chars: int = SNAPSHOT_CHARS) -> List[Dict[str, str]]:
63
+ """User prompts and assistant text from a Kimi ``wire.jsonl`` tail.
64
+
65
+ User turns are ``context.append_message`` records whose origin kind is
66
+ ``user`` (injections, hook results and task notices are skipped); assistant
67
+ text streams as ``content.part`` loop events and is joined per step.
68
+ """
69
+ messages: List[Dict[str, str]] = []
70
+ step, buffer = None, []
71
+
72
+ def flush() -> None:
73
+ text = "".join(buffer).strip()
74
+ if text:
75
+ messages.append({"role": "assistant", "text": text[:max_chars]})
76
+ buffer.clear()
77
+
78
+ for line in read_tail(path).splitlines():
79
+ try:
80
+ record = json.loads(line)
81
+ except ValueError:
82
+ continue
83
+ if not isinstance(record, dict):
84
+ continue
85
+ kind = record.get("type")
86
+ if kind == "context.clear":
87
+ flush()
88
+ messages.clear()
89
+ elif kind == "context.append_message":
90
+ message = record.get("message") or {}
91
+ origin = message.get("origin")
92
+ origin_kind = origin.get("kind") if isinstance(origin, dict) else origin
93
+ if message.get("role") != "user" or origin_kind not in (None, "user"):
94
+ continue
95
+ flush()
96
+ text = "\n".join(
97
+ part.get("text", "") for part in message.get("content") or []
98
+ if isinstance(part, dict) and part.get("type") == "text").strip()
99
+ if text:
100
+ messages.append({"role": "user", "text": text[:max_chars]})
101
+ elif kind == "context.append_loop_event":
102
+ event = record.get("event") or {}
103
+ part = event.get("part") or {}
104
+ if event.get("type") != "content.part" or part.get("type") != "text":
105
+ continue
106
+ if event.get("stepUuid") != step:
107
+ flush()
108
+ step = event.get("stepUuid")
109
+ buffer.append(str(part.get("text", "")))
110
+ flush()
111
+ return messages[-limit:]
112
+
113
+
114
+ class KimiCodeAdapter(HookAdapter):
115
+ name = "kimi-code"
116
+ display_name = "Kimi Code CLI"
117
+ events = {
118
+ "UserPromptSubmit": PROMPT,
119
+ "PreCompact": PRE_COMPACT,
120
+ "PostCompact": POST_COMPACT,
121
+ }
122
+ restore_on_prompt = True
123
+
124
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
125
+ event = super().parse(name, kind, payload)
126
+ if kind == PROMPT:
127
+ parts = payload.get("prompt")
128
+ if isinstance(parts, list):
129
+ event.prompt = "\n".join(
130
+ p.get("text", "") for p in parts
131
+ if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str))
132
+ elif kind == PRE_COMPACT:
133
+ wire = session_wire(event.session_id)
134
+ event.messages = wire_messages(wire) if wire else []
135
+ return event
136
+
137
+ def render_prompt(self, event: HookEvent, text: str) -> Response:
138
+ return {"message": text}
@@ -0,0 +1,96 @@
1
+ """Letta Code (>= 0.32) and Mistral Vibe (>= 2.25.5).
2
+
3
+ Letta: ``UserPromptSubmit`` exit-0 stdout is injected verbatim (JSON is not
4
+ parsed — ``{}`` would be injected as text), so the hint is plain text. No
5
+ hook can replace tool output, and there's no transcript to snapshot.
6
+
7
+ Vibe: no prompt or compaction events, but ``post_tool`` can replace what the
8
+ model sees: ``{"decision": "deny", "reason": R}`` swaps ``tool_output_text``
9
+ for ``R`` without failing or un-running the call (verified in both of Vibe's
10
+ harnesses). That is the one place subcortex emits ``decision: deny``; it is
11
+ allowed for this adapter's tool-output event only. Failed calls
12
+ (``tool_status`` other than ``success``) are left alone.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional
19
+
20
+ from ..transcript import _entries, message_from_entry
21
+ from .base import PROMPT, TOOL_OUTPUT, HookAdapter, HookEvent, Response
22
+
23
+
24
+ def turn_request(transcript_path: Any) -> str:
25
+ """The user request of the turn in progress, from Vibe's messages.jsonl.
26
+
27
+ Vibe rewrites that log after every LLM step, so once a turn is past its
28
+ first step the log ends with that step's tool results and its last
29
+ (non-injected) user message is the current request. At a turn's first
30
+ step the log still ends with the previous turn, whose request is not
31
+ this one's evidence: "" then (the output is kept).
32
+ """
33
+ try:
34
+ if not isinstance(transcript_path, str) or not transcript_path:
35
+ return ""
36
+ entries = [e for e in _entries(Path(transcript_path)) if isinstance(e, dict)]
37
+ except Exception:
38
+ return ""
39
+ if not entries or entries[-1].get("role") != "tool":
40
+ return ""
41
+ for entry in reversed(entries):
42
+ if entry.get("role") == "user" and not entry.get("injected"):
43
+ message = message_from_entry(entry)
44
+ return message["text"] if message else ""
45
+ return ""
46
+
47
+
48
+ class LettaAdapter(HookAdapter):
49
+ name = "letta"
50
+ display_name = "Letta Code"
51
+ events = {"UserPromptSubmit": PROMPT}
52
+ event_field = "event_type"
53
+
54
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
55
+ if payload.get("is_command"):
56
+ return None # slash commands
57
+ prompt = payload.get("prompt")
58
+ # conversation_id is literally "default" for every agent's default
59
+ # conversation: scope it to the agent, or all Letta sessions share state.
60
+ agent = str(payload.get("agent_id") or "")
61
+ conversation = str(payload.get("conversation_id") or "")
62
+ if not conversation or conversation == "default":
63
+ session = f"{agent}:default" if agent else ""
64
+ else:
65
+ session = conversation
66
+ return HookEvent(kind=kind, name=name, payload=payload, session_id=session,
67
+ prompt=prompt if isinstance(prompt, str) else "")
68
+
69
+ def render_prompt(self, event: HookEvent, text: str) -> Response:
70
+ return text # plain stdout; Letta wraps it in a system reminder
71
+
72
+
73
+ class VibeAdapter(HookAdapter):
74
+ name = "vibe"
75
+ display_name = "Mistral Vibe"
76
+ events = {"post_tool": TOOL_OUTPUT}
77
+ allowed_blocking = ((TOOL_OUTPUT, "decision", "deny"),)
78
+
79
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
80
+ event = HookEvent(kind=kind, name=name, payload=payload,
81
+ session_id=str(payload.get("session_id") or ""))
82
+ text = payload.get("tool_output_text")
83
+ if payload.get("tool_status") != "success" or not isinstance(text, str):
84
+ return event
85
+ event.output = text
86
+ event.extra["task"] = turn_request(payload.get("transcript_path"))
87
+ event.tool = str(payload.get("tool_name") or "bash")
88
+ event.tool_input = payload.get("tool_input")
89
+ return event
90
+
91
+ def render_tool_output(self, event: HookEvent, replacement: Optional[str]) -> Response:
92
+ if not replacement:
93
+ return None # an empty reason would blank the output
94
+ removed = len(event.output or "") - len(replacement)
95
+ return {"decision": "deny", "reason": replacement,
96
+ "system_message": f"subcortex: trimmed {max(removed, 0)} chars of low-value output"}
@@ -0,0 +1,153 @@
1
+ """OpenHands CLI (>= 1.12, SDK hook engine) adapter.
2
+
3
+ Only ``UserPromptSubmit`` can inject (``{"additionalContext": ...}``); the
4
+ terminal tool truncates big outputs itself (30k chars) and PostToolUse /
5
+ SessionStart output is ignored, so those aren't registered.
6
+
7
+ There is no compaction hook. OpenHands' event log is append-only, though: a
8
+ ``Condensation`` event hides older events from the model but leaves them on
9
+ disk. So on each prompt we check for a condensation we haven't handled yet and,
10
+ if there is one, hand back the last messages it hid. The first prompt of a
11
+ session only records a baseline (a resumed, already-condensed conversation is
12
+ not re-injected).
13
+
14
+ Never emitted, per the SDK executor: exit 2, ``decision: deny`` (with any exit
15
+ code) or ANY ``continue`` key (every falsy value blocks).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from .. import state
26
+ from ..transcript import message_from_entry
27
+ from .base import PROMPT, HookAdapter, HookEvent, Response
28
+
29
+ MAX_EVENT_FILES = 3000
30
+
31
+
32
+ def conversations_dir() -> Path:
33
+ override = os.environ.get("OPENHANDS_CONVERSATIONS_DIR", "").strip()
34
+ if override:
35
+ return Path(override)
36
+ persistence = os.environ.get("OPENHANDS_PERSISTENCE_DIR", "").strip()
37
+ base = Path(persistence) if persistence else Path.home() / ".openhands"
38
+ return base / "conversations"
39
+
40
+
41
+ def _read(path: Path) -> Optional[Dict[str, Any]]:
42
+ try:
43
+ data = json.loads(path.read_text(encoding="utf-8"))
44
+ except (OSError, ValueError):
45
+ return None
46
+ return data if isinstance(data, dict) else None
47
+
48
+
49
+ def _event_message(event: Dict[str, Any]) -> Optional[Dict[str, str]]:
50
+ if event.get("kind") != "MessageEvent":
51
+ return None
52
+ role = {"user": "user", "agent": "assistant"}.get(str(event.get("source")))
53
+ if role is None:
54
+ return None
55
+ msg = message_from_entry({"role": role, **(event.get("llm_message") or {})})
56
+ return {"role": role, "text": msg["text"]} if msg else None
57
+
58
+
59
+ def _record(session_id: str, record: Dict[str, Any]) -> None:
60
+ marker = state.path("openhands", "openhands", session_id)
61
+ if marker is not None:
62
+ with state.locked(state.key("openhands", session_id)):
63
+ state.write_json(marker, record)
64
+
65
+
66
+ def condensation_context(session_id: str, cfg: Dict[str, Any],
67
+ commits: Optional[List[Any]] = None) -> Optional[str]:
68
+ """Messages hidden by a condensation not yet reported for this session.
69
+
70
+ Only event files newer than the last scan are read (a long conversation has
71
+ thousands). The "reported" marker is written once the context was delivered
72
+ (via ``commits``), so a hook that dies mid-way reports it next time.
73
+ """
74
+ from .. import policy
75
+
76
+ if not session_id or "/" in session_id:
77
+ return None
78
+ events_dir = conversations_dir() / session_id.replace("-", "") / "events"
79
+ try:
80
+ files = sorted(events_dir.glob("event-*.json"))[-MAX_EVENT_FILES:]
81
+ marker = state.path("openhands", "openhands", session_id)
82
+ recorded = state.read_json(marker) if marker is not None else None
83
+ except Exception:
84
+ return None
85
+ if not files:
86
+ return None
87
+ first_visit = recorded is None
88
+ seen = (recorded or {}).get("condensation")
89
+ scanned = str((recorded or {}).get("scanned") or "")
90
+ newest, index = seen, -1
91
+ for i in range(len(files) - 1, -1, -1):
92
+ if not first_visit and files[i].name <= scanned:
93
+ break # older files were checked on an earlier prompt
94
+ event = _read(files[i])
95
+ if event and event.get("kind") == "Condensation":
96
+ newest, index = event.get("id") or files[i].name, i
97
+ break
98
+ record = {"condensation": newest, "scanned": files[-1].name}
99
+ deliver = not first_visit and index != -1 and newest != seen
100
+ context = None
101
+ if deliver:
102
+ limit = int((cfg.get("hooks") or {}).get("snapshot_messages", 5))
103
+ max_chars = int((cfg.get("hooks") or {}).get("snapshot_chars", 500))
104
+ messages: List[Dict[str, str]] = []
105
+ for path in reversed(files[:index]): # newest hidden message first, stop when enough
106
+ event = _read(path)
107
+ msg = _event_message(event) if event else None
108
+ if msg:
109
+ messages.append({"role": msg["role"], "text": msg["text"][:max_chars]})
110
+ if len(messages) >= limit:
111
+ break
112
+ if messages:
113
+ lines = [f"{m['role']}: {m['text']}" for m in reversed(messages)]
114
+ context = policy.RESTORE_HEADER + "\n" + "\n".join(lines)
115
+ try:
116
+ state.prune("openhands", policy.SNAPSHOT_MAX_AGE_S)
117
+ if context and commits is not None:
118
+ commits.append(lambda: _record(session_id, record))
119
+ elif record != recorded:
120
+ _record(session_id, record)
121
+ except Exception:
122
+ return None
123
+ return context
124
+
125
+
126
+ class OpenHandsAdapter(HookAdapter):
127
+ name = "openhands"
128
+ display_name = "OpenHands CLI"
129
+ events = {"UserPromptSubmit": PROMPT}
130
+ event_field = "event_type"
131
+
132
+ def parse(self, name: str, kind: str, payload: Dict[str, Any]) -> Optional[HookEvent]:
133
+ event = super().parse(name, kind, payload)
134
+ message = payload.get("message")
135
+ event.prompt = message if isinstance(message, str) else ""
136
+ return event
137
+
138
+ def prompt_context(self, event: HookEvent, cfg: Dict[str, Any]) -> Optional[str]:
139
+ if not (cfg.get("features") or {}).get("compaction_snapshot", True):
140
+ return None
141
+ try:
142
+ return condensation_context(event.session_id, cfg, event.commits)
143
+ except Exception:
144
+ return None
145
+
146
+ def render_prompt(self, event: HookEvent, text: str) -> Response:
147
+ return {"additionalContext": text}
148
+
149
+ def guard(self, kind: str, response: Response) -> Response:
150
+ # Any `continue` key with a falsy value blocks in OpenHands; allow none at all.
151
+ if isinstance(response, dict) and "continue" in response:
152
+ return None
153
+ return super().guard(kind, response)