From 922cfe4a242076238d0227841b68978de93b38af Mon Sep 17 00:00:00 2001 From: Benjamin Hodgens Date: Tue, 17 Feb 2026 12:43:53 -0700 Subject: [PATCH 1/2] fix: export only default for opencode plugin loader compatibility --- src/index.ts | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5928f76..46fefb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,28 +5,5 @@ * Compatible with claude-brain - shares the same .claude/mind.mv2 file. */ -// Export plugin -export { OpenCodeBrain, default } from "./plugin.js" - -// Export types -export type { - Observation, - ObservationType, - ObservationMetadata, - MindConfig, - MindStats, - MemorySearchResult, - InjectedContext, - SessionSummary, - RememberInput, -} from "./types.js" - -export { DEFAULT_CONFIG } from "./types.js" - -// Export core -export { Mind, getMind, resetMind } from "./core/mind.js" - -// Export utils -export { formatTimestamp, generateId, estimateTokens, classifyObservationType } from "./utils/helpers.js" -export { compressToolOutput, getCompressionStats } from "./utils/compression.js" -export { withMindLock } from "./utils/lock.js" +// Plugin default export — must be the ONLY export for opencode plugin loader compatibility +export { default } from "./plugin.js" From b851b9d659dc9ed0e017f64ff887074299118606 Mon Sep 17 00:00:00 2001 From: Benjamin Hodgens Date: Tue, 17 Feb 2026 12:58:45 -0700 Subject: [PATCH 2/2] feat: auto skill detection and system prompt injection for claudeception --- src/hooks/session-end.ts | 8 ++ src/hooks/skill-detector.ts | 240 ++++++++++++++++++++++++++++++++++++ src/plugin.ts | 54 ++++++-- src/utils/skill-staging.ts | 89 +++++++++++++ 4 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 src/hooks/skill-detector.ts create mode 100644 src/utils/skill-staging.ts diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index b546561..c8dd6a3 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -6,6 +6,8 @@ import type { Mind } from "../core/mind.js" import { debug } from "../utils/helpers.js" +import { detectSkillCandidates } from "./skill-detector.js" +import { savePendingCandidates } from "../utils/skill-staging.js" // BunShell type from OpenCode plugin context interface BunShell { @@ -52,6 +54,12 @@ export async function handleSessionEnd( await mind.saveSessionSummary(summary) debug(`Session summary saved: ${summary.keyDecisions.length} decisions, ${summary.filesModified.length} files`) } + + // Detect skill-worthy patterns and stage for next session + const candidates = detectSkillCandidates(sessionObs, mind.getSessionId()) + if (candidates.length > 0) { + await savePendingCandidates(directory, candidates) + } } /** diff --git a/src/hooks/skill-detector.ts b/src/hooks/skill-detector.ts new file mode 100644 index 0000000..b7363b8 --- /dev/null +++ b/src/hooks/skill-detector.ts @@ -0,0 +1,240 @@ +import type { Observation } from "../types.js" +import { debug } from "../utils/helpers.js" + +export interface SkillCandidate { + id: string + title: string + reason: string + confidence: "high" | "medium" + evidence: Array<{ type: string; summary: string; content: string }> + detectedAt: number + sessionId: string +} + +interface ObsWindow { + problems: Observation[] + solutions: Observation[] + errors: Observation[] + fixes: Observation[] + discoveries: Observation[] + decisions: Observation[] + patterns: Observation[] + warnings: Observation[] +} + +const ERROR_SIGNALS = [ + "error", "failed", "exception", "crash", "broken", + "typeerror", "referenceerror", "syntaxerror", + "enoent", "eacces", "timeout", "rejected", +] + +const FIX_SIGNALS = [ + "fix", "fixed", "resolved", "workaround", "solved", + "the issue was", "root cause", "the problem was", + "changed to", "switched to", "replaced with", +] + +const NON_OBVIOUS_SIGNALS = [ + "actually", "turns out", "not obvious", "misleading", + "the real", "root cause", "unexpected", "surprisingly", + "counterintuitively", "despite", "even though", + "the trick", "key insight", "important to note", +] + +function classify(observations: Observation[]): ObsWindow { + const win: ObsWindow = { + problems: [], solutions: [], errors: [], fixes: [], + discoveries: [], decisions: [], patterns: [], warnings: [], + } + + for (const obs of observations) { + const lower = (obs.summary + " " + obs.content).toLowerCase() + + if (obs.type === "problem" || ERROR_SIGNALS.some(s => lower.includes(s))) { + win.problems.push(obs) + if (ERROR_SIGNALS.some(s => lower.includes(s))) win.errors.push(obs) + } + + if (obs.type === "solution" || obs.type === "bugfix" || obs.type === "success") { + win.solutions.push(obs) + if (FIX_SIGNALS.some(s => lower.includes(s))) win.fixes.push(obs) + } + + if (obs.type === "discovery") win.discoveries.push(obs) + if (obs.type === "decision") win.decisions.push(obs) + if (obs.type === "pattern") win.patterns.push(obs) + if (obs.type === "warning") win.warnings.push(obs) + } + + return win +} + +function hasNonObviousSignals(obs: Observation): boolean { + const lower = (obs.summary + " " + obs.content).toLowerCase() + return NON_OBVIOUS_SIGNALS.some(s => lower.includes(s)) +} + +function extractTitle(problem: Observation, solution: Observation): string { + const probSummary = problem.summary.replace(/^\[.*?\]\s*/, "").slice(0, 60) + const solSummary = solution.summary.replace(/^\[.*?\]\s*/, "").slice(0, 60) + + if (solSummary.toLowerCase().startsWith("fix")) return solSummary + if (probSummary.length > 10) return `Fix: ${probSummary}` + return solSummary || probSummary || "Unnamed skill candidate" +} + +function relatedByFile(a: Observation, b: Observation): boolean { + const filesA = a.metadata?.files as string[] | undefined + const filesB = b.metadata?.files as string[] | undefined + if (!filesA?.length || !filesB?.length) return false + return filesA.some(f => filesB.includes(f)) +} + +function relatedByContent(a: Observation, b: Observation): boolean { + const textA = (a.summary + " " + a.content).toLowerCase() + const textB = (b.summary + " " + b.content).toLowerCase() + + const wordsA = new Set(textA.split(/\s+/).filter(w => w.length > 4)) + const wordsB = new Set(textB.split(/\s+/).filter(w => w.length > 4)) + + let overlap = 0 + for (const w of wordsA) { + if (wordsB.has(w)) overlap++ + } + + return overlap >= 3 +} + +function toEvidence(obs: Observation): { type: string; summary: string; content: string } { + return { + type: obs.type, + summary: obs.summary.slice(0, 200), + content: obs.content.slice(0, 500), + } +} + +/** + * Detect skill-worthy patterns in a set of session observations. + * + * Returns candidates ordered by confidence. + */ +export function detectSkillCandidates( + observations: Observation[], + sessionId: string, +): SkillCandidate[] { + if (observations.length < 3) return [] + + const win = classify(observations) + const candidates: SkillCandidate[] = [] + const now = Date.now() + let idCounter = 0 + + const nextId = () => `sc_${sessionId.slice(0, 8)}_${idCounter++}` + + // Pattern 1 — Problem → Solution pair (high confidence) + for (const problem of win.problems) { + for (const solution of win.solutions) { + if ((solution.timestamp ?? 0) <= (problem.timestamp ?? 0)) continue + if (!relatedByFile(problem, solution) && !relatedByContent(problem, solution)) continue + + const isNonObvious = hasNonObviousSignals(solution) || hasNonObviousSignals(problem) + candidates.push({ + id: nextId(), + title: extractTitle(problem, solution), + reason: isNonObvious + ? "Non-obvious problem→solution pair with investigation" + : "Problem→solution pair detected", + confidence: isNonObvious ? "high" : "medium", + evidence: [toEvidence(problem), toEvidence(solution)], + detectedAt: now, + sessionId, + }) + break // one match per problem is enough + } + } + + // Pattern 2 — Error message → Fix (high confidence) + for (const err of win.errors) { + for (const fix of win.fixes) { + if ((fix.timestamp ?? 0) <= (err.timestamp ?? 0)) continue + // Skip if already captured by pattern 1 + if (candidates.some(c => + c.evidence.some(e => e.summary === err.summary.slice(0, 200)) && + c.evidence.some(e => e.summary === fix.summary.slice(0, 200)) + )) continue + + candidates.push({ + id: nextId(), + title: extractTitle(err, fix), + reason: "Error with specific fix found", + confidence: "high", + evidence: [toEvidence(err), toEvidence(fix)], + detectedAt: now, + sessionId, + }) + break + } + } + + // Pattern 3 — Deep investigation (5+ observations touching same files) + const fileGroups = new Map() + for (const obs of observations) { + const files = obs.metadata?.files as string[] | undefined + if (!files) continue + for (const f of files) { + const group = fileGroups.get(f) ?? [] + group.push(obs) + fileGroups.set(f, group) + } + } + for (const [file, group] of fileGroups) { + if (group.length < 5) continue + const hasProblems = group.some(o => o.type === "problem") + const hasFixes = group.some(o => o.type === "solution" || o.type === "bugfix") + if (!hasProblems || !hasFixes) continue + + const fileName = file.split("/").pop() ?? file + candidates.push({ + id: nextId(), + title: `Investigation: ${fileName}`, + reason: `Deep investigation — ${group.length} observations on ${fileName}`, + confidence: "medium", + evidence: group.slice(0, 4).map(toEvidence), + detectedAt: now, + sessionId, + }) + } + + // Pattern 4 — Non-obvious discoveries or warnings + const nonObvious = [...win.discoveries, ...win.warnings, ...win.patterns] + .filter(hasNonObviousSignals) + if (nonObvious.length >= 2) { + candidates.push({ + id: nextId(), + title: nonObvious[0].summary.replace(/^\[.*?\]\s*/, "").slice(0, 80), + reason: `${nonObvious.length} non-obvious discoveries/warnings in session`, + confidence: "medium", + evidence: nonObvious.slice(0, 4).map(toEvidence), + detectedAt: now, + sessionId, + }) + } + + // Deduplicate by title similarity + const seen = new Set() + const deduped = candidates.filter(c => { + const key = c.title.toLowerCase().replace(/\s+/g, " ").trim() + if (seen.has(key)) return false + seen.add(key) + return true + }) + + // Sort: high confidence first, then by evidence count + deduped.sort((a, b) => { + if (a.confidence !== b.confidence) return a.confidence === "high" ? -1 : 1 + return b.evidence.length - a.evidence.length + }) + + debug(`Skill detector: ${deduped.length} candidate(s) from ${observations.length} observations`) + return deduped.slice(0, 5) // cap at 5 candidates per session +} diff --git a/src/plugin.ts b/src/plugin.ts index cc7b31c..64eaf86 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -16,6 +16,8 @@ import { writeSessionInfo, detectSource } from "./utils/session.js" import { generateId } from "./utils/helpers.js" import type { InjectedContext, Observation } from "./types.js" import { debug } from "./utils/helpers.js" +import { loadPendingCandidates, markInjected, clearPendingCandidates } from "./utils/skill-staging.js" +import type { SkillCandidate } from "./hooks/skill-detector.js" /** * Format context for system prompt injection @@ -38,14 +40,39 @@ function formatContextForSystem(context: InjectedContext): string { return lines.join("\n") } -/** - * OpenCode Brain Plugin - */ +function formatSkillCandidatesForSystem(candidates: SkillCandidate[]): string { + const lines = [""] + lines.push("Your previous session produced knowledge worth extracting as reusable skills.") + lines.push("") + lines.push(`${candidates.length} candidate(s) detected:`) + lines.push("") + + for (const c of candidates) { + lines.push(`### ${c.title} [${c.confidence}]`) + lines.push(`Reason: ${c.reason}`) + lines.push("Evidence:") + for (const e of c.evidence) { + lines.push(` - [${e.type}] ${e.summary}`) + } + lines.push("") + } + + lines.push("ACTION REQUIRED: Evaluate these candidates for skill extraction.") + lines.push("If the claudeception skill is loaded, use it. Otherwise:") + lines.push("1. For each high-confidence candidate, create a SKILL.md at ~/.claude/skills/[name]/SKILL.md") + lines.push("2. For medium-confidence, mention them to the user and ask if extraction is wanted") + lines.push("3. Skip candidates whose knowledge is trivial or already exists as a skill") + lines.push("After extraction (or dismissal), tell the user what was extracted and why.") + lines.push("") + + return lines.join("\n") +} + export const OpenCodeBrain: Plugin = async (ctx) => { const { directory, $ } = ctx - - // Session state tracking + let sessionSummaryGenerated = false + let skillCandidatesInjected = false // Initialize mind lazily let mind: Awaited> | null = null @@ -92,10 +119,11 @@ export const OpenCodeBrain: Plugin = async (ctx) => { } } - // Session end - generate summary if (event.type === "session.idle" && !sessionSummaryGenerated) { try { - // Use the Bun shell from context + if (skillCandidatesInjected) { + await clearPendingCandidates(directory) + } await handleSessionEnd(directory, ensureMind, $ as Parameters[2]) sessionSummaryGenerated = true resetMind() @@ -114,7 +142,6 @@ export const OpenCodeBrain: Plugin = async (ctx) => { } }, - // Inject context into system prompt "experimental.chat.system.transform": async (_input, output) => { try { const memoryPath = resolve(directory, ".claude/mind.mv2") @@ -126,6 +153,17 @@ export const OpenCodeBrain: Plugin = async (ctx) => { if (context.recentObservations.length > 0) { output.system.push(formatContextForSystem(context)) } + + // Inject pending skill candidates once per session + if (!skillCandidatesInjected) { + skillCandidatesInjected = true + const staged = await loadPendingCandidates(directory) + if (staged?.candidates?.length && !staged.injectedAt) { + output.system.push(formatSkillCandidatesForSystem(staged.candidates)) + await markInjected(directory) + debug(`Injected ${staged.candidates.length} skill candidate(s) into system prompt`) + } + } } catch { // Don't block chat on memory errors } diff --git a/src/utils/skill-staging.ts b/src/utils/skill-staging.ts new file mode 100644 index 0000000..3956ba2 --- /dev/null +++ b/src/utils/skill-staging.ts @@ -0,0 +1,89 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises" +import { existsSync } from "node:fs" +import { resolve, dirname } from "node:path" +import { debug } from "./helpers.js" +import type { SkillCandidate } from "../hooks/skill-detector.js" + +const STAGING_FILE = ".claude/mind-skills-pending.json" + +export interface StagedCandidates { + candidates: SkillCandidate[] + injectedAt?: number +} + +function stagingPath(directory: string): string { + return resolve(directory, STAGING_FILE) +} + +export async function loadPendingCandidates(directory: string): Promise { + const path = stagingPath(directory) + if (!existsSync(path)) return null + + try { + const raw = await readFile(path, "utf8") + return JSON.parse(raw) as StagedCandidates + } catch { + debug("Failed to read skill staging file") + return null + } +} + +export async function savePendingCandidates( + directory: string, + candidates: SkillCandidate[], +): Promise { + const path = stagingPath(directory) + await mkdir(dirname(path), { recursive: true }) + + const existing = await loadPendingCandidates(directory) + const merged = mergeWithExisting(existing, candidates) + + const staged: StagedCandidates = { candidates: merged } + await writeFile(path, JSON.stringify(staged, null, 2)) + debug(`Staged ${candidates.length} skill candidate(s) (${merged.length} total pending)`) +} + +export async function markInjected(directory: string): Promise { + const path = stagingPath(directory) + const staged = await loadPendingCandidates(directory) + if (!staged) return + + staged.injectedAt = Date.now() + await writeFile(path, JSON.stringify(staged, null, 2)) +} + +export async function clearPendingCandidates(directory: string): Promise { + const path = stagingPath(directory) + if (!existsSync(path)) return + + try { + const { unlink } = await import("node:fs/promises") + await unlink(path) + debug("Cleared skill staging file") + } catch { + debug("Failed to clear skill staging file") + } +} + +function mergeWithExisting( + existing: StagedCandidates | null, + incoming: SkillCandidate[], +): SkillCandidate[] { + if (!existing?.candidates?.length) return incoming + + // Keep un-injected existing candidates, add new ones, cap at 10 + const uninjected = existing.injectedAt + ? [] // already injected → start fresh + : existing.candidates + + const combined = [...uninjected, ...incoming] + + // Deduplicate by title + const seen = new Set() + return combined.filter(c => { + const key = c.title.toLowerCase().trim() + if (seen.has(key)) return false + seen.add(key) + return true + }).slice(0, 10) +}