diff --git a/packages/opencode/src/altimate/workspace/memory-api.ts b/packages/opencode/src/altimate/workspace/memory-api.ts index 1729f613c..5ce0fce40 100644 --- a/packages/opencode/src/altimate/workspace/memory-api.ts +++ b/packages/opencode/src/altimate/workspace/memory-api.ts @@ -52,6 +52,10 @@ export interface MirrorMetadata { visibility: "private" block_created: string block_updated: string + /** Heading shown for the record in the workspace UI. The create's extractor + * writes its own, but the repair ``update`` replaces the metadata dict + * wholesale, so a synced block has no heading unless we supply one. */ + title?: string /** Absent for global blocks — that is what makes them span workspaces. */ datamate_id?: string datamate_name?: string diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 8e7b9c0b3..d27f24d5f 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -303,6 +303,41 @@ export function decodeTags(raw: unknown): string[] { .filter(Boolean) } +/** Longest heading we will mirror. A block may be up to MEMORY_MAX_BLOCK_SIZE, + * and its first line can be most of that. */ +const TITLE_MAX = 120 + +/** Heading for a synced block. + * + * A create runs the extractor, which writes its own `title`, but the repair + * `update` replaces the metadata dict wholesale — so without this the record + * reaches the workspace with no heading at all. Blocks conventionally + * open with a markdown heading; the block id is a readable fallback for those + * that do not. + */ +export function blockTitle(block: MemoryBlock): string { + // A training block opens with its metadata comment; the heading is after it. + for (const line of stripTrainingMeta(block.content).split("\n")) { + // CommonMark: up to three leading spaces, and a closing run of hashes that + // is decoration rather than title text. The closing run must be preceded by + // whitespace, so a heading like `# C#` keeps its hash. (stripTrainingMeta + // trims, so deeper indentation on the first line is gone before we look — + // a usable title beats falling back to the id over leading whitespace.) + const heading = line.match(/^ {0,3}#{1,6}\s+(\S.*?)(?:\s+#+)?\s*$/) + if (heading) { + const text = heading[1] + // Count code points, not UTF-16 units: slicing mid-surrogate leaves a + // lone half that renders as a replacement character in the workspace. + const points = Array.from(text) + if (points.length <= TITLE_MAX) return text + return `${points.slice(0, TITLE_MAX - 1).join("")}\u2026` + } + // Content that opens with body text has no heading to borrow. + if (line.trim()) break + } + return block.id +} + export function buildMetadata(block: MemoryBlock, binding: CachedBinding | null): MirrorMetadata { const meta: MirrorMetadata = { source: MIRROR_SOURCE, @@ -311,6 +346,7 @@ export function buildMetadata(block: MemoryBlock, binding: CachedBinding | null) visibility: "private", block_created: block.created, block_updated: block.updated, + title: blockTitle(block), } // JSON, not a comma join: a tag containing a comma split into two on read. if (block.tags.length > 0) meta.block_tags = JSON.stringify(block.tags) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index a81510ae1..be9ee7afe 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -40,6 +40,7 @@ const { backfill, belongsHere, buildMetadata, + blockTitle, hydrate, isEnabled, memoryEnabledCached, @@ -332,6 +333,14 @@ describe("buildMetadata", () => { expect(buildMetadata(block(), null).visibility).toBe("private") }) + test("a heading is always supplied, so the record is not title-less", () => { + // The create's extractor writes a title, but the repair update() replaces + // the metadata dict wholesale — without one here the record reaches the + // workspace with no heading. + const meta = buildMetadata(block({ content: "# Staging Model Convention\n\n- prefix stg_" }), null) + expect(meta.title).toBe("Staging Model Convention") + }) + test("created and updated timestamps are carried", () => { const meta = buildMetadata(block({ created: NOW, updated: NOW }), null) expect(meta.block_created).toBe(NOW) @@ -361,6 +370,66 @@ function gatedFetch() { } } +describe("blockTitle", () => { + test("uses the block's leading markdown heading", () => { + expect(blockTitle(block({ content: "# Warehouse access\n\nbody" }))).toBe("Warehouse access") + }) + + test("accepts any heading level and trims surrounding space", () => { + expect(blockTitle(block({ content: "### Deploy steps \nbody" }))).toBe("Deploy steps") + }) + + test("skips blank lines before the heading", () => { + expect(blockTitle(block({ content: "\n\n## Naming rules\nbody" }))).toBe("Naming rules") + }) + + test("falls back to the block id when the content opens with body text", () => { + // Borrowing a body line would produce a heading the user never wrote. + const b = block({ id: "warehouse/snowflake", content: "Snowflake account is acme-prod.\n\n# Later heading" }) + expect(blockTitle(b)).toBe("warehouse/snowflake") + }) + + test("falls back to the block id for a hash with no heading text", () => { + expect(blockTitle(block({ id: "a/b", content: "#hashtag not a heading" }))).toBe("a/b") + }) + + test("reads past a training block's metadata comment to its heading", () => { + // Training blocks always open with this comment, so scanning raw content + // sees it as body text and falls back to the id. + const content = "\n# Naming rules\n\nbody" + expect(blockTitle(block({ id: "t/1", content }))).toBe("Naming rules") + }) + + test("allows the three leading spaces CommonMark permits", () => { + expect(blockTitle(block({ content: " # Indented heading\nbody" }))).toBe("Indented heading") + }) + + test("drops a closing run of hashes", () => { + expect(blockTitle(block({ content: "## Release notes ##\nbody" }))).toBe("Release notes") + }) + + test("keeps a hash that is part of the heading text", () => { + // The closing run must be preceded by whitespace, so this is not one. + expect(blockTitle(block({ content: "# Style guide for C#\nbody" }))).toBe("Style guide for C#") + }) + + test("truncating never splits an emoji into a lone surrogate", () => { + // slice() counts UTF-16 units; cutting mid-pair renders as U+FFFD. + const content = `# ${"x".repeat(118)}\u{1F600}${"y".repeat(10)}` + const title = blockTitle(block({ content })) + expect(Array.from(title).length).toBe(120) + expect(title).toContain("\u{1F600}") + expect(title.isWellFormed()).toBe(true) + }) + + test("truncates a heading longer than the cap", () => { + const long = "x".repeat(200) + const title = blockTitle(block({ content: `# ${long}` })) + expect(title.length).toBe(120) + expect(title.endsWith("\u2026")).toBe(true) + }) +}) + // ── write path ────────────────────────────────────────────────────────────── describe("mirrorBlock", () => { test("flushPendingMirrors waits for a mirror a short-lived process would abandon (#1332)", async () => {