Created
September 12, 2026 13:24
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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