Skip to content

Commit c07886a

Browse files
fix: preserve library cards in compact chat rows (raphaeltm#1524)
Co-authored-by: Raphaël Titsworth-Morin <raphael@raphaeltm.com>
1 parent d0a3e86 commit c07886a

9 files changed

Lines changed: 231 additions & 17 deletions

File tree

apps/api/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ BASE_DOMAIN=workspaces.example.com
436436
# Project Data Durable Object limits
437437
# MAX_SESSIONS_PER_PROJECT=10000
438438
# MAX_MESSAGES_PER_SESSION=100000
439+
# DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES=16384
439440
# MESSAGE_SIZE_THRESHOLD=102400
440441
# ACTIVITY_RETENTION_DAYS=90
441442
# SESSION_IDLE_TIMEOUT_MINUTES=60

apps/api/src/durable-objects/project-data/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ export class ProjectData extends DurableObject<Env> {
149149
}
150150

151151
async getMessages(sessionId: string, limit: number = 1000, before: number | null = null, roles?: string[], compact: boolean = false, order: 'asc' | 'desc' = 'desc') {
152-
return messages.getMessages(this.sql, sessionId, limit, before, roles, compact, order);
152+
const compactOptions = compact ? messages.resolveCompactMessageOptions(this.env) : undefined;
153+
return messages.getMessages(this.sql, sessionId, limit, before, roles, compact, order, compactOptions);
153154
}
154155

155156
async getMessageToolContent(sessionId: string, messageId: string): Promise<unknown[] | null> {

apps/api/src/durable-objects/project-data/messages.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import { buildSafeFtsQuery } from '../../lib/fts5';
55
import { log } from '../../lib/logger';
66
import {
7+
type CompactMessageOptions,
8+
DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES,
79
parseChatMessageRow,
810
parseChatMessageRowCompact,
911
parseCount,
@@ -35,6 +37,16 @@ function resolveMaxMessagesPerSession(env: Env): number {
3537
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_MESSAGES_PER_SESSION;
3638
}
3739

40+
export function resolveCompactMessageOptions(env: Env): CompactMessageOptions {
41+
const parsed = Number.parseInt(env.DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES || '', 10);
42+
return {
43+
documentCardRawOutputMaxBytes:
44+
Number.isFinite(parsed) && parsed > 0
45+
? parsed
46+
: DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES,
47+
};
48+
}
49+
3850
/**
3951
* Returns the next monotonic sequence number for a session's messages.
4052
*/
@@ -335,7 +347,8 @@ export function getMessages(
335347
before: number | null = null,
336348
roles?: string[],
337349
compact: boolean = false,
338-
order: 'asc' | 'desc' = 'desc'
350+
order: 'asc' | 'desc' = 'desc',
351+
compactOptions?: CompactMessageOptions
339352
): { messages: Record<string, unknown>[]; hasMore: boolean } {
340353
let query =
341354
'SELECT id, session_id, role, content, tool_metadata, created_at, sequence FROM chat_messages WHERE session_id = ?';
@@ -386,10 +399,11 @@ export function getMessages(
386399

387400
const trimmedRows = candidateRows.slice(0, safeCount);
388401

389-
const rowParser = compact ? parseChatMessageRowCompact : parseChatMessageRow;
390402
const orderedRows = order === 'desc' ? trimmedRows.reverse() : trimmedRows;
391403
return {
392-
messages: orderedRows.map((row) => rowParser(row)),
404+
messages: orderedRows.map((row) =>
405+
compact ? parseChatMessageRowCompact(row, compactOptions) : parseChatMessageRow(row)
406+
),
393407
hasMore,
394408
};
395409
}

apps/api/src/durable-objects/project-data/row-schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export {
3636
export { parseInboxMessageRow, parseMailboxMessageRow } from './row-schemas/mailbox';
3737
export { parseMaterializationCheck, parseMaterializationToken, parseRowid, parseSessionId } from './row-schemas/materialization';
3838
export {
39+
type CompactMessageOptions,
40+
DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES,
3941
parseChatMessageRow,
4042
parseChatMessageRowCompact,
4143
parseSearchResultRow,

apps/api/src/durable-objects/project-data/row-schemas/messages.ts

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ export function parseChatMessageRow(row: unknown): {
4444
* tool_metadata and replaces it with a `contentSize` byte count.
4545
* This dramatically reduces RPC payload size for tool-heavy sessions.
4646
*/
47-
export function parseChatMessageRowCompact(row: unknown): {
47+
export type CompactMessageOptions = {
48+
documentCardRawOutputMaxBytes?: number;
49+
};
50+
51+
export const DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES = 16 * 1024;
52+
53+
export function parseChatMessageRowCompact(row: unknown, options?: CompactMessageOptions): {
4854
id: string;
4955
sessionId: string;
5056
role: string;
@@ -55,7 +61,7 @@ export function parseChatMessageRowCompact(row: unknown): {
5561
} {
5662
const r = parseRow(ChatMessageRowSchema, row, 'chat_message');
5763
const parsed = safeParseJson(r.tool_metadata);
58-
const toolMetadata = parsed === null ? null : stripToolMetadataContent(parsed);
64+
const toolMetadata = parsed === null ? null : stripToolMetadataContent(parsed, options);
5965
return {
6066
id: r.id,
6167
sessionId: r.session_id,
@@ -73,18 +79,135 @@ export function parseChatMessageRowCompact(row: unknown): {
7379
* Preserves all other metadata fields (toolCallId, title, kind, status, locations).
7480
*/
7581
const textEncoder = new TextEncoder();
82+
const DOCUMENT_CARD_TOOLS = new Set([
83+
'upload_to_library',
84+
'replace_library_file',
85+
'display_from_library',
86+
]);
87+
const TOOL_NAME_SEPARATORS = /__|\/|\.|:/;
88+
89+
function resolveDocumentCardRawOutputMaxBytes(options?: CompactMessageOptions): number {
90+
const configured = options?.documentCardRawOutputMaxBytes;
91+
return typeof configured === 'number' && Number.isFinite(configured) && configured > 0
92+
? Math.floor(configured)
93+
: DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES;
94+
}
95+
96+
function normalizeToolName(toolName: unknown): string | undefined {
97+
if (typeof toolName !== 'string' || !toolName) return undefined;
98+
const segments = toolName.split(TOOL_NAME_SEPARATORS).filter(Boolean);
99+
return segments.length > 0 ? segments[segments.length - 1] : toolName;
100+
}
101+
102+
function isDocumentCardTool(meta: Record<string, unknown>): boolean {
103+
const base = normalizeToolName(meta.toolName) ?? normalizeToolName(meta.title);
104+
return Boolean(base && DOCUMENT_CARD_TOOLS.has(base));
105+
}
106+
107+
function isRecord(value: unknown): value is Record<string, unknown> {
108+
return typeof value === 'object' && value !== null;
109+
}
110+
111+
function parseDocumentPayloadCandidate(
112+
value: string,
113+
maxBytes: number
114+
): Record<string, unknown> | undefined {
115+
const trimmed = value.trim();
116+
if (!trimmed || textEncoder.encode(trimmed).byteLength > maxBytes) {
117+
return undefined;
118+
}
119+
120+
const candidates = [trimmed];
121+
const firstBrace = trimmed.indexOf('{');
122+
const lastBrace = trimmed.lastIndexOf('}');
123+
if (firstBrace >= 0 && lastBrace > firstBrace) {
124+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
125+
}
126+
127+
for (const candidate of candidates) {
128+
try {
129+
const parsed: unknown = JSON.parse(candidate);
130+
if (isRecord(parsed) && isDocumentResultPayload(parsed)) {
131+
return parsed;
132+
}
133+
} catch {
134+
// Try the next candidate.
135+
}
136+
}
137+
138+
return undefined;
139+
}
140+
141+
function isDocumentResultPayload(payload: Record<string, unknown>): boolean {
142+
if (typeof payload.fileId === 'string' || typeof payload.id === 'string') return true;
143+
if (isRecord(payload.existingFile)) return true;
144+
return payload.error === 'FILE_NOT_FOUND' || payload.error === 'FILE_EXISTS';
145+
}
146+
147+
function findDocumentPayload(
148+
value: unknown,
149+
maxBytes: number,
150+
depth = 0
151+
): Record<string, unknown> | undefined {
152+
if (depth > 6 || value === null || value === undefined) return undefined;
153+
154+
if (typeof value === 'string') {
155+
return parseDocumentPayloadCandidate(value, maxBytes);
156+
}
157+
158+
if (Array.isArray(value)) {
159+
for (const entry of value) {
160+
const found = findDocumentPayload(entry, maxBytes, depth + 1);
161+
if (found) return found;
162+
}
163+
return undefined;
164+
}
165+
166+
if (!isRecord(value)) return undefined;
167+
168+
for (const key of ['text', 'output', 'content', 'result', 'message']) {
169+
const found = findDocumentPayload(value[key], maxBytes, depth + 1);
170+
if (found) return found;
171+
}
172+
173+
return undefined;
174+
}
175+
176+
function extractDocumentCardRawOutput(
177+
meta: Record<string, unknown>,
178+
contentArray: unknown[],
179+
maxBytes: number
180+
): Array<{ type: 'text'; text: string }> | undefined {
181+
if (!isDocumentCardTool(meta)) return undefined;
182+
if (meta.rawOutput !== undefined && meta.rawOutput !== null) return undefined;
183+
184+
const payload = findDocumentPayload(contentArray, maxBytes);
185+
if (!payload) return undefined;
186+
187+
const text = JSON.stringify(payload);
188+
if (textEncoder.encode(text).byteLength > maxBytes) {
189+
return undefined;
190+
}
191+
192+
return [{ type: 'text', text }];
193+
}
76194

77-
export function stripToolMetadataContent(meta: unknown): unknown {
195+
export function stripToolMetadataContent(meta: unknown, options?: CompactMessageOptions): unknown {
78196
if (!meta || typeof meta !== 'object') return meta;
79197
const obj = expectJsonRecord(meta, 'project-data.tool_metadata');
80198
const contentArray = obj.content;
81199
if (!Array.isArray(contentArray) || contentArray.length === 0) return meta;
82200

83201
const contentJson = JSON.stringify(contentArray);
84202
const contentSize = textEncoder.encode(contentJson).byteLength;
203+
const rawOutput = extractDocumentCardRawOutput(
204+
obj,
205+
contentArray,
206+
resolveDocumentCardRawOutputMaxBytes(options)
207+
);
85208

86209
const rest = Object.fromEntries(Object.entries(obj).filter(([k]) => k !== 'content'));
87-
return { ...rest, contentSize };
210+
return rawOutput ? { ...rest, rawOutput, contentSize } : { ...rest, contentSize };
88211
}
89212

90213
/** Search result row (message + session join) */

apps/api/src/durable-objects/project-data/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type Env = {
88
DO_SUMMARY_SYNC_DEBOUNCE_MS?: string;
99
MAX_SESSIONS_PER_PROJECT?: string;
1010
MAX_MESSAGES_PER_SESSION?: string;
11+
DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES?: string;
1112
ACTIVITY_RETENTION_DAYS?: string;
1213
SESSION_IDLE_TIMEOUT_MINUTES?: string;
1314
IDLE_CLEANUP_RETRY_DELAY_MS?: string;

apps/api/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export interface Env {
271271
CACHED_COMMANDS_MAX_DESC_LENGTH?: string;
272272
MAX_SESSIONS_PER_PROJECT?: string;
273273
MAX_MESSAGES_PER_SESSION?: string;
274+
DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES?: string; // Max document-card rawOutput bytes preserved in compact message metadata (default: 16384)
274275
MESSAGE_SIZE_THRESHOLD?: string;
275276
ACTIVITY_RETENTION_DAYS?: string;
276277
SESSION_IDLE_TIMEOUT_MINUTES?: string;

apps/api/tests/unit/durable-objects/compact-mode.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,76 @@ describe('stripToolMetadataContent', () => {
8787
expect(result.contentSize).toBeGreaterThan(100_000);
8888
expect(result.toolCallId).toBe('tc-big');
8989
});
90+
91+
it('preserves a document-card rawOutput payload while stripping compact content', () => {
92+
const payload = {
93+
fileId: '01KWV8QZ0PM59JS9YQSPVTCMCJ',
94+
filename: 'sam-architecture-comprehensive.html',
95+
mimeType: 'text/html; charset=utf-8',
96+
sizeBytes: 51849,
97+
caption: 'Newest comprehensive SAM architecture webpage.',
98+
};
99+
const meta = {
100+
toolCallId: 'tc-display',
101+
title: 'sam-mcp/display_from_library',
102+
kind: 'other',
103+
status: 'completed',
104+
content: [
105+
{
106+
type: 'content',
107+
content: { type: 'text', text: JSON.stringify(payload, null, 2) },
108+
},
109+
],
110+
};
111+
112+
const result = stripToolMetadataContent(meta) as Record<string, unknown>;
113+
114+
expect(result.content).toBeUndefined();
115+
expect(result.contentSize).toBeGreaterThan(0);
116+
expect(result.title).toBe('sam-mcp/display_from_library');
117+
const rawOutput = result.rawOutput as Array<{ type: string; text: string }>;
118+
expect(rawOutput).toHaveLength(1);
119+
expect(rawOutput[0]?.type).toBe('text');
120+
expect(JSON.parse(rawOutput[0]?.text ?? '{}')).toEqual(payload);
121+
});
122+
123+
it('does not synthesize rawOutput for non-document tool content', () => {
124+
const meta = {
125+
toolCallId: 'tc-shell',
126+
title: 'exec_command',
127+
kind: 'other',
128+
status: 'completed',
129+
content: [{ type: 'content', text: '{"fileId":"not-a-library-card"}' }],
130+
};
131+
132+
const result = stripToolMetadataContent(meta) as Record<string, unknown>;
133+
134+
expect(result.content).toBeUndefined();
135+
expect(result.contentSize).toBeGreaterThan(0);
136+
expect(result.rawOutput).toBeUndefined();
137+
});
138+
139+
it('honors the document-card rawOutput byte budget', () => {
140+
const meta = {
141+
toolCallId: 'tc-display',
142+
title: 'sam-mcp/display_from_library',
143+
status: 'completed',
144+
content: [
145+
{
146+
type: 'content',
147+
text: '{"fileId":"01KWV8QZ0PM59JS9YQSPVTCMCJ","filename":"sam-architecture-comprehensive.html"}',
148+
},
149+
],
150+
};
151+
152+
const result = stripToolMetadataContent(meta, {
153+
documentCardRawOutputMaxBytes: 16,
154+
}) as Record<string, unknown>;
155+
156+
expect(result.content).toBeUndefined();
157+
expect(result.contentSize).toBeGreaterThan(0);
158+
expect(result.rawOutput).toBeUndefined();
159+
});
90160
});
91161

92162
describe('parseChatMessageRowCompact', () => {

apps/www/src/content/docs/docs/reference/configuration.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,16 @@ SAM loads OpenCode Zen and OpenCode Go model choices through the authenticated m
261261

262262
## Durable Object Limits
263263

264-
| Variable | Default | Description |
265-
| ------------------------------ | -------- | ---------------------------------- |
266-
| `MAX_SESSIONS_PER_PROJECT` | `10000` | Max chat sessions per project |
267-
| `MAX_MESSAGES_PER_SESSION` | `100000` | Max messages per chat session |
268-
| `MESSAGE_SIZE_THRESHOLD` | `102400` | Max message size in bytes |
269-
| `ACTIVITY_RETENTION_DAYS` | `90` | Days to retain activity events |
270-
| `SESSION_IDLE_TIMEOUT_MINUTES` | `60` | Idle session timeout |
271-
| `SESSION_ACTIVITY_STALE_THRESHOLD_MS` | `300000` (5 min) | Evidence threshold before stale working activity can be healed to idle |
272-
| `DO_SUMMARY_SYNC_DEBOUNCE_MS` | `5000` | Debounce for DO-to-D1 summary sync |
264+
| Variable | Default | Description |
265+
| ----------------------------------------- | ------------------ | ------------------------------------------------------------------------- |
266+
| `MAX_SESSIONS_PER_PROJECT` | `10000` | Max chat sessions per project |
267+
| `MAX_MESSAGES_PER_SESSION` | `100000` | Max messages per chat session |
268+
| `DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES` | `16384` | Max compact metadata bytes preserved for library document cards |
269+
| `MESSAGE_SIZE_THRESHOLD` | `102400` | Max message size in bytes |
270+
| `ACTIVITY_RETENTION_DAYS` | `90` | Days to retain activity events |
271+
| `SESSION_IDLE_TIMEOUT_MINUTES` | `60` | Idle session timeout |
272+
| `SESSION_ACTIVITY_STALE_THRESHOLD_MS` | `300000` (5 min) | Evidence threshold before stale working activity can be healed to idle |
273+
| `DO_SUMMARY_SYNC_DEBOUNCE_MS` | `5000` | Debounce for DO-to-D1 summary sync |
273274

274275
## Durable Object Retry
275276

0 commit comments

Comments
 (0)