Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/hooks/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

/**
Expand Down
240 changes: 240 additions & 0 deletions src/hooks/skill-detector.ts
Original file line number Diff line number Diff line change
@@ -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<string, Observation[]>()
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<string>()
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
}
27 changes: 2 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
54 changes: 46 additions & 8 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,14 +40,39 @@ function formatContextForSystem(context: InjectedContext): string {
return lines.join("\n")
}

/**
* OpenCode Brain Plugin
*/
function formatSkillCandidatesForSystem(candidates: SkillCandidate[]): string {
const lines = ["<openception-skill-extraction>"]
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("</openception-skill-extraction>")

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<ReturnType<typeof getMind>> | null = null
Expand Down Expand Up @@ -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<typeof handleSessionEnd>[2])
sessionSummaryGenerated = true
resetMind()
Expand All @@ -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")
Expand All @@ -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
}
Expand Down
Loading