From fc1c08b0f16d41abb1a13b66b7ba996136cae376 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 22 Sep 2026 10:54:29 +0530 Subject: [PATCH 1/2] fix(workspace): give synced memory blocks a title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory blocks synced to the workspace arrived with no heading in the UI. A create runs the backend's LLM extractor, which writes its own `title` into the record's metadata. The repair `update` that follows restores the block verbatim, and it replaces the metadata dict wholesale — so the extractor's heading is written and then immediately dropped. `buildMetadata` never supplied one of its own, leaving every synced block title-less. `blockTitle` borrows the block's leading markdown heading, which blocks conventionally open with, and falls back to the block id when the content starts with body text. Long headings are capped at 120 characters. The archive path is unaffected: it spreads the record's existing metadata before overriding, so a title already stored survives. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/altimate/workspace/memory-api.ts | 4 ++ .../src/altimate/workspace/memory-sync.ts | 26 ++++++++++++ .../altimate/workspace/memory-sync.test.ts | 40 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/packages/opencode/src/altimate/workspace/memory-api.ts b/packages/opencode/src/altimate/workspace/memory-api.ts index 1729f613cc..5ce0fce405 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 8e7b9c0b3c..a4d3fb7862 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -303,6 +303,31 @@ 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 { + for (const line of block.content.split("\n")) { + const heading = line.match(/^#{1,6}\s+(\S.*?)\s*$/) + if (heading) { + const text = heading[1] + return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1)}\u2026` : text + } + // 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 +336,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 a81510ae17..aa636f667d 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,37 @@ 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("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 () => { From e7f6d64883fd286336f6ab0ddc5ff916b4e903bd Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 22 Sep 2026 11:06:32 +0530 Subject: [PATCH 2/2] fix(workspace): harden blockTitle against real heading forms Addresses review findings on the title derivation: - Read past a training block's metadata comment. Training blocks always open with ``, so scanning raw content saw it as body text and fell back to the block id, costing exactly the blocks a title helps most. Reuses `stripTrainingMeta`, as `contentHash` already does. - Accept the up-to-three leading spaces CommonMark permits before the hashes. - Drop a closing run of hashes (`## Release notes ##`), which is decoration rather than title text. The run must be preceded by whitespace, so a heading ending in `C#` keeps its hash. - Truncate by code point. `slice` counts UTF-16 units, so a heading with an emoji on the 120-character boundary was cut mid-surrogate and rendered as a replacement character. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/altimate/workspace/memory-sync.ts | 16 ++++++++-- .../altimate/workspace/memory-sync.test.ts | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index a4d3fb7862..d27f24d5f8 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -316,11 +316,21 @@ const TITLE_MAX = 120 * that do not. */ export function blockTitle(block: MemoryBlock): string { - for (const line of block.content.split("\n")) { - const heading = line.match(/^#{1,6}\s+(\S.*?)\s*$/) + // 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] - return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1)}\u2026` : text + // 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 diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index aa636f667d..be9ee7afe0 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -393,6 +393,35 @@ describe("blockTitle", () => { 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}` }))