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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- 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)
|