Skip to content
Merged
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
13 changes: 3 additions & 10 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { installFetchWrapper } from "./lib/fetch-wrapper"
import { createPruningTool } from "./lib/pruning-tool"
import { createEventHandler, createChatParamsHandler } from "./lib/hooks"
import { createToolTracker } from "./lib/fetch-wrapper/tool-tracker"
import { loadPrompt } from "./lib/core/prompt"

const plugin: Plugin = (async (ctx) => {
const { config, migrations } = getConfig(ctx)
Expand Down Expand Up @@ -40,17 +39,11 @@ const plugin: Plugin = (async (ctx) => {
}
)

// Create tool tracker and load prompts for synthetic instruction injection
// Create tool tracker for nudge injection
const toolTracker = createToolTracker()

const prompts = {
synthInstruction: loadPrompt("synthetic"),
nudgeInstruction: loadPrompt("nudge"),
systemReminder: loadPrompt("system-reminder")
}

// Install global fetch wrapper for context pruning and synthetic instruction injection
installFetchWrapper(state, logger, ctx.client, config, toolTracker, prompts)
// Install global fetch wrapper for context pruning and system message injection
installFetchWrapper(state, logger, ctx.client, config, toolTracker)

// Log initialization
logger.info("plugin", "DCP initialized", {
Expand Down
2 changes: 1 addition & 1 deletion lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const defaultConfig: PluginConfig = {
showUpdateToasts: true,
strictModelSelection: false,
pruning_summary: 'detailed',
nudge_freq: 10,
nudge_freq: 0,
strategies: {
onIdle: ['ai-analysis'],
onTool: ['ai-analysis']
Expand Down
24 changes: 3 additions & 21 deletions lib/core/janitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from "zod"
import type { Logger } from "../logger"
import type { PruningStrategy } from "../config"
import type { PluginState } from "../state"
import type { ToolMetadata } from "../fetch-wrapper/types"
import type { ToolMetadata, PruneReason, SessionStats, GCStats, PruningResult } from "../fetch-wrapper/types"
import { findCurrentAgent } from "../hooks"
import { buildAnalysisPrompt } from "./prompt"
import { selectModel, extractModelFromSession } from "../model-selector"
Expand All @@ -14,25 +14,7 @@ import {
type NotificationContext
} from "../ui/notification"

export interface SessionStats {
totalToolsPruned: number
totalTokensSaved: number
totalGCTokens: number
totalGCTools: number
}

export interface GCStats {
tokensCollected: number
toolsDeduped: number
}

export interface PruningResult {
prunedCount: number
tokensSaved: number
llmPrunedIds: string[]
toolMetadata: Map<string, ToolMetadata>
sessionStats: SessionStats
}
export type { SessionStats, GCStats, PruningResult }

export interface PruningOptions {
reason?: string
Expand Down Expand Up @@ -120,7 +102,7 @@ async function runWithStrategies(

const [sessionInfoResponse, messagesResponse] = await Promise.all([
client.session.get({ path: { id: sessionID } }),
client.session.messages({ path: { id: sessionID }, query: { limit: 100 } })
client.session.messages({ path: { id: sessionID }, query: { limit: 500 } })
])

const sessionInfo = sessionInfoResponse.data
Expand Down
130 changes: 130 additions & 0 deletions lib/fetch-wrapper/formats/anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { FormatDescriptor, ToolOutput } from "../types"
import type { PluginState } from "../../state"

/**
* Anthropic Messages API format with top-level `system` array.
* Tool calls: `tool_use` blocks in assistant content with `id`
* Tool results: `tool_result` blocks in user content with `tool_use_id`
*/
export const anthropicFormat: FormatDescriptor = {
name: 'anthropic',

detect(body: any): boolean {
return (
body.system !== undefined &&
Array.isArray(body.messages)
)
},

getDataArray(body: any): any[] | undefined {
return body.messages
},

injectSystemMessage(body: any, injection: string): boolean {
if (!injection) return false

if (typeof body.system === 'string') {
body.system = [{ type: 'text', text: body.system }]
} else if (!Array.isArray(body.system)) {
body.system = []
}

body.system.push({ type: 'text', text: injection })
return true
},

appendToLastAssistantMessage(body: any, injection: string): boolean {
if (!injection || !body.messages || body.messages.length === 0) return false

// Find the last assistant message
for (let i = body.messages.length - 1; i >= 0; i--) {
const msg = body.messages[i]
if (msg.role === 'assistant') {
// Append to existing content array
if (Array.isArray(msg.content)) {
msg.content.push({ type: 'text', text: injection })
} else if (typeof msg.content === 'string') {
// Convert string content to array format
msg.content = [
{ type: 'text', text: msg.content },
{ type: 'text', text: injection }
]
} else {
msg.content = [{ type: 'text', text: injection }]
}
return true
}
}
return false
},

extractToolOutputs(data: any[], state: PluginState): ToolOutput[] {
const outputs: ToolOutput[] = []

for (const m of data) {
if (m.role === 'user' && Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'tool_result' && block.tool_use_id) {
const toolUseId = block.tool_use_id.toLowerCase()
const metadata = state.toolParameters.get(toolUseId)
outputs.push({
id: toolUseId,
toolName: metadata?.tool
})
}
}
}
}

return outputs
},

replaceToolOutput(data: any[], toolId: string, prunedMessage: string, _state: PluginState): boolean {
const toolIdLower = toolId.toLowerCase()
let replaced = false

for (let i = 0; i < data.length; i++) {
const m = data[i]

if (m.role === 'user' && Array.isArray(m.content)) {
let messageModified = false
const newContent = m.content.map((block: any) => {
if (block.type === 'tool_result' && block.tool_use_id?.toLowerCase() === toolIdLower) {
messageModified = true
return {
...block,
content: prunedMessage
}
}
return block
})
if (messageModified) {
data[i] = { ...m, content: newContent }
replaced = true
}
}
}

return replaced
},

hasToolOutputs(data: any[]): boolean {
for (const m of data) {
if (m.role === 'user' && Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'tool_result') return true
}
}
}
return false
},

getLogMetadata(data: any[], replacedCount: number, inputUrl: string): Record<string, any> {
return {
url: inputUrl,
replacedCount,
totalMessages: data.length,
format: 'anthropic'
}
}
}
64 changes: 24 additions & 40 deletions lib/fetch-wrapper/formats/bedrock.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,6 @@
import type { FormatDescriptor, ToolOutput } from "../types"
import type { PluginState } from "../../state"

function isNudgeMessage(msg: any, nudgeText: string): boolean {
if (typeof msg.content === 'string') {
return msg.content === nudgeText
}
return false
}

function injectSynth(messages: any[], instruction: string, nudgeText: string, systemReminder: string): boolean {
const fullInstruction = systemReminder + '\n\n' + instruction
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role === 'user') {
if (isNudgeMessage(msg, nudgeText)) continue

if (typeof msg.content === 'string') {
if (msg.content.includes(instruction)) return false
msg.content = msg.content + '\n\n' + fullInstruction
} else if (Array.isArray(msg.content)) {
const alreadyInjected = msg.content.some(
(part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction)
)
if (alreadyInjected) return false
msg.content.push({ type: 'text', text: fullInstruction })
}
return true
}
}
return false
}

function injectPrunableList(messages: any[], injection: string): boolean {
if (!injection) return false
messages.push({ role: 'user', content: injection })
return true
}

/**
* Bedrock uses top-level `system` array + `inferenceConfig` (distinguishes from OpenAI/Anthropic).
* Tool calls: `toolUse` blocks in assistant content with `toolUseId`
Expand All @@ -57,12 +21,32 @@ export const bedrockFormat: FormatDescriptor = {
return body.messages
},

injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean {
return injectSynth(data, instruction, nudgeText, systemReminder)
injectSystemMessage(body: any, injection: string): boolean {
if (!injection) return false

if (!Array.isArray(body.system)) {
body.system = []
}

body.system.push({ text: injection })
return true
},

injectPrunableList(data: any[], injection: string): boolean {
return injectPrunableList(data, injection)
appendToLastAssistantMessage(body: any, injection: string): boolean {
if (!injection || !body.messages || body.messages.length === 0) return false

for (let i = body.messages.length - 1; i >= 0; i--) {
const msg = body.messages[i]
if (msg.role === 'assistant') {
if (Array.isArray(msg.content)) {
msg.content.push({ text: injection })
} else {
msg.content = [{ text: injection }]
}
return true
}
}
return false
},

extractToolOutputs(data: any[], state: PluginState): ToolOutput[] {
Expand Down
63 changes: 27 additions & 36 deletions lib/fetch-wrapper/formats/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,6 @@
import type { FormatDescriptor, ToolOutput } from "../types"
import type { PluginState } from "../../state"

function isNudgeContent(content: any, nudgeText: string): boolean {
if (Array.isArray(content.parts) && content.parts.length === 1) {
const part = content.parts[0]
return part?.text === nudgeText
}
return false
}

function injectSynth(contents: any[], instruction: string, nudgeText: string, systemReminder: string): boolean {
const fullInstruction = systemReminder + '\n\n' + instruction
for (let i = contents.length - 1; i >= 0; i--) {
const content = contents[i]
if (content.role === 'user' && Array.isArray(content.parts)) {
if (isNudgeContent(content, nudgeText)) continue

const alreadyInjected = content.parts.some(
(part: any) => part?.text && typeof part.text === 'string' && part.text.includes(instruction)
)
if (alreadyInjected) return false
content.parts.push({ text: fullInstruction })
return true
}
}
return false
}

function injectPrunableList(contents: any[], injection: string): boolean {
if (!injection) return false
contents.push({ role: 'user', parts: [{ text: injection }] })
return true
}

/**
* Gemini doesn't include tool call IDs in its native format.
* We use position-based correlation via state.googleToolCallMapping which maps
Expand All @@ -49,12 +17,35 @@ export const geminiFormat: FormatDescriptor = {
return body.contents
},

injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean {
return injectSynth(data, instruction, nudgeText, systemReminder)
injectSystemMessage(body: any, injection: string): boolean {
if (!injection) return false

if (!body.systemInstruction) {
body.systemInstruction = { parts: [] }
}
if (!Array.isArray(body.systemInstruction.parts)) {
body.systemInstruction.parts = []
}

body.systemInstruction.parts.push({ text: injection })
return true
},

injectPrunableList(data: any[], injection: string): boolean {
return injectPrunableList(data, injection)
appendToLastAssistantMessage(body: any, injection: string): boolean {
if (!injection || !body.contents || body.contents.length === 0) return false

for (let i = body.contents.length - 1; i >= 0; i--) {
const content = body.contents[i]
if (content.role === 'model') {
if (Array.isArray(content.parts)) {
content.parts.push({ text: injection })
} else {
content.parts = [{ text: injection }]
}
return true
}
}
return false
},

extractToolOutputs(data: any[], state: PluginState): ToolOutput[] {
Expand Down
1 change: 1 addition & 0 deletions lib/fetch-wrapper/formats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { openaiChatFormat } from './openai-chat'
export { openaiResponsesFormat } from './openai-responses'
export { geminiFormat } from './gemini'
export { bedrockFormat } from './bedrock'
export { anthropicFormat } from './anthropic'
Loading