/** * Trellis Context Manager * * Utility class for OpenCode plugins providing file reading, * JSONL parsing, and context building capabilities. */ import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs" import { isAbsolute, join } from "path" import { platform } from "os" import { execSync } from "child_process" import { createHash } from "crypto" import process from "process" const PYTHON_CMD = platform() === "win32" ? "python" : "python3" // Debug logging const DEBUG_LOG = "/tmp/trellis-plugin-debug.log" function debugLog(prefix, ...args) { const timestamp = new Date().toISOString() const msg = `[${timestamp}] [${prefix}] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : a).join(" ")}\n` try { appendFileSync(DEBUG_LOG, msg) } catch { // ignore } } function stringValue(value) { return typeof value === "string" && value.trim() ? value.trim() : null } function sanitizeKey(raw) { const safe = raw.trim().replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._-]+|[._-]+$/g, "") return safe ? safe.slice(0, 160) : "" } function hashValue(raw) { return createHash("sha256").update(raw).digest("hex").slice(0, 24) } function lookupString(data, keys) { if (!data || typeof data !== "object") return null for (const key of keys) { const value = stringValue(data[key]) if (value) return value } for (const nestedKey of ["input", "properties", "event", "hook_input", "hookInput"]) { const nested = data[nestedKey] if (nested && typeof nested === "object") { const value = lookupString(nested, keys) if (value) return value } } return null } function buildContextKey(platformName, kind, value) { if (kind === "transcript") { return `${platformName}_transcript_${hashValue(value)}` } const safeValue = sanitizeKey(value) return safeValue ? `${platformName}_${safeValue}` : `${platformName}_${hashValue(value)}` } // Matches `trellis-implement`, `trellis-check`, `trellis-research` exactly. // Used by chat.message plugins to skip injection inside Trellis sub-agent turns. const TRELLIS_SUBAGENT_RE = /^trellis-(implement|check|research)$/ /** * Return true when the OpenCode `chat.message` input represents a Trellis * sub-agent turn. `input.agent` is set by OpenCode when a Task tool spawns a * child session with a custom agent (see `packages/opencode/src/tool/task.ts`). */ export function isTrellisSubagent(input) { if (!input || typeof input !== "object") return false const agent = typeof input.agent === "string" ? input.agent.trim() : "" return TRELLIS_SUBAGENT_RE.test(agent) } /** * Trellis Context Manager */ export class TrellisContext { constructor(directory) { this.directory = directory debugLog("context", "TrellisContext initialized", { directory }) } // ============================================================ // Trellis Project Detection // ============================================================ isTrellisProject() { return existsSync(join(this.directory, ".trellis")) } getContextKey(platformInput = null) { const override = stringValue(process.env.TRELLIS_CONTEXT_ID) if (override) { return sanitizeKey(override) || hashValue(override) } const runID = stringValue(process.env.OPENCODE_RUN_ID) if (runID) return buildContextKey("opencode", "session", runID) const input = platformInput && typeof platformInput === "object" ? platformInput : null if (!input) return null const sessionID = lookupString(input, ["session_id", "sessionId", "sessionID"]) if (sessionID) return buildContextKey("opencode", "session", sessionID) const conversationID = lookupString(input, ["conversation_id", "conversationId", "conversationID"]) if (conversationID) return buildContextKey("opencode", "conversation", conversationID) const transcriptPath = lookupString(input, ["transcript_path", "transcriptPath", "transcript"]) if (transcriptPath) return buildContextKey("opencode", "transcript", transcriptPath) return null } readContext(contextKey) { try { const contextPath = join(this.directory, ".trellis", ".runtime", "sessions", `${contextKey}.json`) if (!existsSync(contextPath)) return null return JSON.parse(readFileSync(contextPath, "utf-8")) } catch { return null } } /** * Get active task from session runtime context. * * Resolution order (mirrors Python `active_task.resolve_active_task`): * 1. Lookup the runtime file for the input-derived context key. * 2. If that misses and exactly one session runtime file exists locally, * use it (`_resolveSingleSessionFallback`). Refuses to guess when 0 or * ≥2 files exist so multi-window isolation holds. */ getActiveTask(platformInput = null) { const contextKey = this.getContextKey(platformInput) if (contextKey) { const context = this.readContext(contextKey) const taskRef = this.normalizeTaskRef(context?.current_task || "") if (taskRef) { const taskDir = this.resolveTaskDir(taskRef) return { taskPath: taskRef, source: `session:${contextKey}`, stale: !taskDir || !existsSync(taskDir), } } } const fallback = this._resolveSingleSessionFallback() if (fallback) { return fallback } return { taskPath: null, source: "none", stale: false } } /** * Mirror of Python `_resolve_single_session_fallback`. Returns the task * pointed at by the sole session runtime file when exactly one exists, * else null. */ _resolveSingleSessionFallback() { const sessionsDir = join(this.directory, ".trellis", ".runtime", "sessions") if (!existsSync(sessionsDir)) return null let files try { files = readdirSync(sessionsDir) .filter(name => name.endsWith(".json")) .sort() } catch { return null } if (files.length !== 1) return null const sessionFile = join(sessionsDir, files[0]) let context try { context = JSON.parse(readFileSync(sessionFile, "utf-8")) } catch { return null } const taskRef = this.normalizeTaskRef(context?.current_task || "") if (!taskRef) return null const taskDir = this.resolveTaskDir(taskRef) const fallbackKey = files[0].replace(/\.json$/, "") return { taskPath: taskRef, source: `session-fallback:${fallbackKey}`, stale: !taskDir || !existsSync(taskDir), } } getCurrentTask(platformInput = null) { return this.getActiveTask(platformInput).taskPath } normalizeTaskRef(taskRef) { if (!taskRef) { return "" } if (isAbsolute(taskRef)) { return taskRef.trim() } let normalized = taskRef.trim().replace(/\\/g, "/") while (normalized.startsWith("./")) { normalized = normalized.slice(2) } if (normalized.startsWith("tasks/")) { return `.trellis/${normalized}` } return normalized } resolveTaskDir(taskRef) { const normalized = this.normalizeTaskRef(taskRef) if (!normalized) { return null } if (isAbsolute(normalized)) { return normalized } if (normalized.startsWith(".trellis/")) { return join(this.directory, normalized) } return join(this.directory, ".trellis", "tasks", normalized) } // ============================================================ // File Reading Utilities // ============================================================ readFile(filePath) { try { if (existsSync(filePath)) { return readFileSync(filePath, "utf-8") } } catch { // Ignore read errors } return null } readProjectFile(relativePath) { return this.readFile(join(this.directory, relativePath)) } runScript(scriptPath, cwd = null, contextKey = null) { try { const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, { cwd: cwd || this.directory, timeout: 10000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...(contextKey ? { TRELLIS_CONTEXT_ID: contextKey } : {}), }, }) return result || "" } catch { return "" } } // ============================================================ // JSONL Reading // ============================================================ readDirectoryMdFiles(dirPath, maxFiles = 20) { const results = [] const fullPath = join(this.directory, dirPath) if (!existsSync(fullPath)) { return results } try { const files = readdirSync(fullPath) .filter(f => f.endsWith(".md")) .sort() .slice(0, maxFiles) for (const filename of files) { const filePath = join(dirPath, filename) const content = this.readProjectFile(filePath) if (content) { results.push({ path: filePath, content }) } } } catch { // Ignore directory read errors } return results } /** * Read a JSONL file and load referenced files/directories * Supports: * {"file": "path/to/file.md", "reason": "..."} * {"file": "path/to/dir/", "type": "directory", "reason": "..."} */ readJsonlWithFiles(jsonlPath) { const results = [] const content = this.readFile(jsonlPath) if (!content) return results for (const line of content.split("\n")) { if (!line.trim()) continue try { const item = JSON.parse(line) const file = item.file || item.path const entryType = item.type || "file" if (!file) continue if (entryType === "directory") { const dirEntries = this.readDirectoryMdFiles(file) results.push(...dirEntries) } else { const fullPath = join(this.directory, file) const fileContent = this.readFile(fullPath) if (fileContent) { results.push({ path: file, content: fileContent }) } } } catch { // Ignore parse errors for individual lines } } return results } buildContextFromEntries(entries) { return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n") } } // ============================================================ // Context Collector (for session deduplication) // ============================================================ class ContextCollector { constructor() { this.processed = new Set() } markProcessed(sessionID) { this.processed.add(sessionID) } isProcessed(sessionID) { return this.processed.has(sessionID) } clear(sessionID) { this.processed.delete(sessionID) } } // Singleton instance export const contextCollector = new ContextCollector() // Export debug log for plugins export { debugLog }