0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
274 lines
8.9 KiB
Python
274 lines
8.9 KiB
Python
"""OpenCode plugin for headroom learn.
|
|
|
|
Reads conversation data from the OpenCode SQLite database at
|
|
``~/.local/share/opencode/opencode.db``.
|
|
|
|
OpenCode stores messages and tool parts in two tables:
|
|
- ``message``: one row per turn (user/assistant), with JSON ``data``
|
|
- ``part``: one row per content part; tool parts have ``data.type == "tool"``
|
|
|
|
A tool part looks like::
|
|
|
|
{
|
|
"type": "tool",
|
|
"tool": "bash",
|
|
"callID": "toolu_01...",
|
|
"state": {
|
|
"status": "completed" | "error",
|
|
"input": { ... },
|
|
"output": "..."
|
|
}
|
|
}
|
|
|
|
``headroom learn opencode`` mines these for errors and writes corrections to
|
|
the project's ``AGENTS.md`` file (OpenCode's native rules file).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from .._shared import classify_error, is_error_content, normalize_tool_name
|
|
from ..base import ConversationScanner, LearnPlugin
|
|
from ..models import (
|
|
ErrorCategory,
|
|
ProjectInfo,
|
|
SessionData,
|
|
SessionEvent,
|
|
ToolCall,
|
|
)
|
|
from ..writer import CodexWriter, ContextWriter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
|
|
|
# Tool part status values that indicate failure.
|
|
_ERROR_STATUSES = {"error", "failed", "aborted"}
|
|
|
|
|
|
class OpenCodePlugin(LearnPlugin, ConversationScanner):
|
|
"""Read OpenCode sessions from the SQLite database.
|
|
|
|
OpenCode stores all conversation data in a single SQLite file which makes
|
|
discovery fast — one file, many projects.
|
|
"""
|
|
|
|
def __init__(self, db_path: Path | None = None) -> None:
|
|
self._db_path = db_path or _OPENCODE_DB
|
|
|
|
# ------------------------------------------------------------------
|
|
# LearnPlugin identity
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "opencode"
|
|
|
|
@property
|
|
def display_name(self) -> str:
|
|
return "OpenCode"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return "OpenCode (~/.local/share/opencode/opencode.db)"
|
|
|
|
def detect(self) -> bool:
|
|
return self._db_path.exists()
|
|
|
|
def create_writer(self) -> ContextWriter:
|
|
# Re-use CodexWriter — it writes to AGENTS.md, which is exactly what
|
|
# OpenCode reads.
|
|
return CodexWriter()
|
|
|
|
# ------------------------------------------------------------------
|
|
# ConversationScanner interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def discover_projects(self) -> list[ProjectInfo]:
|
|
"""Discover all projects that have at least one session."""
|
|
if not self.detect():
|
|
return []
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(self._db_path))
|
|
except sqlite3.Error as exc:
|
|
logger.warning("Cannot open OpenCode DB %s: %s", self._db_path, exc)
|
|
return []
|
|
|
|
try:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT p.id, p.name, p.worktree "
|
|
"FROM project p "
|
|
"WHERE EXISTS (SELECT 1 FROM session s WHERE s.project_id = p.id)"
|
|
)
|
|
projects: list[ProjectInfo] = []
|
|
for row in cursor.fetchall():
|
|
proj_id, proj_name, worktree = row
|
|
worktree_path = Path(worktree) if worktree else Path("~").expanduser()
|
|
agents_md = worktree_path / "AGENTS.md"
|
|
projects.append(
|
|
ProjectInfo(
|
|
name=proj_name or worktree_path.name or proj_id,
|
|
project_path=worktree_path,
|
|
data_path=self._db_path.parent,
|
|
context_file=agents_md if agents_md.exists() else None,
|
|
)
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
return projects
|
|
|
|
def scan_project(
|
|
self, project: ProjectInfo, max_workers: int = 1, include_subagents: bool = True
|
|
) -> list[SessionData]:
|
|
"""Scan all sessions for a project and return normalized tool calls."""
|
|
if not self.detect():
|
|
return []
|
|
|
|
try:
|
|
conn = sqlite3.connect(str(self._db_path))
|
|
except sqlite3.Error as exc:
|
|
logger.warning("Cannot open OpenCode DB: %s", exc)
|
|
return []
|
|
|
|
try:
|
|
return self._scan_project_sessions(conn, project)
|
|
finally:
|
|
conn.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _scan_project_sessions(
|
|
self, conn: sqlite3.Connection, project: ProjectInfo
|
|
) -> list[SessionData]:
|
|
cursor = conn.cursor()
|
|
|
|
# Find the project ID from the worktree path.
|
|
cursor.execute(
|
|
"SELECT id FROM project WHERE worktree = ?",
|
|
(str(project.project_path),),
|
|
)
|
|
row = cursor.fetchone()
|
|
if row is None:
|
|
return []
|
|
project_db_id: str = row[0]
|
|
|
|
# Get all sessions for this project.
|
|
cursor.execute(
|
|
"SELECT id, time_created FROM session WHERE project_id = ? ORDER BY time_created DESC LIMIT 200",
|
|
(project_db_id,),
|
|
)
|
|
session_rows = cursor.fetchall()
|
|
|
|
sessions: list[SessionData] = []
|
|
for session_id, time_created in session_rows:
|
|
session = self._scan_session(conn, session_id, time_created)
|
|
if session is not None and session.tool_calls:
|
|
sessions.append(session)
|
|
|
|
return sessions
|
|
|
|
def _scan_session(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
session_id: str,
|
|
time_created: int | None,
|
|
) -> SessionData | None:
|
|
cursor = conn.cursor()
|
|
|
|
# Get all tool parts for this session ordered by creation time.
|
|
cursor.execute(
|
|
"""
|
|
SELECT p.data, p.time_created
|
|
FROM part p
|
|
JOIN message m ON p.message_id = m.id
|
|
WHERE m.session_id = ?
|
|
AND p.data LIKE '%"type"%tool%'
|
|
ORDER BY p.time_created
|
|
""",
|
|
(session_id,),
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
tool_calls: list[ToolCall] = []
|
|
events: list[SessionEvent] = []
|
|
|
|
for idx, (data_raw, part_time) in enumerate(rows):
|
|
try:
|
|
data = json.loads(data_raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if data.get("type") != "tool":
|
|
continue
|
|
|
|
raw_tool_name = str(data.get("tool") or "unknown")
|
|
tool_name = (
|
|
"Bash" if raw_tool_name.lower() == "bash" else normalize_tool_name(raw_tool_name)
|
|
)
|
|
call_id = str(data.get("callID") or f"oc_{session_id}_{idx}")
|
|
state_raw = data.get("state")
|
|
state: dict = state_raw if isinstance(state_raw, dict) else {}
|
|
status = str(state.get("status") or "unknown")
|
|
input_raw = state.get("input")
|
|
input_data: dict = input_raw if isinstance(input_raw, dict) else {}
|
|
output: str = str(state.get("output") or "")
|
|
|
|
# Detect truncated output pointer.
|
|
if output == "...output truncated..." or output.startswith("...output truncated"):
|
|
truncated_ref = state.get("outputRef") or state.get("outputFile")
|
|
if truncated_ref:
|
|
try:
|
|
output = Path(truncated_ref).read_text(errors="replace")
|
|
except OSError:
|
|
pass
|
|
|
|
is_error = status in _ERROR_STATUSES or is_error_content(output)
|
|
error_cat = classify_error(output) if is_error else ErrorCategory.UNKNOWN
|
|
|
|
tc = ToolCall(
|
|
name=tool_name,
|
|
tool_call_id=call_id,
|
|
input_data=input_data,
|
|
output=output,
|
|
is_error=is_error,
|
|
error_category=error_cat,
|
|
msg_index=idx,
|
|
output_bytes=len(output.encode()),
|
|
)
|
|
tool_calls.append(tc)
|
|
part_ts = str(part_time) if part_time is not None else None
|
|
events.append(
|
|
SessionEvent(type="tool_call", msg_index=idx, timestamp=part_ts, tool_call=tc)
|
|
)
|
|
|
|
if not tool_calls:
|
|
return None
|
|
|
|
ts: datetime | None = None
|
|
if time_created is not None:
|
|
try:
|
|
ts = datetime.fromtimestamp(time_created / 1000, tz=timezone.utc)
|
|
except (OSError, OverflowError, ValueError):
|
|
pass
|
|
|
|
return SessionData(
|
|
session_id=session_id,
|
|
tool_calls=tool_calls,
|
|
events=events,
|
|
timestamp=ts,
|
|
)
|
|
|
|
|
|
# Module-level instance — auto-discovered by the learn plugin registry.
|
|
plugin = OpenCodePlugin()
|