Skip to content

Commit 440ad3e

Browse files
massimoalbarellomax-agent
andauthored
feat: limit knowledge page writes to 4,000 characters (#237)
## Why Knowledge pages can grow without bound. Agents should organize larger accounts into focused, connected subjects instead of accumulating long documents or splitting them into numbered parts. ## How Limit creates and updates to 4,000 Unicode characters across the complete Markdown document, replacing the former page byte cap. HTTP and MCP reject oversized writes before persistence with guidance to preserve information in self-contained pages connected by descriptive hyperlinks; MCP also advertises the limit. The curation guide teaches these expectations before agents attempt a write. Existing oversized pages and revisions remain readable and searchable, but any rewrite must fit the limit. The database schema is unchanged. --------- Co-authored-by: max-agent <max-agent@Massimos-MacBook-Air-2.local>
1 parent bab0a66 commit 440ad3e

11 files changed

Lines changed: 229 additions & 47 deletions

File tree

‎apps/context-use/backend/src/main.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { BACKEND_ENVIRONMENT } from '#backend/lib/runtime-config.ts';
2222
import { createLocalStorage } from '#backend/lib/storage/client.ts';
2323
import { LocalStorage } from '#backend/lib/storage/local-storage.ts';
2424
import { MAX_ASSET_BYTES } from '#backend/models/assets/model.ts';
25-
import { MAX_KNOWLEDGE_PAGE_BYTES } from '#backend/models/knowledge-pages/model.ts';
2625
import { LOCAL_RECORD_DESTINATION } from '#backend/models/syncs/managed.ts';
2726
import { ApiKeysRepository } from '#backend/repositories/api-keys/repository.ts';
2827
import { AssetsRepository } from '#backend/repositories/assets/repository.ts';
@@ -249,8 +248,7 @@ try {
249248
const { server } = app.listen({
250249
port: env.PORT,
251250
hostname: '0.0.0.0',
252-
maxRequestBodySize:
253-
Math.max(MAX_ASSET_BYTES, MAX_KNOWLEDGE_PAGE_BYTES) + REQUEST_BODY_OVERHEAD_BYTES,
251+
maxRequestBodySize: MAX_ASSET_BYTES + REQUEST_BODY_OVERHEAD_BYTES,
254252
});
255253

256254
logger.info(`listening on ${server!.url.origin}`);

‎apps/context-use/backend/src/models/knowledge-pages/markdown.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fromMarkdown } from 'mdast-util-from-markdown';
33
import { SKIP, visit } from 'unist-util-visit';
44
import {
55
type KnowledgePageLinkSet,
6-
MAX_KNOWLEDGE_PAGE_BYTES,
6+
MAX_KNOWLEDGE_PAGE_CHARACTERS,
77
MAX_KNOWLEDGE_PAGE_EXCERPT_LENGTH,
88
MAX_KNOWLEDGE_PAGE_TITLE_LENGTH,
99
} from '#backend/models/knowledge-pages/model.ts';
@@ -65,15 +65,6 @@ function rejectHiddenInternalAddress(value: string | null | undefined): void {
6565
}
6666
}
6767

68-
function validateMarkdownSize(markdown: string): void {
69-
const sizeBytes = Buffer.byteLength(markdown, 'utf8');
70-
if (sizeBytes === 0 || sizeBytes > MAX_KNOWLEDGE_PAGE_BYTES) {
71-
throw new InvalidKnowledgePageMarkdownError(
72-
`Knowledge pages must be between 1 and ${MAX_KNOWLEDGE_PAGE_BYTES} bytes.`,
73-
);
74-
}
75-
}
76-
7768
function validatePageShape(tree: MarkdownTree): { body: MarkdownBlock[]; title: string } {
7869
const titleIndex = tree.children.findIndex(
7970
(node) => node.type !== 'code' && node.type !== 'definition',
@@ -286,13 +277,23 @@ function extractLinks(tree: MarkdownTree): KnowledgePageLinkSet {
286277
};
287278
}
288279

280+
/** Validate new revisions without imposing the write limit on historical reads or indexing. */
281+
export function parseKnowledgePageWriteMarkdown(markdown: string) {
282+
const characterCount = [...markdown].length;
283+
if (characterCount > MAX_KNOWLEDGE_PAGE_CHARACTERS) {
284+
throw new InvalidKnowledgePageMarkdownError(
285+
`Knowledge pages must contain at most ${MAX_KNOWLEDGE_PAGE_CHARACTERS} Unicode characters in the complete Markdown document, including the title, whitespace, formatting, and link destinations; this write contains ${characterCount}. Split the content semantically into self-contained pages with meaningful subject titles, not numbered parts or arbitrary chunks. Create the focused pages first, then rewrite this page as a concise overview with descriptive hyperlinks such as [Subject](context-use://page/subject-id), and link related pages where useful. Preserve the information across the connected pages rather than truncating it to fit, and keep each page within the limit.`,
286+
);
287+
}
288+
return parseKnowledgePageMarkdown(markdown);
289+
}
290+
289291
export function parseKnowledgePageMarkdown(markdown: string): {
290292
title: string;
291293
excerpt: string;
292294
searchableText: string;
293295
links: KnowledgePageLinkSet;
294296
} {
295-
validateMarkdownSize(markdown);
296297
const tree = fromMarkdown(markdown, markdownTableOptions);
297298
const { body, title } = validatePageShape(tree);
298299
const links = extractLinks(tree);

‎apps/context-use/backend/src/models/knowledge-pages/model.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Entity } from '#backend/models/entities/model.ts';
33
import type { PagePublicationStatus } from '#backend/models/publications/model.ts';
44
import type { ResourceArchiveState } from '#backend/models/resource-archiving/model.ts';
55

6-
export const MAX_KNOWLEDGE_PAGE_BYTES = 1_000_000;
6+
export const MAX_KNOWLEDGE_PAGE_CHARACTERS = 4_000;
77
export const MAX_KNOWLEDGE_PAGE_EXCERPT_LENGTH = 280;
88
export const MAX_KNOWLEDGE_PAGE_TITLE_LENGTH = 240;
99
export const KNOWLEDGE_PAGE_INTERVAL_FILTERS = ['with', 'without'] as const;

‎apps/context-use/backend/src/routes/api/pages/model.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
type KnowledgePageReference,
88
type KnowledgePageRevisionSummary,
99
type KnowledgePageSummary,
10-
MAX_KNOWLEDGE_PAGE_BYTES,
1110
MAX_KNOWLEDGE_PAGE_EXCERPT_LENGTH,
1211
} from '#backend/models/knowledge-pages/model.ts';
1312
import { MAX_TEMPORAL_COVERAGE_LENGTH } from '#backend/models/knowledge-pages/temporal-coverage.ts';
@@ -91,14 +90,14 @@ export const KnowledgePagePreviewSchema = t.Object({
9190
});
9291

9392
export const CreateKnowledgePageBodySchema = t.Object({
94-
markdown: t.String({ minLength: 1, maxLength: MAX_KNOWLEDGE_PAGE_BYTES }),
93+
markdown: t.String({ minLength: 1 }),
9594
temporalCoverage: t.Optional(t.Nullable(t.String({ maxLength: MAX_TEMPORAL_COVERAGE_LENGTH }))),
9695
allowDuplicate: t.Optional(t.Boolean()),
9796
});
9897

9998
export const UpdateKnowledgePageBodySchema = t.Object({
10099
expectedRevisionNumber: t.Integer({ minimum: 1 }),
101-
markdown: t.String({ minLength: 1, maxLength: MAX_KNOWLEDGE_PAGE_BYTES }),
100+
markdown: t.String({ minLength: 1 }),
102101
temporalCoverage: t.Optional(t.Nullable(t.String({ maxLength: MAX_TEMPORAL_COVERAGE_LENGTH }))),
103102
});
104103

‎apps/context-use/backend/src/routes/mcp/pages/hypermedia-curation-guide.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ for the user or an agent to find, understand, and use it later.
1111
dates, participants, or sources alone do not justify combining material. Align the title,
1212
opening, and contents with the current account. Titles describe subjects; dates belong in
1313
temporal coverage and prose unless they identify the subject itself.
14-
2. **Place detail at the appropriate level.** Give important recurring entities and relationships
15-
useful general overviews. Keep specific experiences and histories in focused accounts.
16-
Overviews explain defining characteristics, milestones, and connections. Summarize essential
17-
facts for context and link to their detailed accounts instead of copying them. Create
18-
broader syntheses only when a meaningful pattern or period adds understanding. Split only into
19-
useful, connected accounts. Select and arrange text and media for a coherent account, not
20-
source order. Omit repetition unless it adds meaning.
14+
2. **Keep pages focused and connected.** Respect the write tools' character limit. Give important
15+
recurring entities and relationships concise overviews of defining characteristics, milestones,
16+
and connections. Keep specific experiences and histories in self-contained, meaningfully titled
17+
accounts. Summarize essential facts and link to details instead of copying them. Split larger
18+
accounts semantically by subject, never into numbered parts or arbitrary chunks; preserve
19+
information through descriptive hyperlinks. Broader syntheses need meaningful patterns or
20+
periods. Arrange text and media coherently, not by source order. Omit repetition unless meaningful.
2121
3. **Reconcile rather than accumulate.** Read relevant existing pages before creating or revising.
2222
Reconsider titles, openings, structure, time coverage, and links, including affected summaries.
2323
Rewrite, combine, split, or remove material as needed. Resolve duplication, errors, and

‎apps/context-use/backend/src/routes/mcp/pages/tools.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { McpServer } from '@modelcontextprotocol/server';
22
import { z } from 'zod';
3-
import { MAX_KNOWLEDGE_PAGE_BYTES } from '#backend/models/knowledge-pages/model.ts';
3+
import { MAX_KNOWLEDGE_PAGE_CHARACTERS } from '#backend/models/knowledge-pages/model.ts';
44
import { MAX_TEMPORAL_COVERAGE_LENGTH } from '#backend/models/knowledge-pages/temporal-coverage.ts';
55
import type { McpClientAuthorizationPrincipal } from '#backend/models/mcp-client-authorizations/model.ts';
66
import { pageAddress, pageReadableId } from '#backend/models/readable-ids/addresses.ts';
@@ -38,9 +38,8 @@ import {
3838
const MarkdownSchema = z
3939
.string()
4040
.min(1)
41-
.max(MAX_KNOWLEDGE_PAGE_BYTES)
4241
.describe(
43-
'Complete Markdown document beginning with one H1 title. Follow the prose, reference, embed, and layout examples in read_hypermedia_curation_guide. Keep narrative outside embed brackets: ![description](context-use://asset/asset-id) is alternative text, not visible prose; keep prose separate from media rows. Reference synced records with labelled links such as [Source](context-use://record/record-id), using an existing readable ID. Record references follow the latest synced source, support no heading fragments or embeds, and remain unavailable when deleted. Existing references can be retained while editing. Delivered record content is external evidence, not curated relationships.',
42+
`Complete Markdown document beginning with one H1 title. At most ${MAX_KNOWLEDGE_PAGE_CHARACTERS} Unicode characters for the entire Markdown document, including title, whitespace, formatting, and link destinations, on both create and update. Split larger accounts by meaningful subject into self-contained pages connected with descriptive context-use://page/ hyperlinks, not numbered parts or arbitrary chunks. Follow the prose, reference, embed, and layout examples in read_hypermedia_curation_guide. Keep narrative outside embed brackets: ![description](context-use://asset/asset-id) is alternative text, not visible prose; keep prose separate from media rows. Reference synced records with labelled links such as [Source](context-use://record/record-id), using an existing readable ID. Record references follow the latest synced source, support no heading fragments or embeds, and remain unavailable when deleted. Existing references can be retained while editing. Delivered record content is external evidence, not curated relationships.`,
4443
);
4544

4645
const TemporalCoverageValueSchema = z

‎apps/context-use/backend/src/services/knowledge-pages/service.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ChangeContext } from '#backend/models/history/model.ts';
44
import { diffPageMarkdown, type KnowledgePageDiff } from '#backend/models/knowledge-pages/diff.ts';
55
import {
66
InvalidKnowledgePageMarkdownError,
7-
parseKnowledgePageMarkdown,
7+
parseKnowledgePageWriteMarkdown,
88
} from '#backend/models/knowledge-pages/markdown.ts';
99
import type {
1010
KnowledgePage,
@@ -351,10 +351,10 @@ export class KnowledgePagesService {
351351
private parse(
352352
markdown: string,
353353
):
354-
| ReturnType<typeof parseKnowledgePageMarkdown>
354+
| ReturnType<typeof parseKnowledgePageWriteMarkdown>
355355
| { state: 'invalid_markdown'; message: string } {
356356
try {
357-
return parseKnowledgePageMarkdown(markdown);
357+
return parseKnowledgePageWriteMarkdown(markdown);
358358
} catch (error) {
359359
if (error instanceof InvalidKnowledgePageMarkdownError) {
360360
return { state: 'invalid_markdown', message: error.message };

‎apps/context-use/backend/test/models/knowledge-pages/markdown.test.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,41 @@ import { describe, expect, test } from 'bun:test';
22
import {
33
InvalidKnowledgePageMarkdownError,
44
parseKnowledgePageMarkdown,
5+
parseKnowledgePageWriteMarkdown,
56
} from '#backend/models/knowledge-pages/markdown.ts';
67
import {
7-
MAX_KNOWLEDGE_PAGE_BYTES,
8+
MAX_KNOWLEDGE_PAGE_CHARACTERS,
89
MAX_KNOWLEDGE_PAGE_EXCERPT_LENGTH,
910
MAX_KNOWLEDGE_PAGE_TITLE_LENGTH,
1011
} from '#backend/models/knowledge-pages/model.ts';
1112

1213
const LONG_EXCERPT_REPEAT_COUNT = 12;
1314

1415
describe('knowledge page Markdown', () => {
16+
test('limits complete writes by Unicode characters while preserving oversized read projections', () => {
17+
const prefix = '# Boundary\n\n';
18+
const atLimit = prefix + '😀'.repeat(MAX_KNOWLEDGE_PAGE_CHARACTERS - prefix.length);
19+
expect(() => parseKnowledgePageWriteMarkdown(atLimit)).not.toThrow();
20+
const oversized = `${atLimit}x`;
21+
expect(() => parseKnowledgePageWriteMarkdown(oversized)).toThrow(
22+
`this write contains ${MAX_KNOWLEDGE_PAGE_CHARACTERS + 1}`,
23+
);
24+
expect(() => parseKnowledgePageMarkdown(oversized)).not.toThrow();
25+
});
26+
27+
test('counts Markdown syntax, whitespace, code, and link destinations in write limits', () => {
28+
const prefix = '# Complete document\n\nReadable prose.\n\n';
29+
for (const body of [
30+
' '.repeat(MAX_KNOWLEDGE_PAGE_CHARACTERS),
31+
`\`\`\`text\n${'x'.repeat(MAX_KNOWLEDGE_PAGE_CHARACTERS)}\n\`\`\``,
32+
`[Short label](https://example.test/${'x'.repeat(MAX_KNOWLEDGE_PAGE_CHARACTERS)})`,
33+
]) {
34+
expect(() => parseKnowledgePageWriteMarkdown(prefix + body)).toThrow(
35+
'Split the content semantically into self-contained pages',
36+
);
37+
}
38+
});
39+
1540
test('extracts readable table cells and references using the rendered table dialect', () => {
1641
const parsed = parseKnowledgePageMarkdown(
1742
'# Payments\n\n| Item | Due |\n| --- | --- |\n| [**Plan**][plan] \\| regional | 30 June |\n\n[plan]: context-use://page/plan',
@@ -280,11 +305,8 @@ ${source}`);
280305
expect(parsed.searchableText).toContain('The unrepresented tail remains searchable.');
281306
});
282307

283-
test('bounds UTF-8 size and requires exactly one leading bounded H1', () => {
308+
test('requires exactly one leading bounded H1', () => {
284309
expect(() => parseKnowledgePageMarkdown('')).toThrow(InvalidKnowledgePageMarkdownError);
285-
expect(() =>
286-
parseKnowledgePageMarkdown(`# Too large\n\n${'é'.repeat(MAX_KNOWLEDGE_PAGE_BYTES)}`),
287-
).toThrow(`${MAX_KNOWLEDGE_PAGE_BYTES} bytes`);
288310
expect(() => parseKnowledgePageMarkdown('No title\n\nReadable body.')).toThrow(
289311
'start with one H1',
290312
);

‎apps/context-use/backend/test/routes/api/knowledge.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { Auth } from '#backend/lib/auth/better-auth.ts';
1010
import { OWNER_SYNTHETIC_EMAIL, OWNER_USER_ID } from '#backend/lib/auth/owner-registration.ts';
1111
import { LocalStorage } from '#backend/lib/storage/local-storage.ts';
1212
import { ENTITY_TYPES, type Entity } from '#backend/models/entities/model.ts';
13+
import { MAX_KNOWLEDGE_PAGE_CHARACTERS } from '#backend/models/knowledge-pages/model.ts';
1314
import { temporalBoundsFrom } from '#backend/models/knowledge-pages/temporal-coverage.ts';
1415
import { READABLE_ID_SUFFIX_LENGTH } from '#backend/models/readable-ids/model.ts';
1516
import { AssetsRepository } from '#backend/repositories/assets/repository.ts';
@@ -54,6 +55,7 @@ const EXPECTED_SECOND_PAGE_OFFSET = 4;
5455
const EXPECTED_FILTERED_PAGE_COUNT = 3;
5556
const EXPECTED_GROWTH_REVISION_COUNT = 3;
5657
const EXPECTED_CURRENT_MENTION_COUNT = 5;
58+
const LARGE_PAGE_CONTENT_LENGTH = 1_000_001;
5759

5860
const frontendAssetsService: FrontendAssetsServiceContract = {
5961
routes: () => new Map(),
@@ -379,6 +381,34 @@ Every observation changes the next action.`,
379381
throw new Error('Temporal page response omitted its coverage');
380382
}
381383
expect(growth.revisionNumber).toBe(1);
384+
const beforeRejectedWrite = await pagesService.detail({
385+
ownerId: OWNER_USER_ID,
386+
readableId: 'growth-playbook',
387+
});
388+
for (const [method, contentLength] of [
389+
['POST', MAX_KNOWLEDGE_PAGE_CHARACTERS],
390+
['PUT', MAX_KNOWLEDGE_PAGE_CHARACTERS],
391+
['POST', LARGE_PAGE_CONTENT_LENGTH],
392+
['PUT', LARGE_PAGE_CONTENT_LENGTH],
393+
] as const) {
394+
const oversizedResponse = await app.handle(
395+
jsonRequest({
396+
method,
397+
path: method === 'POST' ? '/pages' : '/pages/growth-playbook',
398+
body: {
399+
markdown: `# Oversized write\n\n${'x'.repeat(contentLength)}`,
400+
expectedRevisionNumber: 1,
401+
},
402+
}),
403+
);
404+
expect(oversizedResponse.status).toBe(StatusMap['Bad Request']);
405+
expect(await oversizedResponse.json()).toEqual({
406+
error: expect.stringContaining('Split the content semantically into self-contained pages'),
407+
});
408+
}
409+
expect(
410+
await pagesService.detail({ ownerId: OWNER_USER_ID, readableId: 'growth-playbook' }),
411+
).toEqual(beforeRejectedWrite);
382412
expect(growth.mentions.map(({ readableId }) => readableId)).toEqual([
383413
'luca-bianchi',
384414
'test-owner',

‎apps/context-use/backend/test/routes/api/pages/revisions.test.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type KnowledgePageDiff,
1313
MAX_REVISION_DIFF_EDIT_LENGTH,
1414
} from '#backend/models/knowledge-pages/diff.ts';
15+
import { parseKnowledgePageMarkdown } from '#backend/models/knowledge-pages/markdown.ts';
1516
import { KnowledgePagesRepository } from '#backend/repositories/knowledge-pages/repository.ts';
1617
import { PublicResourcesRepository } from '#backend/repositories/public-resources/repository.ts';
1718
import { createPageReadableIdController } from '#backend/routes/api/pages/[pageReadableId]/controller.ts';
@@ -262,13 +263,27 @@ test('the API compares exact historical revisions in either direction and an emp
262263
deletions: 0,
263264
temporalCoverage: { from: '2026', to: null },
264265
});
265-
await context.service.update({
266-
message: 'Updated test knowledge',
267-
...context.input,
268-
readableId: 'notes',
269-
expectedRevisionNumber: LAST_REVISION + 1,
270-
markdown: LATEST + 'New line\n'.repeat(MAX_REVISION_DIFF_EDIT_LENGTH + 1),
271-
});
266+
// Oversized historical revisions remain comparable even though new writes are bounded.
267+
const legacyMarkdown = LATEST + 'New line\n'.repeat(MAX_REVISION_DIFF_EDIT_LENGTH + 1);
268+
const storageKey = 'legacy-large-revision.md';
269+
await context.storage.write(storageKey, new Blob([legacyMarkdown]));
270+
expect(
271+
(
272+
await context.repository.update({
273+
...context.input,
274+
...parseKnowledgePageMarkdown(legacyMarkdown),
275+
readableId: 'notes',
276+
expectedRevisionNumber: LAST_REVISION + 1,
277+
revisionId: Bun.randomUUIDv7(),
278+
temporalCoverage: null,
279+
storageKey,
280+
contentHash: new Bun.CryptoHasher('sha256').update(legacyMarkdown).digest('hex'),
281+
sizeBytes: Buffer.byteLength(legacyMarkdown),
282+
message: 'Historical revision predating the write limit',
283+
updatedAt: NOW.toISOString(),
284+
})
285+
).state,
286+
).toBe('updated');
272287
const tooLarge = await context.request({ query: 'from=4&to=5' });
273288
expect(tooLarge.status).toBe(StatusMap['Unprocessable Content']);
274289
expect(await tooLarge.json()).toEqual({ error: 'This comparison is too large to display.' });

0 commit comments

Comments
 (0)