437 lines
12 KiB
JavaScript
437 lines
12 KiB
JavaScript
|
|
/**
|
||
|
|
* Trellis Context Manager
|
||
|
|
*
|
||
|
|
* Unified context management for OpenCode plugins.
|
||
|
|
* Handles detection of oh-my-opencode, .claude/hooks/, and other edge cases.
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* import { TrellisContext } from "./trellis-context.js"
|
||
|
|
* const ctx = new TrellisContext(directory)
|
||
|
|
* if (ctx.shouldSkipHook("session-start")) return
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs"
|
||
|
|
import { join } from "path"
|
||
|
|
import { homedir, platform } from "os"
|
||
|
|
import { execSync } from "child_process"
|
||
|
|
|
||
|
|
// Python command: Windows uses 'python', macOS/Linux use 'python3'
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Trellis Context Manager
|
||
|
|
*
|
||
|
|
* Centralized logic for:
|
||
|
|
* - Detecting oh-my-opencode installation
|
||
|
|
* - Checking .claude/hooks/ presence
|
||
|
|
* - Determining which plugin should handle each hook
|
||
|
|
*/
|
||
|
|
export class TrellisContext {
|
||
|
|
constructor(directory) {
|
||
|
|
this.directory = directory
|
||
|
|
this._omoInstalled = null
|
||
|
|
this._omoHooksEnabled = null
|
||
|
|
this._claudeHooksPresent = {}
|
||
|
|
|
||
|
|
debugLog("context", "TrellisContext initialized", { directory })
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// oh-my-opencode Detection
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if oh-my-opencode is installed
|
||
|
|
*
|
||
|
|
* Detection order:
|
||
|
|
* 1. Check if oh-my-opencode.json exists (most reliable)
|
||
|
|
* 2. Fallback: check opencode.json plugin list
|
||
|
|
*/
|
||
|
|
isOmoInstalled() {
|
||
|
|
if (this._omoInstalled !== null) {
|
||
|
|
return this._omoInstalled
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const configDir = join(homedir(), ".config", "opencode")
|
||
|
|
|
||
|
|
// Method 1: Check oh-my-opencode.json existence (omo-specific config)
|
||
|
|
const omoConfigPath = join(configDir, "oh-my-opencode.json")
|
||
|
|
if (existsSync(omoConfigPath)) {
|
||
|
|
this._omoInstalled = true
|
||
|
|
debugLog("context", "omo installed: oh-my-opencode.json exists")
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Method 2: Fallback to plugin list check
|
||
|
|
const configPath = join(configDir, "opencode.json")
|
||
|
|
if (!existsSync(configPath)) {
|
||
|
|
this._omoInstalled = false
|
||
|
|
debugLog("context", "omo not installed: no config files")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
const content = readFileSync(configPath, "utf-8")
|
||
|
|
const config = JSON.parse(content)
|
||
|
|
const plugins = config.plugin || []
|
||
|
|
|
||
|
|
this._omoInstalled = plugins.some(p =>
|
||
|
|
typeof p === "string" && p.toLowerCase().includes("oh-my-opencode")
|
||
|
|
)
|
||
|
|
|
||
|
|
debugLog("context", "omo installed (plugin list):", this._omoInstalled)
|
||
|
|
return this._omoInstalled
|
||
|
|
} catch (e) {
|
||
|
|
debugLog("context", "omo detection error:", e.message)
|
||
|
|
this._omoInstalled = false
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if omo's claude_code.hooks is enabled
|
||
|
|
* Reads oh-my-opencode.json or defaults to true
|
||
|
|
*/
|
||
|
|
isOmoHooksEnabled() {
|
||
|
|
if (this._omoHooksEnabled !== null) {
|
||
|
|
return this._omoHooksEnabled
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!this.isOmoInstalled()) {
|
||
|
|
this._omoHooksEnabled = false
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Check global config
|
||
|
|
const globalConfig = join(homedir(), ".config", "opencode", "oh-my-opencode.json")
|
||
|
|
if (existsSync(globalConfig)) {
|
||
|
|
const content = readFileSync(globalConfig, "utf-8")
|
||
|
|
const config = JSON.parse(content)
|
||
|
|
if (config.claude_code?.hooks === false) {
|
||
|
|
this._omoHooksEnabled = false
|
||
|
|
debugLog("context", "omo hooks disabled in global config")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check project config
|
||
|
|
const projectConfig = join(this.directory, "oh-my-opencode.json")
|
||
|
|
if (existsSync(projectConfig)) {
|
||
|
|
const content = readFileSync(projectConfig, "utf-8")
|
||
|
|
const config = JSON.parse(content)
|
||
|
|
if (config.claude_code?.hooks === false) {
|
||
|
|
this._omoHooksEnabled = false
|
||
|
|
debugLog("context", "omo hooks disabled in project config")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Default: enabled
|
||
|
|
this._omoHooksEnabled = true
|
||
|
|
debugLog("context", "omo hooks enabled (default)")
|
||
|
|
return true
|
||
|
|
} catch (e) {
|
||
|
|
debugLog("context", "omo hooks detection error:", e.message)
|
||
|
|
this._omoHooksEnabled = true // Default to enabled
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// .claude/hooks/ Detection
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a specific .claude/hooks/ file exists
|
||
|
|
*/
|
||
|
|
hasClaudeHook(hookName) {
|
||
|
|
if (hookName in this._claudeHooksPresent) {
|
||
|
|
return this._claudeHooksPresent[hookName]
|
||
|
|
}
|
||
|
|
|
||
|
|
const hookPath = join(this.directory, ".claude", "hooks", `${hookName}.py`)
|
||
|
|
const exists = existsSync(hookPath)
|
||
|
|
|
||
|
|
this._claudeHooksPresent[hookName] = exists
|
||
|
|
debugLog("context", `claude hook ${hookName}:`, exists)
|
||
|
|
return exists
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// Trellis Project Detection
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if this is a Trellis-managed project
|
||
|
|
*/
|
||
|
|
isTrellisProject() {
|
||
|
|
return existsSync(join(this.directory, ".trellis"))
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get current task directory from .trellis/.current-task
|
||
|
|
*/
|
||
|
|
getCurrentTask() {
|
||
|
|
try {
|
||
|
|
const currentTaskPath = join(this.directory, ".trellis", ".current-task")
|
||
|
|
if (!existsSync(currentTaskPath)) {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
return readFileSync(currentTaskPath, "utf-8").trim()
|
||
|
|
} catch {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// Hook Decision Logic
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determine if our plugin should skip this hook
|
||
|
|
* (because omo will handle it via .claude/hooks/)
|
||
|
|
*
|
||
|
|
* @param {string} hookName - Hook name without extension (e.g., "session-start")
|
||
|
|
* @returns {boolean} - true if we should skip, false if we should handle
|
||
|
|
*/
|
||
|
|
shouldSkipHook(hookName) {
|
||
|
|
// Not a Trellis project? Skip.
|
||
|
|
if (!this.isTrellisProject()) {
|
||
|
|
debugLog("context", `shouldSkipHook(${hookName}): skip - not Trellis project`)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// omo not installed? We handle it.
|
||
|
|
if (!this.isOmoInstalled()) {
|
||
|
|
debugLog("context", `shouldSkipHook(${hookName}): handle - omo not installed`)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// omo installed but hooks disabled? We handle it.
|
||
|
|
if (!this.isOmoHooksEnabled()) {
|
||
|
|
debugLog("context", `shouldSkipHook(${hookName}): handle - omo hooks disabled`)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// omo installed + hooks enabled + .claude/hooks/ exists? Skip (omo handles).
|
||
|
|
if (this.hasClaudeHook(hookName)) {
|
||
|
|
debugLog("context", `shouldSkipHook(${hookName}): skip - omo will handle via .claude/hooks/`)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// omo installed but no .claude/hooks/ file? We handle it.
|
||
|
|
debugLog("context", `shouldSkipHook(${hookName}): handle - no .claude/hooks/ file`)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// File Reading Utilities
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read a file, return null on error
|
||
|
|
*/
|
||
|
|
readFile(filePath) {
|
||
|
|
try {
|
||
|
|
if (existsSync(filePath)) {
|
||
|
|
return readFileSync(filePath, "utf-8")
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Ignore read errors
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read a file relative to project directory
|
||
|
|
*/
|
||
|
|
readProjectFile(relativePath) {
|
||
|
|
return this.readFile(join(this.directory, relativePath))
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Run a Python script and return output
|
||
|
|
*/
|
||
|
|
runScript(scriptPath, cwd = null) {
|
||
|
|
try {
|
||
|
|
const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, {
|
||
|
|
cwd: cwd || this.directory,
|
||
|
|
timeout: 10000,
|
||
|
|
encoding: "utf-8",
|
||
|
|
stdio: ["pipe", "pipe", "pipe"]
|
||
|
|
})
|
||
|
|
return result || ""
|
||
|
|
} catch {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// JSONL Reading
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read all .md files in a directory
|
||
|
|
* @param {string} dirPath - Directory path relative to project root
|
||
|
|
* @param {number} maxFiles - Max files to read (prevent huge directories)
|
||
|
|
* @returns {Array<{path: string, content: string}>}
|
||
|
|
*/
|
||
|
|
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") {
|
||
|
|
// Read all .md files in directory
|
||
|
|
const dirEntries = this.readDirectoryMdFiles(file)
|
||
|
|
results.push(...dirEntries)
|
||
|
|
} else {
|
||
|
|
// Read single file
|
||
|
|
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
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build context string from file entries
|
||
|
|
*/
|
||
|
|
buildContextFromEntries(entries) {
|
||
|
|
return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// Context Collector (for synthetic message injection)
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Simple context collector for cross-hook communication
|
||
|
|
* Similar to oh-my-opencode's contextCollector
|
||
|
|
*/
|
||
|
|
class ContextCollector {
|
||
|
|
constructor() {
|
||
|
|
this.pending = new Map()
|
||
|
|
this.processed = new Set()
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Store context for a session
|
||
|
|
*/
|
||
|
|
store(sessionID, content) {
|
||
|
|
this.pending.set(sessionID, {
|
||
|
|
content,
|
||
|
|
timestamp: Date.now()
|
||
|
|
})
|
||
|
|
debugLog("collector", "stored context for session:", sessionID, "length:", content.length)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if session has pending context
|
||
|
|
*/
|
||
|
|
hasPending(sessionID) {
|
||
|
|
return this.pending.has(sessionID)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get and consume pending context
|
||
|
|
*/
|
||
|
|
consume(sessionID) {
|
||
|
|
const pending = this.pending.get(sessionID)
|
||
|
|
this.pending.delete(sessionID)
|
||
|
|
return pending
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mark session as processed (for first-message-only injection)
|
||
|
|
*/
|
||
|
|
markProcessed(sessionID) {
|
||
|
|
this.processed.add(sessionID)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if session was already processed
|
||
|
|
*/
|
||
|
|
isProcessed(sessionID) {
|
||
|
|
return this.processed.has(sessionID)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear session state
|
||
|
|
*/
|
||
|
|
clear(sessionID) {
|
||
|
|
this.pending.delete(sessionID)
|
||
|
|
this.processed.delete(sessionID)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Singleton instance
|
||
|
|
export const contextCollector = new ContextCollector()
|
||
|
|
|
||
|
|
// Export debug log for plugins
|
||
|
|
export { debugLog }
|