// Memory plugin for OpenCode — three-tier, multi-root, human-editable. // // ROOT.md the total plan: goals, hard rules, directory registry, // and a routing table of "scenario -> which file to load" // / one per kind of memory, same shape at every level // index.md one line per file. COMPILED from frontmatter, never edited // .md the memory, with frontmatter (summary / highlight / load_when) // // Two roots coexist and use identical rules: // global /memory/ preferences, decisions, general pitfalls // project /.memory/ facts specific to this checkout // Project wins on conflict; global hard rules stay resident everywhere. // // Why a plugin rather than a skill telling the model to read a folder: model- // driven retrieval is unreliable — the model forgets, reads half the files, or // reads the wrong ones. Here the plugin does retrieval, scoring, and injection // itself. The Markdown is the human-facing format; the plugin is the engine. // // Docs consulted: // OpenCode V2 plugins: https://opencode.ai/v2/docs/build/plugins/ // OpenCode plugins config: https://opencode.ai/v2/docs/plugins // Session hooks / events: see the plugin page's Hooks and Events sections // // Prerequisite: npm i @opencode/plugin@$(opencode --version | sed 's/^v//') // then `opencode service restart`. import { Plugin, type Context } from "@opencode/plugin" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { buildInjection, readGoal, scoreFiles } from "./recall.ts" import { DEFAULT_EXTRACT, extract, redactSecrets, userAndAssistantText, type ExtractOptions } from "./extract.ts" import { type MemoryFile, type MemoryRoot, compileCategoryIndexes, compileRoot, ensureRoot, findNearestRoot, globalRoot, markSuperseded, projectRoot, readState, scanRoot, validateCategory, validateSlug, writeMemory, writeState, } from "./store.ts" interface PluginConfig { /** Hard cap on injected characters per model request. Default 1200. */ maxInjectChars?: number /** How many memory bodies to inline per request. Default 4. */ maxInjectEntries?: number /** Half-life in days for the recency decay. Default 14; 0 disables. */ halfLifeDays?: number autoExtract?: boolean minTurns?: number /** How long a turn must be quiet before extraction runs. Default 20000ms. */ settleMs?: number extractModel?: { providerID: string; id: string } /** Seed these categories on first run. */ categories?: string[] } const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"] const DEFAULT_MAX_INJECT = 1200 /** * Events that mark turn activity. Collected from what this stream actually * delivers: `session.idle` is defined in the protocol but never arrives here, * so extraction is debounced off these instead. */ const EXTRACTION_TRIGGERS = new Set([ "session.step.started", "session.tool.called", "session.inbox.delivered", ]) export default Plugin.define({ id: "memory", async setup(ctx) { const config = ctx.options as PluginConfig const maxChars = config.maxInjectChars ?? DEFAULT_MAX_INJECT const maxEntries = config.maxInjectEntries ?? 4 const halfLifeDays = config.halfLifeDays ?? 14 const categories = config.categories ?? CATEGORIES const extractOptions: ExtractOptions = { ...DEFAULT_EXTRACT, enabled: config.autoExtract !== false, minTurns: config.minTurns ?? DEFAULT_EXTRACT.minTurns, ...(config.extractModel ? { model: config.extractModel } : {}), } // `ctx.location.directory` is the session's working directory, NOT the // config root, so it cannot be used to locate this plugin's own files. The // plugin directory itself is the only reliable anchor: // /plugins/memory/index.ts -> up two levels -> const configDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..") const global = globalRoot(configDir) // A project root is only created on demand: an empty `.memory/` directory in // every checkout is worse than none at all. let project: MemoryRoot | undefined const canonical = ctx.location.project?.canonical if (canonical && canonical !== "/" && fs.existsSync(canonical)) { project = projectRoot(canonical, String(ctx.location.project.id)) } const roots = (): MemoryRoot[] => (project ? [global, project] : [global]) // Seeding the skeleton must never be able to break plugin load: an // unwritable directory should degrade to "no memories yet", not to a // failed plugin. Every read path already tolerates a missing root. try { ensureRoot(global, categories) } catch (error) { console.error( `memory: cannot create ${global.dir} (${(error as Error).message}). Memories are disabled until this is writable.`, ) } // -- in-memory view, refreshed when files change on disk ---------------- let cache: { filesByRoot: Map; at: number } | undefined const filesByRoot = (): Map => { const now = Date.now() if (cache && now - cache.at < 2000) return cache.filesByRoot const map = new Map() for (const root of roots()) map.set(root.key, scanRoot(root)) cache = { filesByRoot: map, at: now } return map } const allFiles = (): MemoryFile[] => roots().flatMap((r) => filesByRoot().get(r.key) ?? []) const states = (root: MemoryRoot) => readState(root) const invalidate = () => { cache = undefined } // Recompile every derived index after a write. const recompile = () => { for (const root of roots()) { const files = scanRoot(root) compileCategoryIndexes(root, files) compileRoot(root, files) } invalidate() } // -- injection into the agent loop -------------------------------------- // `context` covers the agent loop including tool continuations, and edits // only the outgoing request — the conversation transcript stays clean. await ctx.session.hook("context", async (event) => { try { const query = latestUserText(event.messages) if (!query) return const text = buildInjection({ roots: roots(), filesByRoot: filesByRoot(), statesByRoot: new Map(roots().map((r) => [r.key, states(r)])), query, options: { maxChars, maxEntries, halfLifeDays }, }) if (text) event.system.push({ type: "text", text }) // Record what the model actually received. Without this there is no way // to tell "injected nothing because there is nothing" apart from // "the hook is not firing at all". await noteDiag(ctx, "lastInjection", { at: Date.now(), chars: text.length, withinBudget: text.length <= maxChars, bodies: (text.match(/^### /gm) ?? []).length, query: query.slice(0, 80), }) } catch (error) { console.error(`memory: injection failed: ${(error as Error).message}`) } }) // -- tools -------------------------------------------------------------- await ctx.tool.transform((editor) => { editor.namespace({ name: "memory", description: "Durable memory across sessions: recall what was learned before, record new facts, forget stale ones.", }) // memory_recall ------------------------------------------------------ editor.add({ name: "recall", description: [ "Search memory for durable knowledge from earlier sessions: preferences, decisions, conventions, pitfalls.", "Start here when ROOT's outline mentions something relevant but you need the full detail,", "or when the user refers to something you do not remember deciding.", "Prefer this over re-deriving a convention from the repo.", ].join(" "), input: { type: "object", properties: { query: { type: "string", description: "What you are trying to recall — a topic, a decision, a symptom.", }, scope: { type: "string", enum: ["any", "project", "global"], description: "Restrict to one root. Default any (project wins on conflict).", }, limit: { type: "number", description: "Max memories to return. Default 5.", }, }, required: ["query"], additionalProperties: false, }, execute: async (input: { query: string; scope?: string; limit?: number }) => { const pool = pickRoots(roots(), input.scope) const scored = pool .flatMap((root) => scoreFiles(filesByRoot().get(root.key) ?? [], input.query, states(root), { halfLifeDays }), ) // Same rel path in two roots: the project one is the specific truth. .filter((entry, i, all) => { const same = all.filter((e) => e.file.rel === entry.file.rel) return same.length === 1 || same[0] === entry }) .sort((a, b) => b.score - a.score) .slice(0, input.limit ?? 5) // Reinforcement: what gets used floats up next time. if (scored.length) { for (const root of pool) { const state = states(root) let dirty = false for (const entry of scored) { if (entry.file.root.key !== root.key) continue const current = state[entry.file.rel] state[entry.file.rel] = { hitCount: (current?.hitCount ?? 0) + 1, lastHitAt: Date.now(), } dirty = true } if (dirty) { try { writeState(root, state) } catch { /* stats are best-effort */ } } } } if (scored.length === 0) { return { content: `没有匹配 "${input.query}" 的记忆。\n\n当前记忆库:\n${outlineText(filesByRoot(), roots())}`, } } const body = scored .map((entry) => { const tags = [ `[${entry.file.root.label}/${entry.file.rel}]`, entry.file.kind ? `(${entry.file.kind})` : "", entry.reasons.length ? ` ${entry.reasons.join(", ")}` : "", ].join("") return `${tags}\nscore ${entry.score.toFixed(2)}\n\n${entry.file.body}` }) .join("\n\n---\n\n") return { content: body } }, }) // memory_remember ------------------------------------------------------- editor.add({ name: "remember", description: [ "Record a durable memory: a user preference, an architectural decision and its reason,", "a non-obvious gotcha, or a project convention.", "Do NOT record what the code already shows, current task state, branch names, or file paths.", "Writes automatically refresh the category index and ROOT's routing table.", ].join(" "), input: { type: "object", properties: { category: { type: "string", description: "Which kind of memory. Use rules/ only for things the user declared absolute.", enum: categories, }, slug: { type: "string", description: "Stable kebab-case identifier. Reusing a slug updates that memory in place.", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", }, title: { type: "string", description: "Short title." }, summary: { type: "string", description: "ONE sentence under 100 chars. This is the only line shown in indexes — make it count.", }, highlight: { type: "string", description: "The single most important detail, shown as the 重点 column.", }, body: { type: "string", description: "The detail: context, the decision, the reason, the root cause." }, scope: { type: "string", enum: ["project", "global"], description: "Which root. Default project. Use global for preferences and knowledge that holds across projects.", }, loadWhen: { type: "array", items: { type: "string" }, description: "Scenarios or trigger words that should surface this memory. These build ROOT's routing table.", }, pinned: { type: "boolean", description: "Pin the memory: never decays, never superseded automatically. Use for things you stated as absolute.", }, supersedes: { type: "string", description: "Relative path of an older memory this replaces. The old one is kept for audit, not injected.", }, }, required: ["category", "slug", "title", "summary", "body"], additionalProperties: false, }, execute: async (input: { category: string slug: string title: string summary: string highlight?: string body: string scope?: string loadWhen?: string[] pinned?: boolean supersedes?: string }) => { const categoryError = validateCategory(input.category) if (categoryError) return { content: `拒绝写入:${categoryError}` } const slugError = validateSlug(input.slug) if (slugError) return { content: `拒绝写入:${slugError}` } const safeBody = redactSecrets(input.body) if (safeBody !== input.body) { return { content: "拒绝写入:正文里检测到疑似密钥(已定位)。记忆文件会进入 git,请改成描述性文字,例如“凭证放在环境变量 X”。", } } const root = input.scope === "global" || !project ? global : project const created = fs.existsSync(path.join(root.dir, input.category, `${input.slug}.md`)) const file = writeMemory(root, { category: input.category, slug: input.slug, title: input.title, summary: input.summary, highlight: input.highlight ?? "", body: safeBody, ...(input.loadWhen?.length ? { loadWhen: input.loadWhen } : {}), // Pass `false` through explicitly so an explicit unpin works; // leaving it undefined lets writeMemory keep an existing pin. ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.supersedes ? { supersedes: input.supersedes } : {}), }) if (input.supersedes) markSuperseded(root, input.supersedes, file.rel) // Promote the whole root's routing table when a new file appears, so // the outline the model sees stays complete. recompile() return { content: [ `${created ? "已更新" : "已写入"}记忆:${file.rel}(根:${root.label})`, "", `概述:${file.summary}`, file.loadWhen.length ? `触发场景:${file.loadWhen.join("、")}` : "触发场景:未设置(不会被路由表自动命中,用 memory_recall 按关键词取)", "", "已同步更新:该目录 index.md" + (file.loadWhen.length ? " 与 ROOT.md 路由表" : "") + "。", ].join("\n"), } }, }) // memory_forget ------------------------------------------------------ editor.add({ name: "forget", description: [ "Remove or supersede a memory that turned out to be wrong.", "Prefer `supersedes` when a newer fact replaces an older one — the old entry stays for audit.", "Use this when the user corrects you or a memory is no longer true.", ].join(" "), input: { type: "object", properties: { path: { type: "string", description: 'Relative path of the memory, e.g. "pitfalls/sqlite-cjk.md".', }, scope: { type: "string", enum: ["project", "global", "any"], description: "Which root to look in. Default any.", }, reason: { type: "string", description: "Why it is being forgotten. Recorded for audit." }, }, required: ["path", "reason"], additionalProperties: false, }, execute: async (input: { path: string; scope?: string; reason: string }) => { const target = input.path.replace(/^\/+/, "") if (!target.endsWith(".md") || target.includes("..")) { return { content: `拒绝:path 必须是根下的相对 .md 路径(收到 ${JSON.stringify(input.path)})` } } const pool = pickRoots(roots(), input.scope) for (const root of pool) { const abs = path.join(root.dir, target) if (!fs.existsSync(abs)) continue fs.rmSync(abs) recompile() return { content: `已删除 ${root.label}/${target}。\n理由:${input.reason}\n已同步重建 index.md 与 ROOT.md 路由表。` } } return { content: `未找到记忆 ${target}。\n现有记忆:\n${outlineText(filesByRoot(), roots())}` } }, }) // memory_inspect ------------------------------------------------------ editor.add({ name: "inspect", description: "Inspect the memory library: every root, every memory with its summary and trigger, plus index consistency checks. Use when tuning memory or explaining what is remembered.", input: { type: "object", properties: { detail: { type: "boolean", description: "Include per-memory hit counts and scores. Default false.", }, }, additionalProperties: false, }, execute: async (input: { detail?: boolean }) => { const lines: string[] = [] for (const root of roots()) { const files = filesByRoot().get(root.key) ?? [] const state = states(root) const goal = readGoal(root.dir) lines.push(`## 根:${root.label}(${root.scope})`) lines.push(`路径:${root.dir}${fs.existsSync(root.dir) ? "" : " ← 尚未创建"}`) if (goal) lines.push(`当前目标:${goal}`) if (files.length === 0) { lines.push("(空)") lines.push("") continue } lines.push("") for (const file of files) { const hits = state[file.rel] const superseded = file.fm.superseded_by ? ` [已被 ${String(file.fm.superseded_by)} 取代]` : "" lines.push(`- ${file.rel}${superseded}`) lines.push(` ${file.summary}`) if (file.loadWhen.length) lines.push(` 触发:${file.loadWhen.join("、")}`) if (file.pinned) lines.push(" 钉住(不衰减、不被取代)") if (input.detail) { lines.push( ` 命中 ${hits?.hitCount ?? 0} 次 · 最后命中 ${ hits?.lastHitAt ? new Date(hits.lastHitAt).toISOString().slice(0, 10) : "从未" }`, ) } } lines.push("") } // Consistency: the compiled tables must match what is on disk, or the // outline the model trusts is a lie. const problems: string[] = [] for (const root of roots()) { // A root nobody has written to yet is not a problem to report. if (!fs.existsSync(root.dir)) continue const files = scanRoot(root) const categories = [...new Set(files.map((f) => f.category))] for (const category of categories) { const indexPath = path.join(root.dir, category, "index.md") if (!fs.existsSync(indexPath)) { problems.push(`${root.label}/${category}/index.md 缺失`) continue } const indexText = fs.readFileSync(indexPath, "utf8") for (const file of files.filter((f) => f.category === category)) { if (!indexText.includes(`${file.slug}.md`)) { problems.push(`${root.label}/${category}/index.md 缺少 ${file.slug}.md`) } } } const rootPath = path.join(root.dir, "ROOT.md") if (!fs.existsSync(rootPath)) problems.push(`${root.label}/ROOT.md 缺失`) } lines.push(problems.length === 0 ? "索引一致性:正常" : `索引一致性:发现 ${problems.length} 个问题`) for (const problem of problems) lines.push(` - ${problem}`) // Runtime health of the two paths that have no other visible effect. lines.push("") const injection = (await ctx.storage.get("diag.lastInjection")) as Record | undefined const extraction = (await ctx.storage.get("diag.lastExtraction")) as Record | undefined lines.push("运行状态:") if (injection) { const when = new Date(Number(injection.at)).toISOString().slice(0, 19).replace("T", " ") lines.push( ` 注入:最近一次 ${when},${String(injection.chars)} 字符,内联 ${String(injection.bodies)} 条正文${injection.withinBudget === false ? " ⚠ 超出预算" : ""}`, ) lines.push(` 检索词:${String(injection.query)}`) } else { lines.push(" 注入:尚无记录(context hook 还没触发过)") } if (extraction) { const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ") lines.push(` 抽取:${when} → ${String(extraction.outcome)}`) if (extraction.reason) lines.push(` 原因:${String(extraction.reason)}`) if (extraction.model) lines.push(` 模型:${String(extraction.model)}`) } else { lines.push( extractOptions.enabled ? " 抽取:尚无记录(本会话还没到静默期,或轮数/内容不足)" : " 抽取:已关闭(autoExtract: false)", ) } return { content: lines.join("\n") } }, }) }) // -- automatic extraction on idle -------------------------------------- let extracting = false const controller = new AbortController() // How long a turn must be quiet before extraction counts it as finished. const settleMs = config.settleMs ?? 20_000 // `session.idle` is NOT delivered on this stream — verified by collecting // every event type that actually arrives. The turn-ending events below do // arrive, so they drive a debounce instead: the turn must go quiet for // `settleMs` before extraction runs, which is what "idle" meant anyway. const pending = new Map>() if (extractOptions.enabled) { void (async () => { try { for await (const event of ctx.event.subscribe({ signal: controller.signal })) { const record = event as unknown as { type?: string; data?: { sessionID?: string } } if (!EXTRACTION_TRIGGERS.has(String(record?.type))) continue const sessionID = record.data?.sessionID if (!sessionID) continue // Debounce: each new turn activity pushes the deadline out. Only a // genuine lull lets the timer fire, so extraction runs on a // finished turn rather than mid-stream. const existing = pending.get(sessionID) if (existing) clearTimeout(existing) pending.set( sessionID, setTimeout(() => { pending.delete(sessionID) if (extracting) return extracting = true void runExtraction(ctx, { sessionID, extractOptions, filesByRoot, project, global, }) .catch((error) => { console.error(`memory: extraction failed: ${(error as Error).message}`) }) .finally(() => { extracting = false }) }, settleMs), ) } } catch (error) { // Subscription teardown is normal on unload. if (!controller.signal.aborted) { console.error(`memory: event stream ended: ${(error as Error).message}`) } } })() } return () => { controller.abort() for (const timer of pending.values()) clearTimeout(timer) pending.clear() } }, }) // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[] { if (scope === "project") return roots.filter((r) => r.scope === "project") if (scope === "global") return roots.filter((r) => r.scope === "global") return roots } /** * Record plugin health for `memory_inspect` to show. * * The two asynchronous paths — the context hook and idle extraction — have no * other observable effect. Without this, "working" and "silently dead" look * identical from the outside. Throttled because the context hook runs on every * model request, including tool continuations. */ const lastDiagAt = new Map() async function noteDiag(ctx: Context, key: string, value: Record): Promise { const now = Date.now() const previous = lastDiagAt.get(key) ?? 0 if (now - previous < 2000) return lastDiagAt.set(key, now) try { await ctx.storage.set(`diag.${key}`, value) } catch { // Diagnostics must never break the hook that reports them. } } function outlineText(filesByRoot: Map, roots: MemoryRoot[]): string { const lines: string[] = [] for (const root of roots) { const files = filesByRoot.get(root.key) ?? [] if (files.length === 0) continue lines.push(`${root.label}:`) for (const file of files) lines.push(` ${file.rel} — ${file.summary}`) } return lines.join("\n") || "(记忆库为空)" } /** * The newest real user text in a request — the retrieval query. * * Harness-injected user messages ("", "The server restarted…") * are technically user turns but are not about the task. Retrieving on them * injects memories chosen by noise, so they are skipped. */ function latestUserText(messages: readonly unknown[]): string | undefined { for (let i = messages.length - 1; i >= 0; i--) { const record = messages[i] as { role?: string; content?: unknown } | null if (!record || record.role !== "user") continue const text = extractUserText(record.content) if (text && !isHarnessMessage(text)) return text } return undefined } const HARNESS_PREFIX = /^<(system-reminder|command-name|command-message|command-args)/i const HARNESS_RE = /^(The server restarted while you were working|Task tools haven't been used recently| if (record.type === "text" && typeof record.text === "string") parts.push(record.text) } } return parts.join(" ").trim() } async function runExtraction( ctx: Context, input: { sessionID: string extractOptions: ExtractOptions filesByRoot: () => Map project: MemoryRoot | undefined global: MemoryRoot }, ): Promise { const { sessionID, extractOptions } = input const messages = (await ctx.session.context({ sessionID })) as readonly unknown[] const turns = messages.filter((m) => { const role = (m as { role?: string }).role return role === "user" || role === "assistant" }) // Long enough to have learned something, short enough to stay cheap. if (turns.length < extractOptions.minTurns) { await noteDiag(ctx, "lastExtraction", { at: Date.now(), outcome: "skipped", reason: "会话轮数不足", turns: turns.length, minTurns: extractOptions.minTurns, }) return } const model = extractOptions.model ?? (await pickCheapModel(ctx)) if (!model) { // pickCheapModel failing is the difference between a working feature and // a silent no-op, so it is reported rather than swallowed. await noteDiag(ctx, "lastExtraction", { at: Date.now(), outcome: "failed", reason: "选不到可用模型(autoExtract 实际上处于关闭状态)", turns: turns.length, }) return } const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages)) if (transcript.length < 200) { await noteDiag(ctx, "lastExtraction", { at: Date.now(), outcome: "skipped", reason: "对话内容太短", turns: turns.length, transcriptChars: transcript.length, }) return } const existing = [...input.filesByRoot().values()].flat() const outcome = await extract({ ctx, model, transcript, existing }) if (outcome.candidates.length === 0) { for (const rejected of outcome.rejected) { console.log( `memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`, ) } await noteDiag(ctx, "lastExtraction", { at: Date.now(), outcome: "no-candidates", reason: outcome.rejected.length ? "候选全部被去重或密钥拦截" : "抽取器认为没有值得记的东西", rejected: outcome.rejected.length, model: `${model.providerID}/${model.id}`, }) return } const now = Date.now() const written: string[] = [] for (const candidate of outcome.candidates) { // Content that mentions this project's own paths belongs to the project. const text = `${candidate.title}\n${candidate.summary}\n${candidate.body}` const projectSpecific = input.project !== undefined && (text.includes(ctx.location.project.directory) || text.includes(ctx.location.directory)) const root = projectSpecific ? input.project! : input.global const file = writeMemory(root, { category: candidate.category, slug: candidate.slug, title: candidate.title, summary: candidate.summary, highlight: candidate.highlight ?? "", body: redactSecrets(candidate.body), kind: candidate.kind ?? (candidate.proposeRule ? "rule-proposal" : "fact"), ...(candidate.loadWhen?.length ? { loadWhen: candidate.loadWhen } : {}), }) written.push(`${root.label}/${file.rel}`) } if (written.length) { for (const root of [input.global, input.project].filter(Boolean) as MemoryRoot[]) { compileCategoryIndexes(root, scanRoot(root)) compileRoot(root, scanRoot(root)) } console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`) } await noteDiag(ctx, "lastExtraction", { at: Date.now(), outcome: written.length ? "extracted" : "all-filtered", model: `${model.providerID}/${model.id}`, turns: turns.length, written: written.length, rejected: outcome.rejected.length, paths: written, }) } /** * Pick the cheapest available model for extraction. Falls back to the session * default when cost metadata is unavailable, and disables extraction entirely * when neither is available. */ async function pickCheapModel(ctx: Context): Promise<{ providerID: string; id: string } | undefined> { try { const models = await ctx.model.list() let best: { ref: { providerID: string; id: string }; cost: number } | undefined for (const model of models) { if (model.disabled) continue // A reasoning-only or tiny-context model cannot do the job reliably. if ((model.limit?.context ?? 0) > 0 && model.limit.context < 8_000) continue const output = model.cost?.[0]?.output ?? 0 const input = model.cost?.[0]?.input ?? 0 const cost = Number(output) + Number(input) if (!best || cost < best.cost) { best = { ref: { providerID: model.providerID, id: model.id }, cost } } } if (best) return best.ref const fallback = await ctx.model.default() if (fallback) return { providerID: fallback.providerID, id: fallback.modelID } } catch { return undefined } return undefined }