@ctcstack/ctcdocs-core 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -11,4 +11,4 @@ export type { AccessClass, AccessModel, ChainReaders, PublishedReaders, Readers,
11
11
  export { accessFindings } from './access-findings.js';
12
12
  export type { AccessFinding } from './access-findings.js';
13
13
  export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
14
- export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, McpConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
14
+ export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, McpConfiguration, McpRecentConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, SyncConfigurationDefaults, } from './site-configuration.js';
@@ -4,9 +4,13 @@ import remarkParse from 'remark-parse';
4
4
  import remarkStringify from 'remark-stringify';
5
5
  import { unified } from 'unified';
6
6
  import { markdownProjectionPath, resolvePermanentLink, } from './project-layout.js';
7
+ /*
8
+ * Tables are written without padding: one long cell would otherwise pad every
9
+ * row of its column, and an assistant would read the spaces (ADR-046).
10
+ */
7
11
  const processor = unified()
8
12
  .use(remarkParse)
9
- .use(remarkGfm)
13
+ .use(remarkGfm, { tablePipeAlign: false })
10
14
  .use(remarkStringify, {
11
15
  bullet: '-',
12
16
  emphasis: '*',
@@ -43,8 +47,68 @@ function markdownUrl(value, stableSlugs, permanentLinks) {
43
47
  }
44
48
  return `${markdownProjectionPath(slug)}${url.search}${url.hash}`;
45
49
  }
46
- function rewriteInternalLinks(body, stableSlugs, permanentLinks) {
50
+ /** A table longer than this is split into parts (ADR-046). */
51
+ const SPLIT_TABLE_CHARACTERS = 3_000;
52
+ /** About as long as each part of a split table is, header included. */
53
+ const TABLE_PART_CHARACTERS = 2_000;
54
+ function textLength(node) {
55
+ if ('value' in node) {
56
+ return node.value.length;
57
+ }
58
+ if ('children' in node) {
59
+ return node.children.reduce((sum, child) => sum + textLength(child), 0);
60
+ }
61
+ return 0;
62
+ }
63
+ /** About how many characters a table row takes, with its pipes. */
64
+ function rowLength(row) {
65
+ return row.children.reduce((sum, cell) => sum + textLength(cell) + 3, 2);
66
+ }
67
+ /**
68
+ * Splits each long table into consecutive tables that repeat its header row,
69
+ * so that a passage search returns from the middle of one still says what each
70
+ * column is (ADR-046). A table that fits stays whole.
71
+ */
72
+ function splitLongTables(node) {
73
+ const children = [];
74
+ for (const child of node.children) {
75
+ if ('children' in child && child.type !== 'table') {
76
+ splitLongTables(child);
77
+ }
78
+ if (child.type !== 'table') {
79
+ children.push(child);
80
+ continue;
81
+ }
82
+ const [header, ...rows] = child.children;
83
+ const lengths = child.children.map(rowLength);
84
+ const total = lengths.reduce((sum, length) => sum + length, 0);
85
+ if (!header || rows.length < 2 || total <= SPLIT_TABLE_CHARACTERS) {
86
+ children.push(child);
87
+ continue;
88
+ }
89
+ const headerLength = lengths[0] ?? 0;
90
+ let part = [];
91
+ let partLength = headerLength;
92
+ const flush = () => {
93
+ children.push({ ...child, children: [header, ...part] });
94
+ part = [];
95
+ partLength = headerLength;
96
+ };
97
+ rows.forEach((row, index) => {
98
+ const length = lengths[index + 1] ?? 0;
99
+ if (part.length > 0 && partLength + length > TABLE_PART_CHARACTERS) {
100
+ flush();
101
+ }
102
+ part.push(row);
103
+ partLength += length;
104
+ });
105
+ flush();
106
+ }
107
+ node.children = children;
108
+ }
109
+ function projectBody(body, stableSlugs, permanentLinks) {
47
110
  const tree = processor.parse(body);
111
+ splitLongTables(tree);
48
112
  walk(tree, (node) => {
49
113
  if (node.type === 'link') {
50
114
  node.url = markdownUrl(node.url, stableSlugs, permanentLinks) ?? node.url;
@@ -104,7 +168,7 @@ export function inlineMarkdown(value) {
104
168
  * `fetch` (ADR-042), so it is also what the sync measures (ADR-043).
105
169
  */
106
170
  export function publishedMarkdownBody(input) {
107
- const body = rewriteInternalLinks(cleanBody(input.body, input.ownershipHeader), input.stableSlugs, input.permanentLinks);
171
+ const body = projectBody(cleanBody(input.body, input.ownershipHeader), input.stableSlugs, input.permanentLinks);
108
172
  return [
109
173
  `# ${inlineMarkdown(input.title)}`,
110
174
  '',
@@ -185,6 +185,13 @@ export interface McpSearchConfiguration {
185
185
  /** Characters of passage text one search returns at most, in all. */
186
186
  readonly passageCharacters: number;
187
187
  }
188
+ /** What `recent` lists (ADR-044). */
189
+ export interface McpRecentConfiguration {
190
+ /** Documents it lists when a call does not ask for a number. */
191
+ readonly defaultResults: number;
192
+ /** Documents a call may ask for at most. */
193
+ readonly results: number;
194
+ }
188
195
  /**
189
196
  * The MCP server through which AI assistants read the site as the person who
190
197
  * connected them (ADR-041). It needs the Worker's sign-in, so it is accepted
@@ -195,6 +202,9 @@ export interface McpConfiguration {
195
202
  readonly search: McpSearchConfiguration;
196
203
  /** Characters `fetch` returns at most; a longer document is cut there. */
197
204
  readonly fetchCharacters: number;
205
+ /** Characters of JSON a `browse` tree takes at most (ADR-044). */
206
+ readonly browseCharacters: number;
207
+ readonly recent: McpRecentConfiguration;
198
208
  }
199
209
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
200
210
  export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
@@ -27,11 +27,13 @@ export const MCP_DEFAULTS = Object.freeze({
27
27
  model: '@cf/baai/bge-reranker-base',
28
28
  threshold: 0,
29
29
  }),
30
- results: 10,
30
+ results: 15,
31
31
  passagesPerResult: 3,
32
32
  passageCharacters: 24_000,
33
33
  }),
34
34
  fetchCharacters: 100_000,
35
+ browseCharacters: 24_000,
36
+ recent: Object.freeze({ defaultResults: 20, results: 50 }),
35
37
  });
36
38
  /**
37
39
  * The characters an assistant reads of a document: the cut `fetch` makes
@@ -44,8 +46,6 @@ export function fetchCharacterLimit(mcp) {
44
46
  }
45
47
  /** About 10,000 tokens of English: a starting value, not a measured one. */
46
48
  const LARGE_DOCUMENT_CHARACTERS = 40_000;
47
- /** A passage of fewer characters than this says too little to judge by. */
48
- const SHORTEST_PASSAGE = 100;
49
49
  export class SiteConfigurationError extends Error {
50
50
  name = 'SiteConfigurationError';
51
51
  }
@@ -246,7 +246,24 @@ function mcpSearch(value) {
246
246
  reranking,
247
247
  results,
248
248
  passagesPerResult: optionalNumber(source, 'passagesPerResult', 'mcp.search.passagesPerResult', defaults.passagesPerResult, { min: 1, whole: true }),
249
- passageCharacters: optionalNumber(source, 'passageCharacters', 'mcp.search.passageCharacters', defaults.passageCharacters, { min: results * SHORTEST_PASSAGE, whole: true }),
249
+ passageCharacters: optionalNumber(source, 'passageCharacters', 'mcp.search.passageCharacters', defaults.passageCharacters,
250
+ // Chunks are shown whole, so the budget answers to the chunk size, not
251
+ // to the results: those it cannot hold are listed without text.
252
+ { min: 1_000, whole: true }),
253
+ };
254
+ }
255
+ function mcpRecent(value) {
256
+ const defaults = MCP_DEFAULTS.recent;
257
+ if (value === undefined) {
258
+ return defaults;
259
+ }
260
+ const source = record(value, 'mcp.recent');
261
+ knownKeys(source, 'mcp.recent', Object.keys(defaults));
262
+ const results = optionalNumber(source, 'results', 'mcp.recent.results', defaults.results, { min: 1, whole: true });
263
+ // The default, like a set value, is no more than a call may ask for.
264
+ return {
265
+ defaultResults: optionalNumber(source, 'defaultResults', 'mcp.recent.defaultResults', Math.min(defaults.defaultResults, results), { min: 1, max: results, whole: true }),
266
+ results,
250
267
  };
251
268
  }
252
269
  function optionalMegabytes(source, key, path, fallback) {
@@ -393,7 +410,13 @@ export function parseSiteConfiguration(input) {
393
410
  let mcp;
394
411
  if (root.mcp !== undefined) {
395
412
  const source = record(root.mcp, 'mcp');
396
- knownKeys(source, 'mcp', ['enabled', 'search', 'fetchCharacters']);
413
+ knownKeys(source, 'mcp', [
414
+ 'enabled',
415
+ 'search',
416
+ 'fetchCharacters',
417
+ 'browseCharacters',
418
+ 'recent',
419
+ ]);
397
420
  const enabled = flag(source, 'enabled', 'mcp.enabled');
398
421
  if (enabled) {
399
422
  if (!signIn) {
@@ -407,6 +430,8 @@ export function parseSiteConfiguration(input) {
407
430
  enabled,
408
431
  search: mcpSearch(source.search),
409
432
  fetchCharacters: optionalNumber(source, 'fetchCharacters', 'mcp.fetchCharacters', MCP_DEFAULTS.fetchCharacters, { min: 1_000, whole: true }),
433
+ browseCharacters: optionalNumber(source, 'browseCharacters', 'mcp.browseCharacters', MCP_DEFAULTS.browseCharacters, { min: 1_000, whole: true }),
434
+ recent: mcpRecent(source.recent),
410
435
  };
411
436
  }
412
437
  const fetchLimit = fetchCharacterLimit(mcp);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctcstack/ctcdocs-core",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Configuration schema, project layout, generated-path allowlist and the published Markdown form shared by the CTCDocs platform packages.",
5
5
  "keywords": [
6
6
  "ctcdocs",
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export type {
80
80
  DeploymentVisibility,
81
81
  HomeConfiguration,
82
82
  McpConfiguration,
83
+ McpRecentConfiguration,
83
84
  McpSearchConfiguration,
84
85
  NavigationConfiguration,
85
86
  SignInConfiguration,
@@ -1,4 +1,4 @@
1
- import type { Root, RootContent } from 'mdast';
1
+ import type { Parents, Root, RootContent, Table } from 'mdast';
2
2
  import * as cheerio from 'cheerio';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import remarkParse from 'remark-parse';
@@ -10,9 +10,13 @@ import {
10
10
  resolvePermanentLink,
11
11
  } from './project-layout.js';
12
12
 
13
+ /*
14
+ * Tables are written without padding: one long cell would otherwise pad every
15
+ * row of its column, and an assistant would read the spaces (ADR-046).
16
+ */
13
17
  const processor = unified()
14
18
  .use(remarkParse)
15
- .use(remarkGfm)
19
+ .use(remarkGfm, { tablePipeAlign: false })
16
20
  .use(remarkStringify, {
17
21
  bullet: '-',
18
22
  emphasis: '*',
@@ -83,12 +87,76 @@ function markdownUrl(
83
87
  return `${markdownProjectionPath(slug)}${url.search}${url.hash}`;
84
88
  }
85
89
 
86
- function rewriteInternalLinks(
90
+ /** A table longer than this is split into parts (ADR-046). */
91
+ const SPLIT_TABLE_CHARACTERS = 3_000;
92
+ /** About as long as each part of a split table is, header included. */
93
+ const TABLE_PART_CHARACTERS = 2_000;
94
+
95
+ function textLength(node: Root | RootContent): number {
96
+ if ('value' in node) {
97
+ return node.value.length;
98
+ }
99
+ if ('children' in node) {
100
+ return node.children.reduce((sum, child) => sum + textLength(child), 0);
101
+ }
102
+ return 0;
103
+ }
104
+
105
+ /** About how many characters a table row takes, with its pipes. */
106
+ function rowLength(row: Table['children'][number]): number {
107
+ return row.children.reduce((sum, cell) => sum + textLength(cell) + 3, 2);
108
+ }
109
+
110
+ /**
111
+ * Splits each long table into consecutive tables that repeat its header row,
112
+ * so that a passage search returns from the middle of one still says what each
113
+ * column is (ADR-046). A table that fits stays whole.
114
+ */
115
+ function splitLongTables(node: Parents): void {
116
+ const children: RootContent[] = [];
117
+ for (const child of node.children) {
118
+ if ('children' in child && child.type !== 'table') {
119
+ splitLongTables(child);
120
+ }
121
+ if (child.type !== 'table') {
122
+ children.push(child);
123
+ continue;
124
+ }
125
+ const [header, ...rows] = child.children;
126
+ const lengths = child.children.map(rowLength);
127
+ const total = lengths.reduce((sum, length) => sum + length, 0);
128
+ if (!header || rows.length < 2 || total <= SPLIT_TABLE_CHARACTERS) {
129
+ children.push(child);
130
+ continue;
131
+ }
132
+ const headerLength = lengths[0] ?? 0;
133
+ let part: Table['children'] = [];
134
+ let partLength = headerLength;
135
+ const flush = () => {
136
+ children.push({ ...child, children: [header, ...part] });
137
+ part = [];
138
+ partLength = headerLength;
139
+ };
140
+ rows.forEach((row, index) => {
141
+ const length = lengths[index + 1] ?? 0;
142
+ if (part.length > 0 && partLength + length > TABLE_PART_CHARACTERS) {
143
+ flush();
144
+ }
145
+ part.push(row);
146
+ partLength += length;
147
+ });
148
+ flush();
149
+ }
150
+ node.children = children as Parents['children'];
151
+ }
152
+
153
+ function projectBody(
87
154
  body: string,
88
155
  stableSlugs: ReadonlySet<string>,
89
156
  permanentLinks: Readonly<Record<string, string>>,
90
157
  ): string {
91
158
  const tree = processor.parse(body) as Root;
159
+ splitLongTables(tree);
92
160
  walk(tree, (node) => {
93
161
  if (node.type === 'link') {
94
162
  node.url = markdownUrl(node.url, stableSlugs, permanentLinks) ?? node.url;
@@ -164,7 +232,7 @@ export function publishedMarkdownBody(
164
232
  'title' | 'ownershipHeader' | 'body' | 'stableSlugs' | 'permanentLinks'
165
233
  >,
166
234
  ): string {
167
- const body = rewriteInternalLinks(
235
+ const body = projectBody(
168
236
  cleanBody(input.body, input.ownershipHeader),
169
237
  input.stableSlugs,
170
238
  input.permanentLinks,
@@ -217,6 +217,14 @@ export interface McpSearchConfiguration {
217
217
  readonly passageCharacters: number;
218
218
  }
219
219
 
220
+ /** What `recent` lists (ADR-044). */
221
+ export interface McpRecentConfiguration {
222
+ /** Documents it lists when a call does not ask for a number. */
223
+ readonly defaultResults: number;
224
+ /** Documents a call may ask for at most. */
225
+ readonly results: number;
226
+ }
227
+
220
228
  /**
221
229
  * The MCP server through which AI assistants read the site as the person who
222
230
  * connected them (ADR-041). It needs the Worker's sign-in, so it is accepted
@@ -227,6 +235,9 @@ export interface McpConfiguration {
227
235
  readonly search: McpSearchConfiguration;
228
236
  /** Characters `fetch` returns at most; a longer document is cut there. */
229
237
  readonly fetchCharacters: number;
238
+ /** Characters of JSON a `browse` tree takes at most (ADR-044). */
239
+ readonly browseCharacters: number;
240
+ readonly recent: McpRecentConfiguration;
230
241
  }
231
242
 
232
243
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
@@ -241,11 +252,13 @@ export const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'> = Object.freeze({
241
252
  model: '@cf/baai/bge-reranker-base',
242
253
  threshold: 0,
243
254
  }),
244
- results: 10,
255
+ results: 15,
245
256
  passagesPerResult: 3,
246
257
  passageCharacters: 24_000,
247
258
  }),
248
259
  fetchCharacters: 100_000,
260
+ browseCharacters: 24_000,
261
+ recent: Object.freeze({ defaultResults: 20, results: 50 }),
249
262
  });
250
263
 
251
264
  /**
@@ -261,9 +274,6 @@ export function fetchCharacterLimit(mcp: McpConfiguration | undefined): number {
261
274
  /** About 10,000 tokens of English: a starting value, not a measured one. */
262
275
  const LARGE_DOCUMENT_CHARACTERS = 40_000;
263
276
 
264
- /** A passage of fewer characters than this says too little to judge by. */
265
- const SHORTEST_PASSAGE = 100;
266
-
267
277
  export interface SiteConfiguration {
268
278
  /** Required to serve a private environment through the platform's Worker. */
269
279
  readonly signIn?: SignInConfiguration;
@@ -601,11 +611,40 @@ function mcpSearch(value: unknown): McpSearchConfiguration {
601
611
  'passageCharacters',
602
612
  'mcp.search.passageCharacters',
603
613
  defaults.passageCharacters,
604
- { min: results * SHORTEST_PASSAGE, whole: true },
614
+ // Chunks are shown whole, so the budget answers to the chunk size, not
615
+ // to the results: those it cannot hold are listed without text.
616
+ { min: 1_000, whole: true },
605
617
  ),
606
618
  };
607
619
  }
608
620
 
621
+ function mcpRecent(value: unknown): McpRecentConfiguration {
622
+ const defaults = MCP_DEFAULTS.recent;
623
+ if (value === undefined) {
624
+ return defaults;
625
+ }
626
+ const source = record(value, 'mcp.recent');
627
+ knownKeys(source, 'mcp.recent', Object.keys(defaults));
628
+ const results = optionalNumber(
629
+ source,
630
+ 'results',
631
+ 'mcp.recent.results',
632
+ defaults.results,
633
+ { min: 1, whole: true },
634
+ );
635
+ // The default, like a set value, is no more than a call may ask for.
636
+ return {
637
+ defaultResults: optionalNumber(
638
+ source,
639
+ 'defaultResults',
640
+ 'mcp.recent.defaultResults',
641
+ Math.min(defaults.defaultResults, results),
642
+ { min: 1, max: results, whole: true },
643
+ ),
644
+ results,
645
+ };
646
+ }
647
+
609
648
  function optionalMegabytes(
610
649
  source: Record<string, unknown>,
611
650
  key: string,
@@ -806,7 +845,13 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
806
845
  let mcp: McpConfiguration | undefined;
807
846
  if (root.mcp !== undefined) {
808
847
  const source = record(root.mcp, 'mcp');
809
- knownKeys(source, 'mcp', ['enabled', 'search', 'fetchCharacters']);
848
+ knownKeys(source, 'mcp', [
849
+ 'enabled',
850
+ 'search',
851
+ 'fetchCharacters',
852
+ 'browseCharacters',
853
+ 'recent',
854
+ ]);
810
855
  const enabled = flag(source, 'enabled', 'mcp.enabled');
811
856
  if (enabled) {
812
857
  if (!signIn) {
@@ -829,6 +874,14 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
829
874
  MCP_DEFAULTS.fetchCharacters,
830
875
  { min: 1_000, whole: true },
831
876
  ),
877
+ browseCharacters: optionalNumber(
878
+ source,
879
+ 'browseCharacters',
880
+ 'mcp.browseCharacters',
881
+ MCP_DEFAULTS.browseCharacters,
882
+ { min: 1_000, whole: true },
883
+ ),
884
+ recent: mcpRecent(source.recent),
832
885
  };
833
886
  }
834
887
  const fetchLimit = fetchCharacterLimit(mcp);