{"slug": "pi-agent-extension-prune-old-images-from-the-context", "title": "Pi agent extension - prune old images from the context", "summary": "A developer released an extension for the Pi coding agent that prunes old images from the context to manage token usage. The extension, called image-context, allows users to configure per-model image limits and replaces pruned images with a placeholder. It is designed to work with Pi agent's message history and supports both new and legacy state formats.", "body_md": "|\nimport { readFileSync } from \"node:fs\"; |\n|\nimport { homedir } from \"node:os\"; |\n|\nimport { join } from \"node:path\"; |\n|\nimport type { AgentMessage } from \"@earendil-works/pi-agent-core\"; |\n|\nimport type { ExtensionAPI } from \"@earendil-works/pi-coding-agent\"; |\n|\n|\n|\ntype JsonObject = Record<string, unknown>; |\n|\n|\n|\ntype ImageLimitRule = { |\n|\nprovider: string; |\n|\nmodels: string[]; |\n|\nmaxImages: number; |\n|\n}; |\n|\n|\n|\nexport type ImageContextConfig = { |\n|\ndefaultMaxImages: number | null; |\n|\nrules: ImageLimitRule[]; |\n|\nplaceholder: string; |\n|\n}; |\n|\n|\n|\ntype ImageBlock = { |\n|\ntype: \"image\"; |\n|\n}; |\n|\n|\n|\ntype ContentBlock = { |\n|\ntype?: string; |\n|\ntext?: string; |\n|\n}; |\n|\n|\n|\ntype MessageWithContent = AgentMessage & { |\n|\ncontent?: string | ContentBlock[]; |\n|\ntimestamp?: number; |\n|\n}; |\n|\n|\n|\ntype PruneResult<T> = { |\n|\nmessages: T[]; |\n|\ntotalImages: number; |\n|\nkeptImages: number; |\n|\nprunedImages: number; |\n|\n}; |\n|\n|\n|\nconst CONFIG_PATH = join(homedir(), \".pi\", \"agent\", \"image-context.json\"); |\n|\nconst STATE_TYPE = \"image-context\"; |\n|\nconst LEGACY_STATE_TYPE = \"qwen-image-context\"; |\n|\nconst STATUS_KEY = \"image-context\"; |\n|\n|\n|\nfunction isObject(value: unknown): value is JsonObject { |\n|\nreturn typeof value === \"object\" && value !== null && !Array.isArray(value); |\n|\n} |\n|\n|\n|\nfunction parseImageLimit(value: unknown, name: string): number | null { |\n|\nif (value === null) return null; |\n|\nif (typeof value !== \"number\" || !Number.isInteger(value) || value < 0) { |\n|\nthrow new Error(`image-context: ${name} must be null or a non-negative integer`); |\n|\n} |\n|\nreturn value; |\n|\n} |\n|\n|\n|\nfunction parseRule(value: unknown, index: number): ImageLimitRule { |\n|\nif (!isObject(value) || typeof value.provider !== \"string\") { |\n|\nthrow new Error(`image-context: rules[${index}].provider must be a string`); |\n|\n} |\n|\nconst models = Array.isArray(value.models) |\n|\n? value.models.filter((model): model is string => typeof model === \"string\") |\n|\n: []; |\n|\nif (models.length === 0) { |\n|\nthrow new Error(`image-context: rules[${index}].models must contain a model id or *`); |\n|\n} |\n|\nconst maxImages = parseImageLimit(value.maxImages, `rules[${index}].maxImages`); |\n|\nif (maxImages === null) { |\n|\nthrow new Error(`image-context: rules[${index}].maxImages cannot be null`); |\n|\n} |\n|\nreturn { provider: value.provider, models, maxImages }; |\n|\n} |\n|\n|\n|\nfunction loadConfig(): ImageContextConfig { |\n|\nconst raw: unknown = JSON.parse(readFileSync(CONFIG_PATH, \"utf8\")); |\n|\nif (!isObject(raw)) throw new Error(\"image-context: config must be an object\"); |\n|\nif (typeof raw.placeholder !== \"string\" || raw.placeholder.length === 0) { |\n|\nthrow new Error(\"image-context: placeholder must be a non-empty string\"); |\n|\n} |\n|\nif (!Array.isArray(raw.rules)) { |\n|\nthrow new Error(\"image-context: rules must be an array\"); |\n|\n} |\n|\nreturn { |\n|\ndefaultMaxImages: parseImageLimit(raw.defaultMaxImages, \"defaultMaxImages\"), |\n|\nrules: raw.rules.map(parseRule), |\n|\nplaceholder: raw.placeholder, |\n|\n}; |\n|\n} |\n|\n|\n|\nexport function resolveImageLimit( |\n|\nconfig: ImageContextConfig, |\n|\nprovider: string | undefined, |\n|\nmodel: string | undefined, |\n|\n): number | null { |\n|\nif (!provider || !model) return config.defaultMaxImages; |\n|\nconst rule = config.rules.find( |\n|\n(candidate) => |\n|\n(candidate.provider === \"*\" || candidate.provider === provider) && |\n|\n(candidate.models.includes(\"*\") || candidate.models.includes(model)), |\n|\n); |\n|\nreturn rule?.maxImages ?? config.defaultMaxImages; |\n|\n} |\n|\n|\n|\nfunction isImageBlock(value: unknown): value is ImageBlock { |\n|\nreturn isObject(value) && value.type === \"image\"; |\n|\n} |\n|\n|\n|\nfunction messageTimestamp(message: MessageWithContent): number | undefined { |\n|\nreturn typeof message.timestamp === \"number\" ? message.timestamp : undefined; |\n|\n} |\n|\n|\n|\nexport function pruneImageContext<T extends MessageWithContent>( |\n|\nmessages: T[], |\n|\nmaxImages: number, |\n|\nclearBeforeTimestamp: number, |\n|\nplaceholder: string, |\n|\n): PruneResult<T> { |\n|\nlet totalImages = 0; |\n|\nlet keptImages = 0; |\n|\nlet prunedImages = 0; |\n|\nlet remaining = maxImages; |\n|\n|\n|\nfor (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) { |\n|\nconst message = messages[messageIndex]; |\n|\nif (!message || !Array.isArray(message.content)) continue; |\n|\nconst timestamp = messageTimestamp(message); |\n|\nconst explicitlyCleared = |\n|\nclearBeforeTimestamp > 0 && |\n|\n(timestamp === undefined || timestamp <= clearBeforeTimestamp); |\n|\n|\n|\nfor (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) { |\n|\nconst block = message.content[blockIndex]; |\n|\nif (!isImageBlock(block)) continue; |\n|\ntotalImages++; |\n|\nif (!explicitlyCleared && remaining > 0) { |\n|\nremaining--; |\n|\nkeptImages++; |\n|\ncontinue; |\n|\n} |\n|\nmessage.content[blockIndex] = { type: \"text\", text: placeholder }; |\n|\nprunedImages++; |\n|\n} |\n|\n} |\n|\n|\n|\nreturn { messages, totalImages, keptImages, prunedImages }; |\n|\n} |\n|\n|\n|\nexport default function imageContext(pi: ExtensionAPI): void { |\n|\nlet config = loadConfig(); |\n|\nlet clearBeforeTimestamp = 0; |\n|\n|\n|\npi.on(\"session_start\", async (_event, ctx) => { |\n|\nclearBeforeTimestamp = 0; |\n|\nfor (const entry of ctx.sessionManager.getEntries()) { |\n|\nif ( |\n|\nentry.type === \"custom\" && |\n|\n(entry.customType === STATE_TYPE || entry.customType === LEGACY_STATE_TYPE) && |\n|\nisObject(entry.data) && |\n|\ntypeof entry.data.clearBeforeTimestamp === \"number\" |\n|\n) { |\n|\nclearBeforeTimestamp = entry.data.clearBeforeTimestamp; |\n|\n} |\n|\n} |\n|\nctx.ui.setStatus(STATUS_KEY, undefined); |\n|\n}); |\n|\n|\n|\npi.on(\"context\", async (event, ctx) => { |\n|\ntry { |\n|\nconfig = loadConfig(); |\n|\n} catch (error) { |\n|\nconsole.error(error); |\n|\n} |\n|\n|\n|\nconst limit = resolveImageLimit(config, ctx.model?.provider, ctx.model?.id); |\n|\nif (limit === null && clearBeforeTimestamp === 0) return; |\n|\n|\n|\nconst result = pruneImageContext( |\n|\nevent.messages as MessageWithContent[], |\n|\nlimit ?? Number.MAX_SAFE_INTEGER, |\n|\nclearBeforeTimestamp, |\n|\nconfig.placeholder, |\n|\n); |\n|\nif (result.totalImages === 0) { |\n|\nctx.ui.setStatus(STATUS_KEY, undefined); |\n|\nreturn { messages: result.messages }; |\n|\n} |\n|\n|\n|\nconst status = |\n|\nresult.prunedImages > 0 |\n|\n? `images ${result.keptImages}/${result.totalImages}` |\n|\n: `images ${result.keptImages}`; |\n|\nctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg(\"muted\", status)); |\n|\nreturn { messages: result.messages }; |\n|\n}); |\n|\n|\n|\npi.registerCommand(\"clear-images\", { |\n|\ndescription: \"Exclude every image already in this session from future LLM context\", |\n|\nhandler: async (_args, ctx) => { |\n|\nclearBeforeTimestamp = Date.now(); |\n|\npi.appendEntry(STATE_TYPE, { clearBeforeTimestamp }); |\n|\nctx.ui.notify( |\n|\n\"Existing session images will no longer be sent to the model. New images follow the active model limit.\", |\n|\n\"info\", |\n|\n); |\n|\n}, |\n|\n}); |\n|\n|\n|\npi.registerCommand(\"image-context\", { |\n|\ndescription: \"Show the active model image-history limit and clear cutoff\", |\n|\nhandler: async (_args, ctx) => { |\n|\nconfig = loadConfig(); |\n|\nconst provider = ctx.model?.provider; |\n|\nconst model = ctx.model?.id; |\n|\nconst limit = resolveImageLimit(config, provider, model); |\n|\nctx.ui.notify( |\n|\n`${provider ?? \"?\"}/${model ?? \"?\"}: ` + |\n|\n`maxImages=${limit ?? \"unlimited\"}; ` + |\n|\n`clearBefore=${clearBeforeTimestamp || \"none\"}`, |\n|\n\"info\", |\n|\n); |\n|\n}, |\n|\n}); |\n|\n} |", "url": "https://wpnews.pro/news/pi-agent-extension-prune-old-images-from-the-context", "canonical_source": "https://gist.github.com/jetnet/15878adefe1f613be188d01bea918038", "published_at": "2026-08-18 21:09:23+00:00", "updated_at": "2026-08-31 15:54:39.298606+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["Pi", "Pi agent", "image-context"], "alternates": {"html": "https://wpnews.pro/news/pi-agent-extension-prune-old-images-from-the-context", "markdown": "https://wpnews.pro/news/pi-agent-extension-prune-old-images-from-the-context.md", "text": "https://wpnews.pro/news/pi-agent-extension-prune-old-images-from-the-context.txt", "jsonld": "https://wpnews.pro/news/pi-agent-extension-prune-old-images-from-the-context.jsonld"}}