Compare commits

..

6 Commits

Author SHA1 Message Date
claude_code 895173b176 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-07-02 19:31:05 +03:00
vvzvlad 45d5ae1601 Merge pull request '[feature][ai-chat] Наблюдаемость page_changed-диффа в истории/экспорте + усиление ноты против перезаписи правок' (#288) from feature/ai-chat-page-change-observability into develop
Reviewed-on: #288
2026-07-02 19:30:53 +03:00
claude_code ec30e6c08a docs(agents): update Gitea MCP workflow details in agents guide
Add clarification that pushing commits is git‑native while PR creation uses the Gitea MCP, replace curl/tea examples with MCP method calls, update API table entries, and revise issue creation instructions accordingly.
2026-07-02 19:20:56 +03:00
vvzvlad af481d401a Merge pull request 'feat(editor): center inline image rows by default via CSS :has()' (#295) from image-inline-center into develop 2026-07-02 16:50:32 +03:00
agent_coder 438ef091f9 fix(#288 review): markdown-safe-escape the untrusted page title in chat export
F1: pc.title (untrusted cross-user page title) was interpolated raw into the
markdown export heading. Reusing escapeAttr alone (the prompt sink's XML-attribute
sanitizer, strips < > ") is insufficient here because the sink is MARKDOWN: link
/image syntax survives, so a title like ![x](http://evil) or [phish](http://evil)
injects a remote image / clickable link into the downloaded .md disguised as a
trusted system annotation. Add markdownHeadingSafe() = escapeAttr() + backslash-
escape [ and ] (disables both [text](url) and ![text](url); a bare (url) is inert).
F2: cover the title branch — a title that collapses to empty via escapeAttr falls
to the bare heading (no ("")), and a link/image-injection title is neutralized
(non-vacuous vs the escapeAttr-only version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:46:44 +03:00
claude_code c39fab70c1 feat(ai-chat): persist page-change diff to history and harden stale-page note
The #274 page_changed marker lived only in the ephemeral system prompt, so the
diff the agent saw was invisible in the chat export/history, and the note was
too weak — the agent still overwrote the user's manual edits with a full-page
replace.

- Persist the diff the agent saw as metadata.pageChanged on the assistant row
  (flushAssistant), threaded into all five flush call sites in stream(). Model
  replay (rowToUiMessage/rowParts) reads only metadata.parts, so the sibling
  never re-injects the note into the model context on later turns.
- Render the persisted diff as a labelled block (en/ru) before the message body
  in the server-side Markdown export (chat-markdown.util.ts).
- Strengthen PAGE_CHANGED_NOTE: mandate a fresh getPage re-read and targeted
  edits (editPageText/patchNode/insertNode/deleteNode) instead of a whole-page
  replace, and never revert or overwrite the user's edits.

Tests: prompt, export and service specs updated; 114 pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:31:41 +03:00
7 changed files with 314 additions and 29 deletions
+32 -21
View File
@@ -72,7 +72,10 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
### 4. Push and PR to develop ### 4. Push and PR to develop
PRs always target `develop`. The `claude_code` password lives in the macOS PRs always target `develop`. Two different mechanisms are involved: **pushing
commits is git-native** (the Gitea MCP cannot push local git history, so the
branch is still pushed with `git push`), while **the PR itself is opened through
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
keychain as a **generic password** under service `gitea-claude-code` (do not keychain as a **generic password** under service `gitea-claude-code` (do not
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
conflict with the owner's account in the git credential helper): conflict with the owner's account in the git credential helper):
@@ -94,18 +97,24 @@ git remote set-url gitea "$ORIG_URL"
unset AGENT_PASS SAFE_PASS unset AGENT_PASS SAFE_PASS
``` ```
The PR is created via the Gitea REST API (Basic Auth as `claude_code`): The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea`
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
Call `pull_request_write` with:
```bash - `method: "create"`
curl -s -X POST \ - `owner: "vvzvlad"`, `repo: "gitmost"`
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \ - `base: "develop"`, `head: "<branch>"`
-H "Content-Type: application/json" \ - `title`, `body` — in the body: what was done, what is out of scope,
-d @pr_body.json \ verification results (tsc/lint/tests).
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
```
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out Manage and read PRs through the same server: `list_pull_requests`,
of scope, verification results (tsc/lint/tests). `pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
`pull_request_review_write`.
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
with `get_me`), a different account from the `claude_code` used for git
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
the MCP account; the commits themselves stay authored as `claude_code`.
> If push fails with `User permission denied for writing`, then `claude_code` > If push fails with `User permission denied for writing`, then `claude_code`
> lacks collaborator rights on the repo. Ask the owner to add them (once, via > lacks collaborator rights on the repo. Ask the owner to add them (once, via
@@ -152,23 +161,25 @@ below.
| Agent user (Gitea/git) | `claude_code` | | Agent user (Gitea/git) | `claude_code` |
| Agent email | `claude_code@vvzvlad.xyz` | | Agent email | `claude_code@vvzvlad.xyz` |
| Keychain password | `security find-generic-password -s gitea-claude-code -w` | | Keychain password | `security find-generic-password -s gitea-claude-code -w` |
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) | | Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
| Base branch | `develop` | | Base branch | `develop` |
| `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI | | `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `upstream` | The original Docmost — **never push** | | `upstream` | The original Docmost — **never push** |
## Creating issues (Gitea `tea` CLI) ## Creating issues (Gitea MCP)
Issues are filed with the official Gitea CLI `tea`, already logged in as File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
`claude_code` (`tea logins list` shows the `gitea` login as default): `issue_write` with:
```bash - `method: "create"`
tea issues create --repo vvzvlad/gitmost --labels feature \ - `owner: "vvzvlad"`, `repo: "gitmost"`
--title '<title>' --description "$(cat body.md)" - `title`, `body`
``` - `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
such as `feature` to its id first with `label_read` (`method: "list"`), then
pass e.g. `labels: [<id>]`.
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not** Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
> `--body` — passing `--body` fails with `flag provided but not defined: -body`. authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
--- ---
@@ -303,6 +303,11 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
expect(prompt).toContain(NOTE_MARKER); expect(prompt).toContain(NOTE_MARKER);
expect(prompt).toContain('-old line'); expect(prompt).toContain('-old line');
expect(prompt).toContain('+new line'); expect(prompt).toContain('+new line');
// Strengthened note (#274): instructs a fresh re-read via getPage and steers
// the agent toward small, targeted edits instead of a full-page overwrite.
expect(prompt).toContain('getPage');
expect(prompt.toLowerCase()).toContain('targeted');
expect(prompt).toContain('editPageText');
// Inside the safety sandwich: the trailing SAFETY block follows the note. // Inside the safety sandwich: the trailing SAFETY block follows the note.
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan( expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
prompt.indexOf(NOTE_MARKER), prompt.indexOf(NOTE_MARKER),
+11 -5
View File
@@ -85,11 +85,17 @@ const INTERRUPT_NOTE =
const PAGE_CHANGED_NOTE = const PAGE_CHANGED_NOTE =
'NOTE: The user edited the open page AFTER your last response in this ' + 'NOTE: The user edited the open page AFTER your last response in this ' +
'conversation, so any copy of that page you produced or remember from earlier ' + 'conversation, so any copy of that page you produced or remember from earlier ' +
'is now STALE. The unified diff below shows exactly what changed since you last ' + 'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
'spoke (lines starting with "-" were removed, "+" were added) and is the source ' + 're-read its current content with the getPage tool and base your work on that ' +
'of truth. Preserve the user\'s edits: build on the current page, do not revert ' + 'live version — never on your earlier copy or on the transcript. The unified ' +
'or overwrite their changes. If you need the full up-to-date page, re-read it ' + 'diff below shows exactly what the user changed since you last spoke (lines ' +
'with the getPage tool before editing.'; 'starting with "-" were removed, "+" were added) and is the source of truth. ' +
'Preserve every one of the user\'s edits: make the smallest change that ' +
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
'truly unavoidable, start from the current getPage content and carry over all ' +
'of the user\'s edits.';
/** /**
* Sanitize a value interpolated into a prompt XML-ish attribute (e.g. * Sanitize a value interpolated into a prompt XML-ish attribute (e.g.
@@ -356,6 +356,32 @@ describe('flushAssistant', () => {
expect(flushed.toolCalls).not.toBeNull(); expect(flushed.toolCalls).not.toBeNull();
expect(flushed.metadata.error).toBe('boom'); expect(flushed.metadata.error).toBe('boom');
}); });
// #274 observability: the page-change diff the agent saw this turn is persisted
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
// the diff is empty/whitespace or the arg is not supplied.
it('persists metadata.pageChanged when a non-empty diff was injected', () => {
const f = flushAssistant([], '', 'completed', {
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
});
expect(f.metadata.pageChanged).toEqual({
title: 'Doc',
diff: '@@ -1 +1 @@\n-old\n+new',
});
});
it('omits metadata.pageChanged for an empty/whitespace diff or a missing arg', () => {
const whitespace = flushAssistant([], '', 'completed', {
pageChanged: { title: 'Doc', diff: ' \n ' },
});
expect('pageChanged' in whitespace.metadata).toBe(false);
const nullArg = flushAssistant([], '', 'completed', { pageChanged: null });
expect('pageChanged' in nullArg.metadata).toBe(false);
const omitted = flushAssistant([], '', 'streaming');
expect('pageChanged' in omitted.metadata).toBe(false);
});
}); });
/** /**
@@ -685,7 +685,7 @@ export class AiChatService implements OnModuleInit {
// no-op (guarded below) so the turn still streams to the user. // no-op (guarded below) so the turn still streams to the user.
let assistantId: string | undefined; let assistantId: string | undefined;
try { try {
const seed = flushAssistant([], '', 'streaming'); const seed = flushAssistant([], '', 'streaming', { pageChanged });
const seeded = await this.aiChatMessageRepo.insert({ const seeded = await this.aiChatMessageRepo.insert({
chatId, chatId,
workspaceId: workspace.id, workspaceId: workspace.id,
@@ -720,7 +720,7 @@ export class AiChatService implements OnModuleInit {
await this.aiChatMessageRepo.update( await this.aiChatMessageRepo.update(
assistantId, assistantId,
workspace.id, workspace.id,
flushAssistant(capturedSteps, '', 'streaming'), flushAssistant(capturedSteps, '', 'streaming', { pageChanged }),
{ onlyIfStreaming: true }, { onlyIfStreaming: true },
); );
} catch (err) { } catch (err) {
@@ -860,6 +860,7 @@ export class AiChatService implements OnModuleInit {
// resolved from the admin-configured provider settings (in // resolved from the admin-configured provider settings (in
// closure scope here). Omitted/0 = no limit. // closure scope here). Omitted/0 = no limit.
maxContextTokens: resolved?.chatContextWindow, maxContextTokens: resolved?.chatContextWindow,
pageChanged,
}), }),
); );
// Lifecycle: release the external MCP clients leased for this turn. // Lifecycle: release the external MCP clients leased for this turn.
@@ -911,6 +912,7 @@ export class AiChatService implements OnModuleInit {
await finalizeAssistant( await finalizeAssistant(
flushAssistant(capturedSteps, inProgressText, 'error', { flushAssistant(capturedSteps, inProgressText, 'error', {
error: errorText, error: errorText,
pageChanged,
}), }),
); );
await closeExternalClients(); await closeExternalClients();
@@ -940,7 +942,9 @@ export class AiChatService implements OnModuleInit {
`steps=${steps.length}`, `steps=${steps.length}`,
); );
await finalizeAssistant( await finalizeAssistant(
flushAssistant(capturedSteps, inProgressText, 'aborted'), flushAssistant(capturedSteps, inProgressText, 'aborted', {
pageChanged,
}),
); );
await closeExternalClients(); await closeExternalClients();
// Advance the page snapshot even on abort (#274): an agent edit that // Advance the page snapshot even on abort (#274): an agent edit that
@@ -1506,6 +1510,7 @@ export function flushAssistant(
contextTokens?: number; contextTokens?: number;
maxContextTokens?: number; maxContextTokens?: number;
error?: string; error?: string;
pageChanged?: { title: string; diff: string } | null;
}, },
): AssistantFlush { ): AssistantFlush {
const finished = capturedSteps ?? []; const finished = capturedSteps ?? [];
@@ -1538,6 +1543,15 @@ export function flushAssistant(
if (extra?.maxContextTokens) if (extra?.maxContextTokens)
metadata.maxContextTokens = extra.maxContextTokens; metadata.maxContextTokens = extra.maxContextTokens;
if (extra?.error) metadata.error = extra.error; if (extra?.error) metadata.error = extra.error;
// Persist the page-change diff the agent saw this turn (#274 observability),
// so history / the Markdown export can show what the user changed. Only when
// a non-empty diff was actually injected into the prompt this turn.
if (extra?.pageChanged && extra.pageChanged.diff?.trim().length) {
metadata.pageChanged = {
title: extra.pageChanged.title,
diff: extra.pageChanged.diff,
};
}
return { return {
content: stepsText + trailing, content: stepsText + trailing,
@@ -269,6 +269,168 @@ describe('buildChatMarkdown (server) — structure', () => {
expect(md).toContain('**⚠️ Error:** 401: Unauthorized'); expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
}); });
// #274 observability: an assistant row whose turn started with a user edit to
// the open page carries metadata.pageChanged = { title, diff }; the export
// renders the diff the agent saw, before the message body.
it('renders the persisted page-change diff block for an assistant row', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: { title: 'Doc', diff: '@@ -1 +1 @@\n-old\n+new' },
} as never,
}),
],
});
expect(md).toContain(
'The user edited this page before this turn; the diff the agent saw:',
);
expect(md).toContain('("Doc")');
expect(md).toContain('-old');
expect(md).toContain('+new');
// The diff sits before the message body (chronological: change, then reply).
expect(md.indexOf('-old')).toBeLessThan(md.indexOf('answer'));
});
it('does not render the page-change block when metadata.pageChanged is absent', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [row({ role: 'assistant', content: 'answer' })],
});
expect(md).not.toContain(
'The user edited this page before this turn; the diff the agent saw:',
);
});
// #288 F1/F2: an empty page title must render the BARE heading with no
// `("…")` suffix (the `pc.title ? … : …` false branch).
it('renders the page-change heading with no title suffix when title is empty', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: { title: '', diff: '@@ -1 +1 @@\n-old\n+new' },
} as never,
}),
],
});
// Bare heading, single line, no parenthesized title.
expect(md).toContain(
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
);
expect(md).not.toContain('("');
expect(md).toContain('-old');
});
// #288 F1: the page title is UNTRUSTED cross-user data, so a title carrying a
// newline / backtick / `"` / `<`/`>` must be neutralized by escapeAttr before
// it is interpolated into the `> **…**` blockquote heading — otherwise it
// could break the blockquote onto multiple lines or inject markup/HTML into
// the downloaded .md. escapeAttr strips `<>"` and collapses whitespace runs to
// a single space, so `Ev"il\n> `x` <b>` becomes ``Evil `x` b``.
it('escapes an untrusted page title in the page-change heading', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: {
title: 'Ev"il\n> `x` <b>',
diff: '@@ -1 +1 @@\n-old\n+new',
},
} as never,
}),
],
});
// The heading stays a single blockquote line with the escaped title.
expect(md).toContain(
'> **📝 The user edited this page before this turn; the diff the agent saw: ("Evil `x` b")**',
);
// No raw attribute/markup breakers survived from the title.
expect(md).not.toContain('Ev"il');
expect(md).not.toContain('<b>');
});
// #288 review F1: escapeAttr ALONE is insufficient for this MARKDOWN sink —
// link/image syntax survives it. A cross-user title with `![x](url)` /
// `[phish](url)` must NOT become a working remote image or clickable link in
// the downloaded .md; markdownHeadingSafe backslash-escapes `[`/`]` so both are
// inert. (Non-vacuous: fails against the escapeAttr-only version, which left
// `](https://` intact.)
it('neutralizes markdown link/image syntax in an untrusted page title', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: {
title:
'![x](https://attacker.example/t.png) and [click](https://phish.example)',
diff: '@@ -1 +1 @@\n-old\n+new',
},
} as never,
}),
],
});
// No WORKING image/link syntax survives — the `[…]` sits escaped as `\[…\]`,
// so the unescaped `![x](` image and `[click](` link markers are gone. (We
// deliberately do NOT assert `not.toContain('](https://')`: after escaping the
// literal `\](https://` still contains `](https://` as a raw substring — that
// check would false-fail even though the link is inert.)
expect(md).not.toContain('![x](');
expect(md).not.toContain('[click](');
// The brackets are backslash-escaped, so `[text](url)`/`![text](url)` are inert.
expect(md).toContain('\\[');
expect(md).toContain('\\]');
// The heading stays a SINGLE blockquote line (no newline injected).
const headingLine = md
.split('\n')
.find((l) => l.includes('the diff the agent saw:'));
expect(headingLine).toBeDefined();
expect(headingLine).toContain('\\[x\\]');
expect(headingLine).toContain('\\[click\\]');
});
// #288 internal review Finding 2: a NON-empty title made up entirely of
// escapeAttr breakers (`<>"`) escapes to '' — the ternary must then fall to the
// BARE heading with NO `("…")` suffix. Locks the ternary-on-escaped-value
// behavior (distinct from the empty-string input test above).
it('renders the bare heading for a title that escapes to empty', () => {
const md = buildChatMarkdown({
title: 'T',
chatId: 'c',
rows: [
row({
role: 'assistant',
content: 'answer',
metadata: {
pageChanged: { title: '<>"', diff: '@@ -1 +1 @@\n-old\n+new' },
} as never,
}),
],
});
expect(md).toContain(
'> **📝 The user edited this page before this turn; the diff the agent saw:**',
);
expect(md).not.toContain('("');
expect(md).toContain('-old');
});
it('escapes embedded triple-backtick fences with a longer delimiter', () => { it('escapes embedded triple-backtick fences with a longer delimiter', () => {
const md = buildChatMarkdown({ const md = buildChatMarkdown({
title: 'T', title: 'T',
@@ -15,6 +15,7 @@
*/ */
import type { AiChatMessage } from '@docmost/db/types/entity.types'; import type { AiChatMessage } from '@docmost/db/types/entity.types';
import { escapeAttr } from './ai-chat.prompt';
/** Supported export label languages. Defaults to English. */ /** Supported export label languages. Defaults to English. */
export type ExportLang = 'en' | 'ru'; export type ExportLang = 'en' | 'ru';
@@ -63,6 +64,7 @@ const LABELS: Record<
tools: Record<string, string>; tools: Record<string, string>;
ranTool: (name: string) => string; ranTool: (name: string) => string;
stillGenerating: string; stillGenerating: string;
pageEditedByUser: string;
} }
> = { > = {
en: { en: {
@@ -83,6 +85,8 @@ const LABELS: Record<
ranTool: (name) => `Ran tool ${name}`, ranTool: (name) => `Ran tool ${name}`,
stillGenerating: stillGenerating:
'This message is still being generated — the export captured a partial, in-progress response.', 'This message is still being generated — the export captured a partial, in-progress response.',
pageEditedByUser:
'The user edited this page before this turn; the diff the agent saw:',
}, },
ru: { ru: {
untitled: 'Без названия', untitled: 'Без названия',
@@ -102,9 +106,29 @@ const LABELS: Record<
ranTool: (name) => `Выполнил инструмент ${name}`, ranTool: (name) => `Выполнил инструмент ${name}`,
stillGenerating: stillGenerating:
'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.', 'Это сообщение всё ещё генерируется — экспорт захватил частичный, незавершённый ответ.',
pageEditedByUser:
'Пользователь изменил страницу перед этим ходом; дифф, который видел агент:',
}, },
}; };
/**
* Make an untrusted title safe to interpolate into a Markdown blockquote
* HEADING. escapeAttr() neutralizes the XML/HTML breakers (`<` `>` `"`) and
* collapses whitespace for the PROMPT sink (`page="…"`), but this export sink is
* MARKDOWN — link/image syntax survives escapeAttr. So additionally backslash-
* escape `[` and `]`: that disables both `[text](url)` links and `![text](url)`
* images, so a cross-user title like `![x](http://evil)` or `[phish](http://evil)`
* cannot inject a remote (auto-loading) image or a clickable link into the
* downloaded .md disguised as a trusted system annotation. A bare `(url)` with no
* preceding `[]` is inert Markdown, so brackets are the only security-critical
* characters here. (We leave backticks to escapeAttr's whitespace pass — a title
* shown as inline code cannot escape the blockquote line or load a resource, so
* it is not a security concern for this sink.)
*/
function markdownHeadingSafe(title: string): string {
return escapeAttr(title).replace(/[[\]]/g, (m) => `\\${m}`);
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */ /** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean { function isToolPart(type: string): boolean {
return type.startsWith('tool-') || type === 'dynamic-tool'; return type.startsWith('tool-') || type === 'dynamic-tool';
@@ -208,6 +232,23 @@ function rowParts(row: AiChatMessage): ExportPart[] {
: [{ type: 'text', text: row.content ?? '' }]; : [{ type: 'text', text: row.content ?? '' }];
} }
/** The persisted page-change diff the agent saw this turn (#274), when any. */
function pageChangedOf(
row: AiChatMessage,
): { title: string; diff: string } | undefined {
const meta = (row.metadata ?? {}) as {
pageChanged?: { title?: string; diff?: string };
};
const pc = meta.pageChanged;
if (pc && typeof pc.diff === 'string' && pc.diff.trim().length > 0) {
return {
title: typeof pc.title === 'string' ? pc.title : '',
diff: pc.diff,
};
}
return undefined;
}
/** /**
* Serialize a chat to a Markdown string from its persisted rows. Source = DB * Serialize a chat to a Markdown string from its persisted rows. Source = DB
* ONLY (no live client state). A row whose `status` is still 'streaming' is an * ONLY (no live client state). A row whose `status` is still 'streaming' is an
@@ -266,6 +307,26 @@ export function buildChatMarkdown(args: {
blocks.push(`<!-- ${iso} -->`); blocks.push(`<!-- ${iso} -->`);
} }
// Page-change observability (#274): show the diff the agent saw at the start
// of this turn, before its response, so the export reflects the stale-page
// warning the model received.
const pc = pageChangedOf(row);
if (pc) {
// The page title is UNTRUSTED cross-user data (a collaborative page's title
// controllable by another user). escapeAttr() alone (the prompt sink) is
// INSUFFICIENT here: this is a MARKDOWN sink, so we neutralize link/image
// syntax too (backslash-escaping `[`/`]`) before interpolating it into this
// `> **…**` blockquote heading — otherwise `![x](url)` / `[phish](url)` would
// inject a remote image or clickable link into the downloaded .md. An
// all-`<>"` title escapes to empty and correctly falls to the bare heading.
// The diff body is already safe via fence(). (#288 review F1.)
const safeTitle = markdownHeadingSafe(pc.title);
const heading = safeTitle
? `${L.pageEditedByUser} ("${safeTitle}")`
: L.pageEditedByUser;
blocks.push(`> **📝 ${heading}**\n\n${fence(pc.diff, 'diff')}`);
}
blocks.push(...renderMessageParts(rowParts(row), lang)); blocks.push(...renderMessageParts(rowParts(row), lang));
// A still-'streaming' row is an interrupted/in-progress turn captured by the // A still-'streaming' row is an interrupted/in-progress turn captured by the