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
75 changes: 64 additions & 11 deletions .opencode/plugins/xpowers-context-gauge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ const loadConfig = async (directory: string): Promise<Required<ContextGaugeConfi
try {
const raw = await readFile(configPath, "utf8")
const parsed = JSON.parse(raw) as ContextGaugeConfig
return { ...DEFAULT_CONFIG, ...parsed }
return {
...DEFAULT_CONFIG,
...parsed,
modelLimits: {
...DEFAULT_MODEL_LIMITS,
...(parsed.modelLimits ?? {}),
},
}
} catch {
return { ...DEFAULT_CONFIG }
}
Expand Down Expand Up @@ -142,25 +149,43 @@ const resolveModelLimit = (
): number => {
if (!modelId) return defaultLimit

const normalized = modelId.toLowerCase().replace(/[^a-z0-9.-]/g, "")
// Strip provider prefix (e.g., "openai/gpt-4o" → "gpt-4o")
const withoutProvider = modelId.includes("/")
? modelId.split("/").pop() ?? modelId
: modelId

const normalized = withoutProvider.toLowerCase().replace(/[^a-z0-9.-]/g, "")

// Try exact match first
if (limits[withoutProvider]) return limits[withoutProvider]
if (limits[modelId]) return limits[modelId]

// Try normalized match
// Try normalized exact match
if (limits[normalized]) return limits[normalized]

// Try partial match on model family
// Try partial match: prefer longest/specific match first to avoid
// "gpt-4" matching before "gpt-4o" or "gpt-4.1"
const candidates: { key: string; limit: number; len: number }[] = []
for (const [key, limit] of Object.entries(limits)) {
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9.-]/g, "")
if (!normalizedKey || normalizedKey === "default") continue

// Key must be a prefix of the model ID, followed by a separator or end
if (
normalized.includes(normalizedKey) ||
normalizedKey.includes(normalized)
normalized === normalizedKey ||
normalized.startsWith(normalizedKey + "-") ||
normalized.startsWith(normalizedKey + ".")
) {
return limit
candidates.push({ key, limit, len: normalizedKey.length })
}
}

// Prefer longest match (most specific)
if (candidates.length > 0) {
candidates.sort((a, b) => b.len - a.len)
return candidates[0].limit
}

return defaultLimit
}

Expand All @@ -173,6 +198,8 @@ type GaugeState = {
modelId: string | null
contextLimit: number
compactSuggested: boolean
createdAt: number
messageContents: Map<string, string> // messageId -> previous content for delta counting
}

const sessions = new Map<string, GaugeState>()
Expand All @@ -187,12 +214,23 @@ const getState = (sessionId: string): GaugeState => {
modelId: null,
contextLimit: DEFAULT_CONFIG.defaultLimit,
compactSuggested: false,
createdAt: Date.now(),
messageContents: new Map(),
}
sessions.set(sessionId, state)
}
return state
}

const cleanupOldGaugeSessions = (ttlMs: number = 86400000) => {
const cutoff = Date.now() - ttlMs
for (const [id, s] of sessions) {
if (s.createdAt < cutoff) {
sessions.delete(id)
}
}
}

// ── Plugin ──────────────────────────────────────────────────────────────────

const xpowersContextGaugePlugin: Plugin = async (ctx) => {
Expand All @@ -218,6 +256,8 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => {
modelId: null,
contextLimit: config.defaultLimit,
compactSuggested: false,
createdAt: Date.now(),
messageContents: new Map(),
})
return
}
Expand All @@ -241,6 +281,8 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => {

if (event.type === "session.deleted" && sessionId) {
sessions.delete(sessionId)
// Cleanup orphaned sessions older than 24 hours
cleanupOldGaugeSessions()
return
}

Expand Down Expand Up @@ -277,9 +319,20 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => {
.join(" ")
}

const addedTokens = estimateTokens(content)
state.estimatedTokens += addedTokens
state.messageCount += 1
// Incremental token counting: only count the delta on streaming updates
const messageId = message.id ?? (event as any).properties?.messageId ?? ""
const prevContent = messageId ? state.messageContents.get(String(messageId)) : undefined
const prevTokens = prevContent !== undefined ? estimateTokens(prevContent) : 0
const newTokens = estimateTokens(content)
const deltaTokens = Math.max(0, newTokens - prevTokens)

state.estimatedTokens += deltaTokens
if (prevContent === undefined) {
state.messageCount += 1 // only count new messages, not streaming updates
}
if (messageId) {
state.messageContents.set(String(messageId), content)
}

// Try to detect model from message metadata
const detectedModel =
Expand Down Expand Up @@ -331,7 +384,7 @@ const xpowersContextGaugePlugin: Plugin = async (ctx) => {
8000,
)

if (!state.compactSuggested && config.suggestCompactAt <= config.dangerThreshold) {
if (!state.compactSuggested && usage >= config.suggestCompactAt) {
state.compactSuggested = true

// Inject suggestion into session
Expand Down
88 changes: 74 additions & 14 deletions .opencode/plugins/xpowers-git-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type SessionState = {
filesCommitted: Set<string>
commitMade: boolean
warnedOnIdle: boolean
createdAt: number
pendingCommitStagedFiles: string[] // staged files captured before git commit
}

const DEFAULT_CONFIG: Required<GitGuardConfig> = {
Expand Down Expand Up @@ -126,8 +128,19 @@ const getGitStatus = async ($: any, cwd: string): Promise<GitStatus> => {
}

// Status line: XY filename or XY "filename with spaces"
// Renames: XY "old" -> "new" (R status in index or worktree)
const statusCode = line.slice(0, 2)
const filePath = line.slice(3).replace(/^"(.*)"$/, "$1")
let filePath = line.slice(3)

// Handle rename entries: R "old/path" -> "new/path"
if (statusCode[0] === "R" || statusCode[1] === "R") {
const renameMatch = line.match(/^\S{2}\s+(.+?)\s+->\s+(.+)$/)
if (renameMatch) {
filePath = renameMatch[2].replace(/^"(.*)"$/, "$1")
}
} else {
filePath = filePath.replace(/^"(.*)"$/, "$1")
}

if (!filePath) continue

Expand All @@ -150,6 +163,12 @@ const getGitStatus = async ($: any, cwd: string): Promise<GitStatus> => {
if (worktreeStatus === "?") {
status.untrackedFiles.push(filePath)
}
// Renames count as modified
if (indexStatus === "R" || worktreeStatus === "R") {
if (!status.modifiedFiles.includes(filePath)) {
status.modifiedFiles.push(filePath)
}
}
}

return status
Expand All @@ -163,12 +182,15 @@ const getGitDiffStat = async ($: any, cwd: string): Promise<{ files: number; ins
const lastLine = output.split("\n").filter((l) => l.trim()).pop() ?? ""

// Parse: " 5 files changed, 23 insertions(+), 10 deletions(-)"
const match = lastLine.match(/(\d+)\s+files?\s+changed.*?(\d+)\s+insertions?.*?(\d+)\s+deletions?/)
if (match) {
// Note: insertions or deletions may be absent in partial output
const filesMatch = lastLine.match(/(\d+)\s+files?\s+changed/)
if (filesMatch) {
const insertionsMatch = lastLine.match(/(\d+)\s+insertions?/)
const deletionsMatch = lastLine.match(/(\d+)\s+deletions?/)
return {
files: parseInt(match[1], 10),
insertions: parseInt(match[2], 10),
deletions: parseInt(match[3], 10),
files: parseInt(filesMatch[1], 10),
insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0,
}
}

Expand All @@ -180,9 +202,19 @@ const hasUncommittedChanges = async ($: any, cwd: string): Promise<boolean> => {
return status.hasChanges
}

const autoCommit = async ($: any, cwd: string, message: string): Promise<{ ok: boolean; error?: string }> => {
const autoCommit = async (
$: any,
cwd: string,
message: string,
filesToCommit: string[],
): Promise<{ ok: boolean; error?: string }> => {
if (filesToCommit.length === 0) {
return { ok: true }
}

try {
const addResult = await $`git -C ${cwd} add -A`.quiet().nothrow()
// Only stage files that were modified during this session, not everything
const addResult = await $`git -C ${cwd} add ${filesToCommit}`.quiet().nothrow()
if (addResult.exitCode !== 0) {
return { ok: false, error: "git add failed" }
}
Expand Down Expand Up @@ -232,12 +264,21 @@ const sessions = new Map<string, SessionState>()
const getSessionState = (sessionId: string): SessionState => {
let state = sessions.get(sessionId)
if (!state) {
state = { filesModified: new Set(), filesCommitted: new Set(), commitMade: false, warnedOnIdle: false }
state = { filesModified: new Set(), filesCommitted: new Set(), commitMade: false, warnedOnIdle: false, createdAt: Date.now(), pendingCommitStagedFiles: [] }
sessions.set(sessionId, state)
}
return state
}

const cleanupOldGitGuardSessions = (ttlMs: number = 86400000) => {
const cutoff = Date.now() - ttlMs
for (const [id, s] of sessions) {
if (s.createdAt < cutoff) {
sessions.delete(id)
}
}
}

// ── Plugin ──────────────────────────────────────────────────────────────────

const xpowersGitGuardPlugin: Plugin = async (ctx) => {
Expand All @@ -248,6 +289,20 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => {
}

return {
// ── Pre-commit: capture staged files before git commit runs ───────────
"tool.execute.before": async (input, output) => {
if (input.tool !== "bash") return
const command = String((output.args as any)?.command ?? "")
if (!/git\s+commit/.test(command)) return

const sessionId = (input as any).sessionID ?? "unknown"
const state = getSessionState(sessionId)

// Capture staged files BEFORE the commit executes
const status = await getGitStatus(ctx.$, ctx.directory)
state.pendingCommitStagedFiles = [...status.stagedFiles]
},

// ── Track file modifications and git commits ──────────────────────────
"tool.execute.after": async (input, output) => {
const sessionId = (input as any).sessionID ?? "unknown"
Expand Down Expand Up @@ -280,12 +335,12 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => {
if (/git\s+commit/.test(command)) {
state.commitMade = true

// Try to extract committed files from the command or check git status
const status = await getGitStatus(ctx.$, ctx.directory)
for (const file of status.stagedFiles) {
// Use staged files captured BEFORE the commit (post-commit staged list is empty)
for (const file of state.pendingCommitStagedFiles) {
state.filesCommitted.add(file)
state.filesModified.delete(file)
}
Comment on lines +335 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify git commit success before clearing modified files

This branch marks state.commitMade = true, removes every pre-staged file from filesModified, and shows a success toast purely from matching git commit in the command string, without checking whether the commit actually succeeded. If git commit exits non-zero (e.g., pre-commit failure, empty commit, merge conflict), the plugin will treat files as committed and suppress later uncommitted-change warnings/blocking for those files, which defeats the guard in real workflows.

Useful? React with 👍 / 👎.

state.pendingCommitStagedFiles = []

await showToast(
ctx.client,
Expand All @@ -310,15 +365,18 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => {
filesCommitted: new Set(),
commitMade: false,
warnedOnIdle: false,
createdAt: Date.now(),
pendingCommitStagedFiles: [],
})
return
}

if (event.type === "session.deleted" && sessionId) {
const state = sessions.get(sessionId)
if (state && config.autoCommitOnSessionEnd && state.filesModified.size > 0 && !state.commitMade) {
// Auto-commit on session end
const result = await autoCommit(ctx.$, ctx.directory, config.autoCommitMessage)
// Auto-commit on session end — only commit files modified during this session
const filesToCommit = Array.from(state.filesModified)
const result = await autoCommit(ctx.$, ctx.directory, config.autoCommitMessage, filesToCommit)
if (result.ok) {
await showToast(
ctx.client,
Expand All @@ -338,6 +396,8 @@ const xpowersGitGuardPlugin: Plugin = async (ctx) => {
}
}
sessions.delete(sessionId)
// Cleanup orphaned sessions older than 24 hours
cleanupOldGitGuardSessions()
return
}

Expand Down
Loading
Loading