Every session with an AI coding assistant ends the same way for me. I close the terminal and lose the decisions.
Not the code, that's in git. The decisions. Why I went with approach A over B. What tradeoffs I accepted. What I left open for next time.
So I built a Pi extension that fixes this. Here's how it works, and how I solved the only hard part.
What Pi is
Pi is an agentic coding assistant, think Claude Code or Codex but open and extensible. It runs in your terminal, has access to your filesystem and bash, and supports TypeScript extensions that hook into its full lifecycle.
The naive approach (and why it fails)
The obvious solution: at session end, grab the conversation and summarize it.
Problem: session context is massive. Tool outputs alone can be thousands of tokens. Feeding that to an LLM is expensive, slow, and produces bloated summaries.
The actual solution: bounded extraction
Instead of feeding the whole session, extract only three things:
// User prompts — what was asked (capped at 300 chars each)
// Tool names — what was touched (bash, edit, read, grep...)
// Last assistant message — what was concluded (capped at 300 chars)
That's it. Tool output blobs are skipped entirely. The extract stays small regardless of session length.
function extractSessionData(ctx: ExtensionContext): string {
const entries = ctx.sessionManager.getBranch();
const userPrompts: string[] = [];
const toolsUsed = new Set<string>();
let lastAssistantText = "";
for (const entry of entries) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role === "user") {
const text = Array.isArray(msg.content)
? msg.content
.filter((b: any) => b.type === "text")
.map((b: any) => b.text.trim())
.join(" ")
: msg.content?.trim() ?? "";
if (text.length > 10) userPrompts.push(text.slice(0, 300));
}
if (msg.role === "assistant" && Array.isArray(msg.content)) {
msg.content
.filter((b: any) => b.type === "tool_use")
.forEach((b: any) => toolsUsed.add(b.name));
const text = msg.content
.filter((b: any) => b.type === "text")
.map((b: any) => b.text.trim())
.join(" ");
// Keep overwriting — we want only the last one
if (text.length > 20) lastAssistantText = text.slice(0, 300);
}
}
// ... build extract string
}
The LLM call
Separate Haiku-or-equivalent call. Never touches the main session context.
I'm using Mistral Small via OpenRouter — ~$0.00039 per call, 350 max output tokens.
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "mistralai/mistral-small-3.2-24b",
max_tokens: 350,
messages: [
{ role: "system", content: TIGHT_STRUCTURED_PROMPT },
{ role: "user", content: extract },
],
}),
});
The system prompt forces structure:
**Accomplished:** concrete bullets
**Touched:** files and tools
**Decisions:** architectural choices
**Open:** unresolved items
The output
After a Stride (macOS app) session:
## unnamed — 02:19 PM
**Accomplished:**
- Created 4 new homepage widget components
- Modified CurrentSessionView.swift to integrate bento-grid layout
- Restructured homepage from single-column to two-column bento-grid
**Touched:**
- CurrentSessionView.swift (restructured layout)
- TodaySummaryWidget.swift (new)
- HabitSummaryWidget.swift (new)
- bash, edit, grep (analysis tools)
**Decisions:**
- Adopted bento-grid layout for better information density
- Maintained ambient background branding for consistency
**Open:**
- MCP module compilation issues (pre-existing, unrelated)
Install
cp session-log.ts ~/.pi/agent/extensions/session-log.ts
# then /reload inside Pi
Commands:
-
/log— generate and save -
/log show— open today's file in your default editor
Full source: shittycodingagent.ai
Top comments (0)