Skip to content

Instantly share code, notes, and snippets.

@nicolasembleton
Created September 12, 2026 13:24
Show Gist options
  • Select an option

  • Save nicolasembleton/4086a8a6c3be8d0ff378e8fcc3c963d2 to your computer and use it in GitHub Desktop.

Select an option

Save nicolasembleton/4086a8a6c3be8d0ff378e8fcc3c963d2 to your computer and use it in GitHub Desktop.
Plugin for Amp Custom Modes to report summaries to their parent threads automatically
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { PluginAPI, ThreadID, ThreadMessage } from '@ampcode/plugin'
/**
* Self-summary plugin — two opt-in markers, matched case-insensitively in the
* prompt that starts a turn. Turns that do not end with status 'done' never arm.
*
* Option A — marker `summarize-on-end`:
* The turn is continued exactly once with a follow-up message that asks the
* agent to summarize itself in natural language, in-thread. The follow-up
* message does not carry the marker, so the loop terminates.
*
* Option B — marker `summarize-ai-on-end` (takes precedence if both appear):
* At agent.end, the plugin builds a budgeted digest of the turn — user
* prompts, every tool call with its modified files / command / output
* excerpt (the source of truth for file paths and commit SHAs), assistant
* prose, then thinking traces with whatever budget remains — and asks a
* cheap long-context model to return a structured summary. The result is
* written to ~/.config/amp/self-summaries/<thread>-<ts>.{json,md} and
* mirrored into the workspace (self-summaries/) so records stay fetchable
* cross-machine — e.g. out of ephemeral orbs via thread file downloads.
* If the prompt also contains the orchestrator's thread URL (or bare T-… ID),
* the summary is self-reported back to that thread as a user message.
* No extra agent turn is started.
*/
const OPT_IN = 'summarize-on-end'
const AI_MARKER = 'summarize-ai-on-end'
const MARKER = '[self-summary]'
const MODEL = 'zhipuai/glm-5.3-flash'
// Zhipu docs (docs.z.ai/guides/capabilities/thinking): GLM-5.3-FLASH cannot
// disable thinking and supports low/high/max only, with max recommended and
// the default. Via the Coding Plan endpoint Amp's efforts map: none/minimal/
// low → low, medium/high → high, xhigh/max → max. PluginAI's 'none' default is
// rejected outright. Summary extraction does not need max reasoning, which can
// consume a small output budget before producing visible text.
const REASONING_EFFORT = 'high'
const MAX_OUTPUT_TOKENS = 16_384
const SUMMARY_DIR = join(homedir(), '.config', 'amp', 'self-summaries')
// Char budget for the digest sent to the summarizer. glm-5.3-flash has ~1M
// token context; 200k chars (~50k tokens) leaves huge headroom while bounding
// cost even for very long turns.
const DIGEST_BUDGET = 200_000
const CAP = {
prompt: 2_000,
assistant: 2_000,
thinking: 1_200,
toolInput: 400,
toolResult: 400,
} as const
const SUMMARY_SCHEMA = {
name: 'AgentTurnSummary',
description: 'Structured summary of one completed agent turn, for an orchestrator',
fields: {
outcome: { type: 'string', description: 'One sentence: the state the work ended in (done / partial / blocked) and why' },
summary: { type: 'string', description: '3-8 sentence natural-language summary of what was done and how' },
filesTouched: { type: 'array', items: { type: 'string' }, description: 'Every file path created, modified, or deleted this turn; empty array if none' },
commits: { type: 'array', items: { type: 'string' }, description: 'Commit SHAs authored this turn as "sha subject", copied verbatim from tool outputs; empty array if none' },
verification: { type: 'string', description: 'Concrete commands or checks an orchestrator can run to validate the work' },
unresolved: { type: 'string', description: 'Open questions, risks, follow-ups; "none" if fully resolved' },
},
}
interface TurnSummary {
outcome: string
summary: string
filesTouched: string[]
commits: string[]
verification: string
unresolved: string
}
interface GenerationDiagnostics {
mode: 'structured' | 'text-fallback' | 'deterministic-fallback'
structuredError?: Record<string, unknown>
textError?: Record<string, unknown>
structuredEmpty?: boolean
textEmpty?: boolean
}
function errorDetails(error: unknown): Record<string, unknown> {
if (!(error instanceof Error)) return { value: String(error) }
const cause = error.cause
return {
name: error.name,
message: error.message,
...(cause === undefined
? {}
: { cause: cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause) }),
}
}
function strings(value: unknown): string[] {
return Array.isArray(value)
? value.map((item) => String(item).trim()).filter(Boolean)
: []
}
function normalizeSummary(value: Record<string, unknown>): TurnSummary {
return {
outcome: String(value.outcome ?? '').trim(),
summary: String(value.summary ?? '').trim(),
filesTouched: strings(value.filesTouched),
commits: strings(value.commits),
verification: String(value.verification ?? '').trim(),
unresolved: String(value.unresolved ?? '').trim(),
}
}
function hasUsefulSummary(summary: TurnSummary): boolean {
return Boolean(summary.outcome && summary.summary && summary.verification && summary.unresolved)
}
const SECTION_KEYS: Record<string, keyof TurnSummary> = {
outcome: 'outcome',
summary: 'summary',
'files touched': 'filesTouched',
files: 'filesTouched',
commits: 'commits',
verification: 'verification',
unresolved: 'unresolved',
}
function parseList(value: string): string[] {
return value
.split('\n')
.map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, '').trim())
.filter(
(line) =>
line &&
!/^\(?(?:none|n\/?a|not applicable)\)?[.!]?$/i.test(line) &&
!/^no (?:files?(?: were)? touched|commits?(?: were)? (?:created|made))[.!]?$/i.test(line),
)
}
/** Parse the markdown requested from text-mode generation into report fields. */
export function parseTextSummary(text: string): TurnSummary | null {
const trimmed = text.trim()
if (!trimmed) return null
const values = new Map<keyof TurnSummary, string[]>()
let current: keyof TurnSummary | null = null
for (const line of trimmed.split('\n')) {
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/)
if (heading) {
current = SECTION_KEYS[heading[1].trim().toLowerCase()] ?? null
if (current && !values.has(current)) values.set(current, [])
continue
}
if (current) values.get(current)?.push(line)
}
if (values.size === 0) {
return {
outcome: '(text fallback — schema generation failed)',
summary: trimmed,
filesTouched: [],
commits: [],
verification: '',
unresolved: '',
}
}
const section = (key: keyof TurnSummary) => (values.get(key) ?? []).join('\n').trim()
return {
outcome: section('outcome') || '(text fallback — schema generation failed)',
summary: section('summary'),
filesTouched: parseList(section('filesTouched')),
commits: parseList(section('commits')),
verification: section('verification'),
unresolved: section('unresolved'),
}
}
export async function generateSummary(
ai: PluginAPI['ai'],
instruction: string,
deterministic: TurnSummary,
): Promise<{ summary: TurnSummary; diagnostics: GenerationDiagnostics }> {
const diagnostics: GenerationDiagnostics = { mode: 'structured' }
try {
const generated = normalizeSummary(
await ai.generate({
prompt: instruction,
schema: SUMMARY_SCHEMA,
model: MODEL,
reasoningEffort: REASONING_EFFORT,
maxTokens: MAX_OUTPUT_TOKENS,
}),
)
if (hasUsefulSummary(generated)) return { summary: generated, diagnostics }
diagnostics.structuredEmpty = true
} catch (error) {
diagnostics.structuredError = errorDetails(error)
}
diagnostics.mode = 'text-fallback'
try {
const text = await ai.generate({
prompt:
`${instruction}\n\nOutput markdown with exactly these headings: ` +
`Outcome, Summary, Files touched, Commits, Verification, Unresolved.`,
model: MODEL,
reasoningEffort: REASONING_EFFORT,
maxTokens: MAX_OUTPUT_TOKENS,
})
const parsed = parseTextSummary(text)
if (parsed && hasUsefulSummary(parsed)) return { summary: parsed, diagnostics }
diagnostics.textEmpty = true
} catch (error) {
diagnostics.textError = errorDetails(error)
}
diagnostics.mode = 'deterministic-fallback'
return { summary: deterministic, diagnostics }
}
function clip(text: string, cap: number): string {
if (text.length <= cap) return text
const half = Math.floor(cap / 2)
return `${text.slice(0, half)} …[+${text.length - cap} chars]… ${text.slice(-half)}`
}
function outputToText(output: unknown): string {
if (typeof output === 'string') return output
if (Array.isArray(output)) {
return output
.map((b) =>
b && typeof b === 'object' && 'text' in b ? String((b as { text: string }).text) : '',
)
.filter(Boolean)
.join('\n')
}
try {
return JSON.stringify(output) ?? ''
} catch {
return ''
}
}
function compactInput(input: Record<string, unknown>): string {
const interesting = ['command', 'patchText', 'path', 'filePath', 'pattern', 'query', 'url', 'workdir', 'prompt']
const parts: string[] = []
for (const key of interesting) {
if (input[key] !== undefined) parts.push(`${key}=${clip(String(input[key]), CAP.toolInput)}`)
}
if (parts.length === 0) {
try {
parts.push(clip(JSON.stringify(input), CAP.toolInput))
} catch {
// unserializable input; nothing useful to show
}
}
return parts.join(' | ')
}
interface Digest {
text: string
stats: Record<string, number | boolean>
}
function buildDigest(messages: ThreadMessage[], amp: PluginAPI): Digest {
const prompts: string[] = []
const assistant: string[] = []
const thinking: string[] = []
for (const message of messages) {
if (message.role === 'user') {
const texts: string[] = []
for (const block of message.content) {
if (block.type === 'text') texts.push(block.text)
}
const text = texts.join('\n').trim()
if (text) prompts.push(clip(text, CAP.prompt))
} else if (message.role === 'assistant') {
for (const block of message.content) {
if (block.type === 'text' && block.text.trim()) {
assistant.push(clip(block.text.trim(), CAP.assistant))
} else if (block.type === 'thinking' && block.thinking.trim()) {
thinking.push(clip(block.thinking.trim(), CAP.thinking))
}
}
}
}
const toolLines: string[] = []
for (const { call, result } of amp.helpers.toolCallsInMessages(messages)) {
const modified =
result.status === 'done' ? amp.helpers.filesModifiedByToolCall(result) : null
const files = modified ? modified.map((uri) => amp.helpers.filePathFromURI(uri)) : []
const bits = [
`#${toolLines.length + 1} ${call.tool}`,
files.length > 0 ? `files: ${files.join(', ')}` : compactInput(call.input),
`status: ${result.status}`,
]
const out = outputToText(result.output).trim()
if (out) bits.push(`result: ${clip(out, CAP.toolResult)}`)
toolLines.push(bits.join(' | '))
}
// Highest operational value first: prompts, then tool facts (files/commits),
// then the agent's own prose, then thinking with whatever budget remains.
const sections = [
{ key: 'prompts', title: 'USER PROMPTS', lines: prompts },
{
key: 'toolCalls',
title: 'TOOL ACTIVITY — commands, modified files, output excerpts (source of truth for file paths and commit SHAs)',
lines: toolLines,
},
{ key: 'assistantMessages', title: 'ASSISTANT MESSAGES', lines: assistant },
{ key: 'thinkingTraces', title: 'THINKING TRACES', lines: thinking },
]
const stats: Record<string, number | boolean> = {}
for (const section of sections) stats[section.key] = section.lines.length
let used = 0
let truncated = false
const body: string[] = []
for (const section of sections) {
const header = `\n## ${section.title}\n`
const remaining = DIGEST_BUDGET - used - header.length
const kept: string[] = []
let sectionUsed = 0
if (remaining > 0) {
for (const line of section.lines) {
if (sectionUsed + line.length + 1 > remaining) {
truncated = true
break
}
kept.push(line)
sectionUsed += line.length + 1
}
} else {
truncated = true
}
stats[`${section.key}Included`] = kept.length
used += header.length + sectionUsed
const omitted = section.lines.length - kept.length
const content = kept.length === 0 ? '(none)' : kept.join('\n')
body.push(header + content + (omitted > 0 ? `\n(${omitted} entries omitted — digest budget)` : ''))
}
stats.truncated = truncated
return { text: body.join('\n'), stats }
}
export function deterministicFallback(messages: ThreadMessage[], amp: PluginAPI): TurnSummary {
let finalAssistant = ''
for (let index = messages.length - 1; index >= 0 && !finalAssistant; index -= 1) {
const message = messages[index]
if (message.role !== 'assistant') continue
finalAssistant = message.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('\n')
.trim()
}
const files = new Set<string>()
for (const { result } of amp.helpers.toolCallsInMessages(messages)) {
if (result.status !== 'done') continue
for (const uri of amp.helpers.filesModifiedByToolCall(result) ?? []) {
files.add(amp.helpers.filePathFromURI(uri))
}
}
return {
outcome: 'Turn completed; AI summary generation returned no usable report.',
summary:
finalAssistant ||
'The turn completed, but no assistant prose was available for the deterministic fallback.',
filesTouched: [...files],
commits: [],
verification: 'Review the child thread and the saved generation diagnostics before relying on this fallback.',
unresolved: 'AI-generated summary unavailable; commit and verification details may require manual review.',
}
}
function renderMarkdown(record: Record<string, unknown>): string {
const s = (record.summary ?? {}) as Record<string, unknown>
const list = (value: unknown): string =>
Array.isArray(value) && value.length > 0 ? value.map((item) => `- ${item}`).join('\n') : '- (none)'
return [
`# Self-summary (AI) — ${record.threadId}`,
``,
`- generated: ${record.generatedAt}`,
`- model: ${record.model}`,
`- generation: ${JSON.stringify(record.generation)}`,
`- digest stats: ${JSON.stringify(record.digest)}`,
``,
`## Outcome`,
String(s.outcome ?? ''),
``,
`## Summary`,
String(s.summary ?? ''),
``,
`## Files touched`,
list(s.filesTouched),
``,
`## Commits`,
list(s.commits),
``,
`## Verification`,
String(s.verification ?? ''),
``,
`## Unresolved`,
String(s.unresolved ?? ''),
``,
].join('\n')
}
/**
* Where records are written: always the machine-local config dir, plus the
* current workspace (when discoverable). The workspace copy is what makes
* records and error evidence reachable cross-machine — ~/.config inside an orb
* container is invisible to every other machine.
*
* amp.workspaceRoot is null in executor-side plugin runtimes (both runner and
* orb children), so when it is unset we fall back to the runtime process's
* working directory. Workspace-loaded plugins run with cwd <workspace>/.amp/
* plugins, so both that directory and its grandparent are candidates — and a
* candidate counts only if it plausibly IS a workspace checkout (contains
* AGENTS.md or a .git dir, and is neither / nor the home dir).
*/
function recordDirs(amp: PluginAPI): string[] {
const dirs = [SUMMARY_DIR]
try {
let workspace: string | null = null
if (amp.workspaceRoot) {
workspace = amp.helpers.filePathFromURI(amp.workspaceRoot)
} else {
const cwd = process.cwd()
const candidates = [cwd]
if (cwd.endsWith(join('.amp', 'plugins'))) candidates.push(dirname(dirname(cwd)))
for (const candidate of candidates) {
if (candidate === '/' || candidate === homedir()) continue
if (existsSync(join(candidate, 'AGENTS.md')) || existsSync(join(candidate, '.git'))) {
workspace = candidate
break
}
}
}
if (workspace && join(workspace, 'self-summaries') !== SUMMARY_DIR) {
dirs.push(join(workspace, 'self-summaries'))
}
} catch {
// workspace unavailable — config dir only
}
return dirs
}
/** Best-effort write of one record file to every record dir. Returns paths written. */
function writeRecord(amp: PluginAPI, fileName: string, content: string): string[] {
const written: string[] = []
for (const dir of recordDirs(amp)) {
try {
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, fileName), content)
written.push(join(dir, fileName))
} catch {
// best effort per location
}
}
return written
}
/**
* Find the orchestrator thread ID in the prompt that armed this turn: the
* first thread ID or ampcode.com/threads URL that is not this thread itself.
*/
function findOrchestratorThreadID(prompt: string, selfID: ThreadID): ThreadID | null {
const ids = prompt.match(/\bT-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi) ?? []
const target = ids.find((id) => id.toLowerCase() !== selfID.toLowerCase())
return target ? (target as ThreadID) : null
}
export default function (amp: PluginAPI) {
amp.on('agent.end', async (event, ctx) => {
if (event.status !== 'done') return
const message = event.message.toLowerCase()
if (message.includes(MARKER)) return
const wantsAI = message.includes(AI_MARKER)
const wantsSelf = message.includes(OPT_IN) && !wantsAI
if (!wantsAI && !wantsSelf) return
// ---- Option A: the agent summarizes itself in one follow-up turn ----
if (wantsSelf) {
amp.logger.log(`self-summary: follow-up summary turn for thread ${event.thread.id}`)
return {
action: 'continue' as const,
userMessage:
`${MARKER} Your turn is complete. Reply with ONLY a concise, ` +
`natural-language summary of what you just did: the outcome, the key ` +
`evidence (files, commands, results), and anything left unresolved. ` +
`Do not use any tools and do not repeat raw tool-call inventories.`,
}
}
// ---- Option B: a detached cheap model summarizes a budgeted digest ----
const started = new Date()
try {
const digest = buildDigest(event.messages, amp)
const instruction =
`An agent just finished a turn in an Amp coding thread. Below is a curated ` +
`digest of that turn: the user's prompts; every tool call with its ` +
`command/input, modified files, and an output excerpt (TOOL ACTIVITY is ` +
`the ONLY source of truth for file paths and commit SHAs — never invent ` +
`or guess them); the agent's own messages; and its thinking traces.\n\n` +
`Produce the structured summary now. Base every factual claim on the ` +
`digest. For commits, copy the SHA and subject verbatim from command ` +
`output. For verification, give commands an orchestrator could actually run.` +
`\n\n=== DIGEST ===\n${digest.text}`
const { summary, diagnostics } = await generateSummary(
amp.ai,
instruction,
deterministicFallback(event.messages, amp),
)
if (diagnostics.mode !== 'structured') {
amp.logger.log(
`self-summary: ${diagnostics.mode} used for thread ${event.thread.id}: ` +
JSON.stringify(diagnostics),
)
}
const record = {
threadId: event.thread.id,
generatedAt: started.toISOString(),
model: MODEL,
marker: AI_MARKER,
digest: { budget: DIGEST_BUDGET, ...digest.stats },
generation: diagnostics,
summary,
}
const base = `${event.thread.id}-${started.getTime()}`
const written = [
...writeRecord(amp, `${base}.json`, `${JSON.stringify(record, null, 2)}\n`),
...writeRecord(amp, `${base}.md`, renderMarkdown(record)),
]
// Self-report: deliver the summary back to the orchestrator thread whose
// URL/ID appeared in the prompt that armed this turn.
const orchestratorID = findOrchestratorThreadID(event.message, event.thread.id)
if (orchestratorID) {
const s = summary
const lines = (v: unknown) =>
Array.isArray(v) && v.length > 0 ? v.map((x) => ` - ${x}`).join('\n') : ' (none)'
const report =
`[self-summary:ai report] Automated summary from child thread ` +
`${event.thread.id} (${`https://ampcode.com/threads/${event.thread.id}`}), ` +
`generated by the self-summary plugin via ${MODEL}. No reply required.\n\n` +
`Outcome: ${String(s.outcome ?? '')}\n\n` +
`Summary: ${String(s.summary ?? '')}\n\n` +
`Files touched:\n${lines(s.filesTouched)}\n\n` +
`Commits:\n${lines(s.commits)}\n\n` +
`Verification: ${String(s.verification ?? '')}\n\n` +
`Unresolved: ${String(s.unresolved ?? '')}\n\n` +
`Generation: ${diagnostics.mode}; ` +
`Full records: ${written.join(', ')}; ` +
`digest stats: ${JSON.stringify(record.digest)}`
await amp.threads
.get(orchestratorID)
.appendUserMessage({ type: 'user-message', content: report }, { steer: true })
amp.logger.log(`self-summary: reported back to orchestrator ${orchestratorID}`)
} else {
amp.logger.log(`self-summary: no orchestrator thread ID in prompt; summary written to disk only`)
}
try {
await ctx.ui.notify(`Self-summary (AI) written: ${base}.md`)
} catch (err) {
if (!amp.helpers.isPluginUINotAvailableError(err as Error)) {
amp.logger.log(`self-summary: notify failed: ${String(err)}`)
}
}
amp.logger.log(`self-summary: AI summary written for thread ${event.thread.id}`)
} catch (err) {
amp.logger.log(`self-summary: AI path failed: ${String(err)}`)
writeRecord(
amp,
`${event.thread.id}-error-${started.getTime()}.txt`,
String((err as Error | undefined)?.stack ?? err),
)
}
return
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment