Skip to content

Commit 4800cf7

Browse files
committed
feat: unified notification system with GC tracking
- Add GCStats type and gcPending state to track deduplication activity - Accumulate GC stats during fetch when runStrategies prunes duplicates - Rewrite notification system to combine AI analysis and GC in one display - Show GC-only notifications when AI prunes nothing but deduplication occurred - Track totalGCTokens in session stats for lifetime GC contribution - Display format: '🧹 DCP: ~20K saved (10 tools, ♻️ ~500) │ Session: ...' - GC-only format: '♻️ DCP: ~500 collected │ Session: ...'
1 parent 87fe1c4 commit 4800cf7

File tree

5 files changed

+251
-122
lines changed

5 files changed

+251
-122
lines changed

lib/core/janitor.ts

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ import { estimateTokensBatch, formatTokenCount } from "../tokenizer"
88
import { saveSessionState } from "../state/persistence"
99
import { ensureSessionRestored } from "../state"
1010
import {
11-
sendPruningSummary,
11+
sendUnifiedNotification,
1212
type NotificationContext
1313
} from "../ui/notification"
1414

15-
// ============================================================================
16-
// Types
17-
// ============================================================================
18-
1915
export interface SessionStats {
2016
totalToolsPruned: number
2117
totalTokensSaved: number
18+
totalGCTokens: number
19+
}
20+
21+
export interface GCStats {
22+
tokensCollected: number
23+
toolsDeduped: number
2224
}
2325

2426
export interface PruningResult {
@@ -136,7 +138,11 @@ async function runWithStrategies(
136138
const alreadyPrunedIds = state.prunedIds.get(sessionID) ?? []
137139
const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id))
138140

139-
if (unprunedToolCallIds.length === 0) {
141+
// Get pending GC stats (accumulated since last notification)
142+
const gcPending = state.gcPending.get(sessionID) ?? null
143+
144+
// If nothing to analyze and no GC activity, exit early
145+
if (unprunedToolCallIds.length === 0 && !gcPending) {
140146
return null
141147
}
142148

@@ -148,7 +154,7 @@ async function runWithStrategies(
148154
// PHASE 1: LLM ANALYSIS
149155
let llmPrunedIds: string[] = []
150156

151-
if (strategies.includes('ai-analysis')) {
157+
if (strategies.includes('ai-analysis') && unprunedToolCallIds.length > 0) {
152158
llmPrunedIds = await runLlmAnalysis(
153159
ctx,
154160
sessionID,
@@ -161,33 +167,62 @@ async function runWithStrategies(
161167
)
162168
}
163169

164-
if (llmPrunedIds.length === 0) {
170+
const finalNewlyPrunedIds = llmPrunedIds.filter(id => !alreadyPrunedIds.includes(id))
171+
172+
// If AI pruned nothing and no GC activity, nothing to report
173+
if (finalNewlyPrunedIds.length === 0 && !gcPending) {
165174
return null
166175
}
167176

168-
const finalNewlyPrunedIds = llmPrunedIds.filter(id => !alreadyPrunedIds.includes(id))
169-
170177
// PHASE 2: CALCULATE STATS & NOTIFICATION
171178
const tokensSaved = await calculateTokensSaved(finalNewlyPrunedIds, toolOutputs)
172179

173-
const currentStats = state.stats.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 }
180+
// Get current session stats, initializing with proper defaults
181+
const currentStats = state.stats.get(sessionID) ?? {
182+
totalToolsPruned: 0,
183+
totalTokensSaved: 0,
184+
totalGCTokens: 0
185+
}
186+
187+
// Update session stats including GC contribution
174188
const sessionStats: SessionStats = {
175189
totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length,
176-
totalTokensSaved: currentStats.totalTokensSaved + tokensSaved
190+
totalTokensSaved: currentStats.totalTokensSaved + tokensSaved,
191+
totalGCTokens: currentStats.totalGCTokens + (gcPending?.tokensCollected ?? 0)
177192
}
178193
state.stats.set(sessionID, sessionStats)
179194

180-
await sendPruningSummary(
195+
// Send unified notification (handles all scenarios)
196+
const notificationSent = await sendUnifiedNotification(
181197
ctx.notificationCtx,
182198
sessionID,
183-
llmPrunedIds,
184-
toolMetadata,
185-
tokensSaved,
186-
sessionStats,
199+
{
200+
aiPrunedCount: llmPrunedIds.length,
201+
aiTokensSaved: tokensSaved,
202+
aiPrunedIds: llmPrunedIds,
203+
toolMetadata,
204+
gcPending,
205+
sessionStats
206+
},
187207
currentAgent
188208
)
189209

190-
// PHASE 3: STATE UPDATE
210+
// Clear pending GC stats after notification (whether sent or not - we've consumed them)
211+
if (gcPending) {
212+
state.gcPending.delete(sessionID)
213+
}
214+
215+
// If we only had GC activity (no AI pruning), return null but notification was sent
216+
if (finalNewlyPrunedIds.length === 0) {
217+
if (notificationSent) {
218+
logger.info("janitor", `GC-only notification: ~${formatTokenCount(gcPending?.tokensCollected ?? 0)} tokens from ${gcPending?.toolsDeduped ?? 0} deduped tools`, {
219+
trigger: options.trigger
220+
})
221+
}
222+
return null
223+
}
224+
225+
// PHASE 3: STATE UPDATE (only if AI pruned something)
191226
const allPrunedIds = [...new Set([...alreadyPrunedIds, ...llmPrunedIds])]
192227
state.prunedIds.set(sessionID, allPrunedIds)
193228

@@ -203,6 +238,10 @@ async function runWithStrategies(
203238
if (options.reason) {
204239
logMeta.reason = options.reason
205240
}
241+
if (gcPending) {
242+
logMeta.gcTokens = gcPending.tokensCollected
243+
logMeta.gcTools = gcPending.toolsDeduped
244+
}
206245

207246
logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta)
208247

lib/fetch-wrapper/gc-tracker.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { PluginState } from "../state"
2+
import type { Logger } from "../logger"
3+
4+
export function accumulateGCStats(
5+
state: PluginState,
6+
sessionId: string,
7+
prunedIds: string[],
8+
body: any,
9+
logger: Logger
10+
): void {
11+
if (prunedIds.length === 0) return
12+
13+
const toolOutputs = extractToolOutputsFromBody(body, prunedIds)
14+
const tokensCollected = estimateTokensFromOutputs(toolOutputs)
15+
16+
const existing = state.gcPending.get(sessionId) ?? { tokensCollected: 0, toolsDeduped: 0 }
17+
18+
state.gcPending.set(sessionId, {
19+
tokensCollected: existing.tokensCollected + tokensCollected,
20+
toolsDeduped: existing.toolsDeduped + prunedIds.length
21+
})
22+
23+
logger.debug("gc-tracker", "Accumulated GC stats", {
24+
sessionId: sessionId.substring(0, 8),
25+
newlyDeduped: prunedIds.length,
26+
tokensThisCycle: tokensCollected,
27+
pendingTotal: state.gcPending.get(sessionId)
28+
})
29+
}
30+
31+
function extractToolOutputsFromBody(body: any, prunedIds: string[]): string[] {
32+
const outputs: string[] = []
33+
const prunedIdSet = new Set(prunedIds.map(id => id.toLowerCase()))
34+
35+
// OpenAI Chat format
36+
if (body.messages && Array.isArray(body.messages)) {
37+
for (const m of body.messages) {
38+
if (m.role === 'tool' && m.tool_call_id && prunedIdSet.has(m.tool_call_id.toLowerCase())) {
39+
if (typeof m.content === 'string') {
40+
outputs.push(m.content)
41+
}
42+
}
43+
// Anthropic format
44+
if (m.role === 'user' && Array.isArray(m.content)) {
45+
for (const part of m.content) {
46+
if (part.type === 'tool_result' && part.tool_use_id && prunedIdSet.has(part.tool_use_id.toLowerCase())) {
47+
if (typeof part.content === 'string') {
48+
outputs.push(part.content)
49+
}
50+
}
51+
}
52+
}
53+
}
54+
}
55+
56+
// OpenAI Responses format
57+
if (body.input && Array.isArray(body.input)) {
58+
for (const item of body.input) {
59+
if (item.type === 'function_call_output' && item.call_id && prunedIdSet.has(item.call_id.toLowerCase())) {
60+
if (typeof item.output === 'string') {
61+
outputs.push(item.output)
62+
}
63+
}
64+
}
65+
}
66+
67+
return outputs
68+
}
69+
70+
// Character-based approximation (chars / 4) to avoid async tokenizer in fetch path
71+
function estimateTokensFromOutputs(outputs: string[]): number {
72+
let totalChars = 0
73+
for (const output of outputs) {
74+
totalChars += output.length
75+
}
76+
return Math.round(totalChars / 4)
77+
}

lib/fetch-wrapper/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { handleOpenAIChatAndAnthropic } from "./openai-chat"
77
import { handleGemini } from "./gemini"
88
import { handleOpenAIResponses } from "./openai-responses"
99
import { runStrategies } from "../core/strategies"
10+
import { accumulateGCStats } from "./gc-tracker"
1011

1112
export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types"
1213

@@ -96,6 +97,9 @@ export function installFetchWrapper(
9697
// Normalize to lowercase to match janitor's ID normalization
9798
const normalizedIds = result.prunedIds.map(id => id.toLowerCase())
9899
state.prunedIds.set(sessionId, [...new Set([...alreadyPruned, ...normalizedIds])])
100+
101+
// Track GC activity for the next notification
102+
accumulateGCStats(state, sessionId, result.prunedIds, body, logger)
99103
}
100104
}
101105
}

lib/state/index.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,17 @@
1-
import type { SessionStats } from "../core/janitor"
1+
import type { SessionStats, GCStats } from "../core/janitor"
22
import type { Logger } from "../logger"
33
import { loadSessionState } from "./persistence"
44

5-
/**
6-
* Centralized state management for the DCP plugin.
7-
* All mutable state is stored here and shared across modules.
8-
*/
95
export interface PluginState {
10-
/** Map of session IDs to arrays of pruned tool call IDs */
116
prunedIds: Map<string, string[]>
12-
/** Map of session IDs to session statistics */
137
stats: Map<string, SessionStats>
14-
/** Cache of tool call IDs to their parameters */
8+
gcPending: Map<string, GCStats>
159
toolParameters: Map<string, ToolParameterEntry>
16-
/** Cache of session IDs to their model info */
1710
model: Map<string, ModelInfo>
18-
/**
19-
* Maps Google/Gemini tool positions to OpenCode tool call IDs for correlation.
20-
* Key: sessionID, Value: Map<positionKey, toolCallId> where positionKey is "toolName:index"
21-
*/
2211
googleToolCallMapping: Map<string, Map<string, string>>
23-
/** Set of session IDs that have been restored from disk */
2412
restoredSessions: Set<string>
25-
/** Set of session IDs we've already checked for subagent status (to avoid redundant API calls) */
2613
checkedSessions: Set<string>
27-
/** Set of session IDs that are subagents (have a parentID) - used to skip fetch wrapper processing */
2814
subagentSessions: Set<string>
29-
/** The most recent session ID seen in chat.params - used to correlate fetch requests */
3015
lastSeenSessionId: string | null
3116
}
3217

@@ -40,13 +25,11 @@ export interface ModelInfo {
4025
modelID: string
4126
}
4227

43-
/**
44-
* Creates a fresh plugin state instance.
45-
*/
4628
export function createPluginState(): PluginState {
4729
return {
4830
prunedIds: new Map(),
4931
stats: new Map(),
32+
gcPending: new Map(),
5033
toolParameters: new Map(),
5134
model: new Map(),
5235
googleToolCallMapping: new Map(),
@@ -78,7 +61,12 @@ export async function ensureSessionRestored(
7861
})
7962
}
8063
if (!state.stats.has(sessionId)) {
81-
state.stats.set(sessionId, persisted.stats)
64+
const stats: SessionStats = {
65+
totalToolsPruned: persisted.stats.totalToolsPruned,
66+
totalTokensSaved: persisted.stats.totalTokensSaved,
67+
totalGCTokens: persisted.stats.totalGCTokens ?? 0
68+
}
69+
state.stats.set(sessionId, stats)
8270
}
8371
}
8472
}

0 commit comments

Comments
 (0)