diff --git a/src/functions/obsidian-export.ts b/src/functions/obsidian-export.ts index c9a0d29fa..0e8194813 100644 --- a/src/functions/obsidian-export.ts +++ b/src/functions/obsidian-export.ts @@ -30,6 +30,29 @@ function sanitize(name: string): string { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").slice(0, 100); } +// #729: every record helper used to crash on null/undefined fields, +// poisoning the whole export. These helpers stay strict about types but +// return sensible fallbacks instead of throwing. +function hasExportId( + item: T | null | undefined, +): item is T & { id: string } { + return !!item && typeof (item as { id?: unknown }).id === "string" && (item as { id: string }).id.length > 0; +} + +function safeArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function safeString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function safeTimestamp(value: unknown): number { + if (typeof value !== "string") return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + function toFrontmatter(obj: Record): string { const lines = ["---"]; for (const [key, value] of Object.entries(obj)) { @@ -45,6 +68,12 @@ function toFrontmatter(obj: Record): string { } function memoryToMd(m: Memory): string { + const concepts = safeArray(m.concepts); + const files = safeArray(m.files); + const relatedIds = safeArray(m.relatedIds); + const supersedes = safeArray(m.supersedes); + const title = safeString(m.title, m.id); + const fm = toFrontmatter({ id: m.id, type: m.type, @@ -52,39 +81,46 @@ function memoryToMd(m: Memory): string { updated: m.updatedAt, strength: m.strength, version: m.version, - concepts: m.concepts, - files: m.files, + concepts, + files, }); - const related = (m.relatedIds || []) - .map((id) => `- [[${id}]]`) - .join("\n"); - const supersedes = (m.supersedes || []) + const relatedLines = relatedIds.map((id) => `- [[${id}]]`).join("\n"); + const supersedesLines = supersedes .map((id) => `- [[${id}]] (superseded)`) .join("\n"); const sections = [ fm, "", - `# ${m.title}`, + `# ${title}`, "", - m.content, + safeString(m.content), ]; - if (m.concepts.length > 0) { - sections.push("", "## Concepts", m.concepts.map((c) => `#${c.replace(/\s+/g, "-")}`).join(" ")); + if (concepts.length > 0) { + sections.push( + "", + "## Concepts", + concepts.map((c) => `#${c.replace(/\s+/g, "-")}`).join(" "), + ); } - if (related) { - sections.push("", "## Related", related); + if (relatedLines) { + sections.push("", "## Related", relatedLines); } - if (supersedes) { - sections.push("", "## Supersedes", supersedes); + if (supersedesLines) { + sections.push("", "## Supersedes", supersedesLines); } return sections.join("\n"); } function lessonToMd(l: Lesson): string { + const tags = safeArray(l.tags); + const sourceIds = safeArray(l.sourceIds); + const content = safeString(l.content); + const headline = content ? content.slice(0, 80) : l.id; + const fm = toFrontmatter({ id: l.id, type: "lesson", @@ -94,27 +130,29 @@ function lessonToMd(l: Lesson): string { created: l.createdAt, updated: l.updatedAt, project: l.project, - tags: l.tags, + tags, decayRate: l.decayRate, }); - const sourceLinks = l.sourceIds - .map((id) => `- [[${id}]]`) - .join("\n"); + const sourceLinks = sourceIds.map((id) => `- [[${id}]]`).join("\n"); const sections = [ fm, "", - `# Lesson: ${l.content.slice(0, 80)}`, + `# Lesson: ${headline}`, "", - l.content, + content, ]; if (l.context) { sections.push("", "## Context", l.context); } - if (l.tags.length > 0) { - sections.push("", "## Tags", l.tags.map((t) => `#${t.replace(/\s+/g, "-")}`).join(" ")); + if (tags.length > 0) { + sections.push( + "", + "## Tags", + tags.map((t) => `#${t.replace(/\s+/g, "-")}`).join(" "), + ); } if (sourceLinks) { sections.push("", "## Sources", sourceLinks); @@ -124,35 +162,40 @@ function lessonToMd(l: Lesson): string { } function crystalToMd(c: Crystal): string { + const keyOutcomes = safeArray(c.keyOutcomes); + const lessons = safeArray(c.lessons); + const filesAffected = safeArray(c.filesAffected); + const sourceActionIds = safeArray(c.sourceActionIds); + const narrative = safeString(c.narrative); + const headline = narrative ? narrative.slice(0, 80) : c.id; + const fm = toFrontmatter({ id: c.id, type: "crystal", created: c.createdAt, project: c.project, sessionId: c.sessionId, - filesAffected: c.filesAffected, + filesAffected, }); - const actionLinks = c.sourceActionIds - .map((id) => `- [[${id}]]`) - .join("\n"); + const actionLinks = sourceActionIds.map((id) => `- [[${id}]]`).join("\n"); const sections = [ fm, "", - `# Crystal: ${c.narrative.slice(0, 80)}`, + `# Crystal: ${headline}`, "", - c.narrative, + narrative, "", "## Key Outcomes", - ...c.keyOutcomes.map((o) => `- ${o}`), + ...keyOutcomes.map((o) => `- ${o}`), ]; - if (c.lessons.length > 0) { - sections.push("", "## Lessons", ...c.lessons.map((l) => `- ${l}`)); + if (lessons.length > 0) { + sections.push("", "## Lessons", ...lessons.map((l) => `- ${l}`)); } - if (c.filesAffected.length > 0) { - sections.push("", "## Files", ...c.filesAffected.map((f) => `- \`${f}\``)); + if (filesAffected.length > 0) { + sections.push("", "## Files", ...filesAffected.map((f) => `- \`${f}\``)); } if (actionLinks) { sections.push("", "## Source Actions", actionLinks); @@ -162,12 +205,17 @@ function crystalToMd(c: Crystal): string { } function sessionToMd(s: Session): string { + const project = safeString(s.project, "unknown"); + const status = safeString(s.status, "unknown"); + const startedAt = safeString(s.startedAt, ""); + const cwd = safeString(s.cwd, ""); + const fm = toFrontmatter({ id: s.id, type: "session", - project: s.project, - status: s.status, - started: s.startedAt, + project, + status, + started: startedAt || undefined, ended: s.endedAt, observations: s.observationCount, }); @@ -175,13 +223,13 @@ function sessionToMd(s: Session): string { return [ fm, "", - `# Session: ${s.project}`, + `# Session: ${project}`, "", - `**Status:** ${s.status}`, - `**Started:** ${s.startedAt}`, + `**Status:** ${status}`, + startedAt ? `**Started:** ${startedAt}` : "", s.endedAt ? `**Ended:** ${s.endedAt}` : "", - `**Observations:** ${s.observationCount}`, - `**CWD:** \`${s.cwd}\``, + `**Observations:** ${s.observationCount ?? 0}`, + cwd ? `**CWD:** \`${cwd}\`` : "", ] .filter(Boolean) .join("\n"); @@ -232,111 +280,153 @@ export function registerObsidianExportFunction( sessions: join(vaultDir, "sessions"), }; - await Promise.all( - Object.values(dirs).map((dir) => mkdir(dir, { recursive: true })), - ); - - const stats = { memories: 0, lessons: 0, crystals: 0, sessions: 0 }; - const errors: ExportError[] = []; - const memoryMoc: string[] = []; - const lessonMoc: string[] = []; - const crystalMoc: string[] = []; - const sessionMoc: string[] = []; - - const [memories, lessons, crystals, sessions] = await Promise.all([ - exportTypes.has("memories") ? kv.list(KV.memories) : Promise.resolve([] as Memory[]), - exportTypes.has("lessons") ? kv.list(KV.lessons) : Promise.resolve([] as Lesson[]), - exportTypes.has("crystals") ? kv.list(KV.crystals) : Promise.resolve([] as Crystal[]), - exportTypes.has("sessions") ? kv.list(KV.sessions) : Promise.resolve([] as Session[]), - ]); - - for (const m of memories.filter((m) => m.isLatest)) { - const filename = `${sanitize(m.id)}.md`; - const filepath = join(dirs.memories, filename); - try { - await writeFile(filepath, memoryToMd(m)); - stats.memories++; - memoryMoc.push(`- [[memories/${sanitize(m.id)}|${m.title}]] (${m.type}, strength: ${m.strength})`); - } catch (err) { - errors.push({ id: m.id, path: filepath, error: err instanceof Error ? err.message : String(err) }); + // Outer try/catch keeps the function from ever throwing out to the + // iii engine's HTTP serializer; #729 surfaced an unhandled + // TypeError as `{"error":"[object Object]"}`. With this guard the + // worst case is `{success: false, error: }`. + try { + await Promise.all( + Object.values(dirs).map((dir) => mkdir(dir, { recursive: true })), + ); + + const stats = { memories: 0, lessons: 0, crystals: 0, sessions: 0 }; + const errors: ExportError[] = []; + const memoryMoc: string[] = []; + const lessonMoc: string[] = []; + const crystalMoc: string[] = []; + const sessionMoc: string[] = []; + + const [memories, lessons, crystals, sessions] = await Promise.all([ + exportTypes.has("memories") ? kv.list(KV.memories) : Promise.resolve([] as Memory[]), + exportTypes.has("lessons") ? kv.list(KV.lessons) : Promise.resolve([] as Lesson[]), + exportTypes.has("crystals") ? kv.list(KV.crystals) : Promise.resolve([] as Crystal[]), + exportTypes.has("sessions") ? kv.list(KV.sessions) : Promise.resolve([] as Session[]), + ]); + + for (const m of memories.filter( + (m): m is Memory & { id: string } => hasExportId(m) && m.isLatest === true, + )) { + const filename = `${sanitize(m.id)}.md`; + const filepath = join(dirs.memories, filename); + try { + await writeFile(filepath, memoryToMd(m)); + stats.memories++; + memoryMoc.push( + `- [[memories/${sanitize(m.id)}|${safeString(m.title, m.id)}]] (${m.type}, strength: ${m.strength ?? 0})`, + ); + } catch (err) { + errors.push({ + id: m.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } } - } - for (const l of lessons.filter((l) => !l.deleted)) { - const filename = `${sanitize(l.id)}.md`; - const filepath = join(dirs.lessons, filename); - try { - await writeFile(filepath, lessonToMd(l)); - stats.lessons++; - lessonMoc.push(`- [[lessons/${sanitize(l.id)}|${l.content.slice(0, 60)}]] (confidence: ${l.confidence})`); - } catch (err) { - errors.push({ id: l.id, path: filepath, error: err instanceof Error ? err.message : String(err) }); + for (const l of lessons.filter( + (l): l is Lesson & { id: string } => hasExportId(l) && !l.deleted, + )) { + const filename = `${sanitize(l.id)}.md`; + const filepath = join(dirs.lessons, filename); + try { + await writeFile(filepath, lessonToMd(l)); + stats.lessons++; + const headline = safeString(l.content).slice(0, 60) || l.id; + lessonMoc.push( + `- [[lessons/${sanitize(l.id)}|${headline}]] (confidence: ${l.confidence ?? 0})`, + ); + } catch (err) { + errors.push({ + id: l.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } } - } - for (const c of crystals) { - const filename = `${sanitize(c.id)}.md`; - const filepath = join(dirs.crystals, filename); - try { - await writeFile(filepath, crystalToMd(c)); - stats.crystals++; - crystalMoc.push(`- [[crystals/${sanitize(c.id)}|${c.narrative.slice(0, 60)}]]`); - } catch (err) { - errors.push({ id: c.id, path: filepath, error: err instanceof Error ? err.message : String(err) }); + for (const c of crystals.filter(hasExportId)) { + const filename = `${sanitize(c.id)}.md`; + const filepath = join(dirs.crystals, filename); + try { + await writeFile(filepath, crystalToMd(c)); + stats.crystals++; + const headline = safeString(c.narrative).slice(0, 60) || c.id; + crystalMoc.push(`- [[crystals/${sanitize(c.id)}|${headline}]]`); + } catch (err) { + errors.push({ + id: c.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } } - } - const recent = sessions - .sort( - (a, b) => - new Date(b.startedAt).getTime() - - new Date(a.startedAt).getTime(), - ) - .slice(0, 50); - for (const s of recent) { - const filename = `${sanitize(s.id)}.md`; - const filepath = join(dirs.sessions, filename); - try { - await writeFile(filepath, sessionToMd(s)); - stats.sessions++; - sessionMoc.push(`- [[sessions/${sanitize(s.id)}|${s.project} (${s.status})]]`); - } catch (err) { - errors.push({ id: s.id, path: filepath, error: err instanceof Error ? err.message : String(err) }); + const recent = sessions + .filter(hasExportId) + .sort((a, b) => safeTimestamp(b.startedAt) - safeTimestamp(a.startedAt)) + .slice(0, 50); + for (const s of recent) { + const filename = `${sanitize(s.id)}.md`; + const filepath = join(dirs.sessions, filename); + try { + await writeFile(filepath, sessionToMd(s)); + stats.sessions++; + sessionMoc.push( + `- [[sessions/${sanitize(s.id)}|${safeString(s.project, "unknown")} (${safeString(s.status, "unknown")})]]`, + ); + } catch (err) { + errors.push({ + id: s.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } } - } - const exportedAt = new Date().toISOString(); - const moc = [ - "---", - "type: moc", - `exported: ${exportedAt}`, - "---", - "", - "# agentmemory vault", - "", - `Exported: ${exportedAt}`, - "", - `## Memories (${stats.memories})`, - ...memoryMoc, - "", - `## Lessons (${stats.lessons})`, - ...lessonMoc, - "", - `## Crystals (${stats.crystals})`, - ...crystalMoc, - "", - `## Sessions (${stats.sessions})`, - ...sessionMoc, - ].join("\n"); - - await writeFile(join(vaultDir, "MOC.md"), moc); - - await recordAudit(kv, "obsidian_export", "mem::obsidian-export", [], { - vaultDir, - stats, - }); - - return { success: true, exported: stats, errors: errors.length > 0 ? errors : undefined, vaultDir }; + const exportedAt = new Date().toISOString(); + const moc = [ + "---", + "type: moc", + `exported: ${exportedAt}`, + "---", + "", + "# agentmemory vault", + "", + `Exported: ${exportedAt}`, + "", + `## Memories (${stats.memories})`, + ...memoryMoc, + "", + `## Lessons (${stats.lessons})`, + ...lessonMoc, + "", + `## Crystals (${stats.crystals})`, + ...crystalMoc, + "", + `## Sessions (${stats.sessions})`, + ...sessionMoc, + ].join("\n"); + + await writeFile(join(vaultDir, "MOC.md"), moc); + + await recordAudit(kv, "obsidian_export", "mem::obsidian-export", [], { + vaultDir, + stats, + }); + + return { + success: true, + exported: stats, + errors: errors.length > 0 ? errors : undefined, + vaultDir, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + vaultDir, + }; + } }, ); } diff --git a/test/obsidian-export.test.ts b/test/obsidian-export.test.ts index a02cc8a4c..31394bca9 100644 --- a/test/obsidian-export.test.ts +++ b/test/obsidian-export.test.ts @@ -297,4 +297,137 @@ describe("Obsidian Export", () => { expect(result.success).toBe(true); expect(result.errors).toBeUndefined(); }); + + // #729: any record missing an id used to crash `sanitize(undefined.id)` + // outside the per-record try, escaping the handler entirely and + // returning HTTP 500 `{"error":"[object Object]"}` with zero files + // written. The hardened loops filter id-less records and the outer + // try/catch keeps thrown errors from ever reaching the HTTP serializer. + it("skips records that are missing an id and keeps exporting the rest", async () => { + await kv.set("mem:memories", "orphan-memory", { ...makeMemory("mem_missing"), id: undefined } as any); + await kv.set("mem:lessons", "orphan-lesson", { ...makeLesson("lsn_missing"), id: undefined } as any); + await kv.set("mem:crystals", "orphan-crystal", { ...makeCrystal("crys_missing"), id: undefined } as any); + await kv.set("mem:sessions", "orphan-session", { ...makeSession("ses_missing"), id: undefined } as any); + await kv.set("mem:sessions", "valid-session", makeSession("ses_valid")); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + errors?: unknown[]; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(0); + expect(result.exported.lessons).toBe(0); + expect(result.exported.crystals).toBe(0); + expect(result.exported.sessions).toBe(1); + expect(result.errors).toBeUndefined(); + expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false); + expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true); + }); + + it("tolerates malformed startedAt timestamps when sorting sessions", async () => { + await kv.set("mem:sessions", "ses_recent", { ...makeSession("ses_recent"), startedAt: "2026-04-02T00:00:00Z" }); + await kv.set("mem:sessions", "ses_bad", { ...makeSession("ses_bad"), startedAt: "not-a-date" } as any); + await kv.set("mem:sessions", "ses_undef", { ...makeSession("ses_undef"), startedAt: undefined } as any); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + }; + + expect(result.success).toBe(true); + expect(result.exported.sessions).toBe(3); + }); + + it("exports memories whose optional array fields are missing or null", async () => { + const incomplete = { + ...makeMemory("mem_incomplete"), + concepts: undefined, + files: null, + relatedIds: null, + supersedes: undefined, + } as any; + await kv.set("mem:memories", incomplete.id, incomplete); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(1); + + const memFile = [...writtenFiles.entries()].find(([k]) => + k.includes("memories/mem_incomplete.md"), + ); + expect(memFile).toBeDefined(); + const content = memFile![1]; + expect(content).toContain("# Memory mem_incomplete"); + expect(content).not.toContain("## Related"); + expect(content).not.toContain("## Supersedes"); + }); + + it("falls back to the id when title / content / narrative are missing", async () => { + await kv.set("mem:memories", "mem_no_title", { + ...makeMemory("mem_no_title"), + title: undefined, + content: undefined, + } as any); + await kv.set("mem:lessons", "lsn_no_content", { + ...makeLesson("lsn_no_content"), + content: undefined, + } as any); + await kv.set("mem:crystals", "crys_no_narr", { + ...makeCrystal("crys_no_narr"), + narrative: undefined, + } as any); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(1); + expect(result.exported.lessons).toBe(1); + expect(result.exported.crystals).toBe(1); + + const memFile = [...writtenFiles.entries()].find(([k]) => + k.includes("memories/mem_no_title.md"), + ); + expect(memFile![1]).toContain("# mem_no_title"); + + const lsnFile = [...writtenFiles.entries()].find(([k]) => + k.includes("lessons/lsn_no_content.md"), + ); + expect(lsnFile![1]).toContain("# Lesson: lsn_no_content"); + + const crysFile = [...writtenFiles.entries()].find(([k]) => + k.includes("crystals/crys_no_narr.md"), + ); + expect(crysFile![1]).toContain("# Crystal: crys_no_narr"); + }); + + it("never throws out to the engine — returns {success: false, error: } on internal failure", async () => { + // Force mkdir to throw to simulate an unexpected runtime error so we + // can assert the outer try/catch turns it into a serializable error. + const fsModule = await import("node:fs/promises"); + const original = fsModule.mkdir; + (fsModule.mkdir as any) = vi.fn(async () => { + throw new TypeError("simulated disk failure"); + }); + + try { + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + error?: string; + }; + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.error).toContain("simulated disk failure"); + } finally { + (fsModule.mkdir as any) = original; + } + }); });