@ctcstack/ctcdocs-core 0.22.3 → 0.23.1

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.
@@ -29,5 +29,7 @@ export interface AccessConfiguration {
29
29
  readonly rules: readonly AccessRule[];
30
30
  }
31
31
  type Fail = (path: string, expectation: string) => never;
32
+ /** Whether a configured value has the shape of a Drive folder ID. */
33
+ export declare function isDriveFolderId(value: unknown): value is string;
32
34
  export declare function parseAccessConfiguration(value: unknown, fail: Fail): AccessConfiguration;
33
35
  export {};
@@ -15,6 +15,10 @@ export const EVERY_MEMBER = '*';
15
15
  const GROUP_ADDRESS = /^[a-z0-9](?:[a-z0-9._%+-]*[a-z0-9])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u;
16
16
  /** The characters Google uses in file and folder IDs. */
17
17
  const FOLDER_ID = /^[A-Za-z0-9_-]{1,128}$/u;
18
+ /** Whether a configured value has the shape of a Drive folder ID. */
19
+ export function isDriveFolderId(value) {
20
+ return typeof value === 'string' && FOLDER_ID.test(value);
21
+ }
18
22
  function object(value, path, fail) {
19
23
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
20
24
  fail(path, 'must be an object');
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccess
10
10
  export type { AccessClass, AccessModel, ChainReaders, PublishedReaders, Readers, } from './access-classes.js';
11
11
  export { accessFindings } from './access-findings.js';
12
12
  export type { AccessFinding } from './access-findings.js';
13
- export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } 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';
13
+ export { fetchCharacterLimit, INSIGHTS_DEFAULTS, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
14
+ export type { AddressPolicy, BrandConfiguration, DeploymentConfiguration, DeploymentEnvironmentConfiguration, DeploymentEnvironmentConfigurations, DeploymentVisibility, HomeConfiguration, InsightsConfiguration, McpConfiguration, McpRecentConfiguration, McpSearchConfiguration, NavigationConfiguration, SignInConfiguration, SiteConfiguration, StaleFolder, SyncConfigurationDefaults, } from './site-configuration.js';
15
15
  export { agentDocumentFormat, DOCUMENT_FORMAT_BADGES, DOCUMENT_FORMAT_NOUNS, DOCUMENT_FORMATS, DOCUMENT_SOURCE_TYPES, documentFormatOf, documentSourceType, isDocumentSourceType, } from './document-format.js';
16
16
  export type { AgentDocumentFormat, DocumentFormat, DocumentSourceType, } from './document-format.js';
package/dist/index.js CHANGED
@@ -6,5 +6,5 @@ export { EVERY_MEMBER, parseAccessConfiguration, } from './access-configuration.
6
6
  export { CorpusStructureError, EMPTY_CORPUS, parseCorpusStructure, readCorpusStructure, } from './corpus-structure.js';
7
7
  export { ADMINS_CLASS, chainReaders, chainRules, classIdentifiers, computeAccessModel, documentClass, folderChain, folderClass, intersectReaders, MEMBERS_CLASS, nextPublishedReaders, widensReaders, } from './access-classes.js';
8
8
  export { accessFindings } from './access-findings.js';
9
- export { fetchCharacterLimit, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
9
+ export { fetchCharacterLimit, INSIGHTS_DEFAULTS, loadSiteConfiguration, MCP_DEFAULTS, parseSiteConfiguration, SiteConfigurationError, } from './site-configuration.js';
10
10
  export { agentDocumentFormat, DOCUMENT_FORMAT_BADGES, DOCUMENT_FORMAT_NOUNS, DOCUMENT_FORMATS, DOCUMENT_SOURCE_TYPES, documentFormatOf, documentSourceType, isDocumentSourceType, } from './document-format.js';
@@ -93,8 +93,21 @@ export declare const PLATFORM_WORKERS: {
93
93
  readonly documentsBinding: "KB_DOCUMENTS";
94
94
  /** The AI Search instance that indexes that bucket. */
95
95
  readonly searchBinding: "KB_SEARCH";
96
- /** How often the site's Worker checks that the bucket matches its build. */
96
+ /**
97
+ * How often the site's Worker checks that the bucket matches its build,
98
+ * and deletes the records of use past their retention (ADR-050).
99
+ */
97
100
  readonly publishSchedule: "*/5 * * * *";
101
+ /**
102
+ * The database of operational state (ADR-050): records of use, and
103
+ * editors' workflow state. Never content, access or a reader's identity.
104
+ */
105
+ readonly stateDatabaseBinding: "KB_STATE_DB";
106
+ /**
107
+ * The platform's migrations for that database, which the binding names so
108
+ * that `wrangler d1 migrations apply` finds them.
109
+ */
110
+ readonly stateMigrations: "node_modules/@ctcstack/ctcdocs/worker/state/migrations";
98
111
  /** Lets the OAuth library fetch a client's metadata document safely. */
99
112
  readonly oauthCompatibilityFlag: "global_fetch_strictly_public";
100
113
  };
@@ -93,8 +93,21 @@ export const PLATFORM_WORKERS = {
93
93
  documentsBinding: 'KB_DOCUMENTS',
94
94
  /** The AI Search instance that indexes that bucket. */
95
95
  searchBinding: 'KB_SEARCH',
96
- /** How often the site's Worker checks that the bucket matches its build. */
96
+ /**
97
+ * How often the site's Worker checks that the bucket matches its build,
98
+ * and deletes the records of use past their retention (ADR-050).
99
+ */
97
100
  publishSchedule: '*/5 * * * *',
101
+ /**
102
+ * The database of operational state (ADR-050): records of use, and
103
+ * editors' workflow state. Never content, access or a reader's identity.
104
+ */
105
+ stateDatabaseBinding: 'KB_STATE_DB',
106
+ /**
107
+ * The platform's migrations for that database, which the binding names so
108
+ * that `wrangler d1 migrations apply` finds them.
109
+ */
110
+ stateMigrations: 'node_modules/@ctcstack/ctcdocs/worker/state/migrations',
98
111
  /** Lets the OAuth library fetch a client's metadata document safely. */
99
112
  oauthCompatibilityFlag: 'global_fetch_strictly_public',
100
113
  };
@@ -118,6 +118,22 @@ export interface SyncConfigurationDefaults {
118
118
  * or to that limit where it is lower.
119
119
  */
120
120
  readonly largeDocumentCharacters: number;
121
+ /**
122
+ * Months without an edit in Drive after which the content health page asks
123
+ * for a document to be reviewed (ADR-049). Defaults to 12; 0 asks for none.
124
+ */
125
+ readonly staleAfterMonths: number;
126
+ /** Folders that keep their own line, for everything below them. */
127
+ readonly staleFolders: readonly StaleFolder[];
128
+ }
129
+ /** A folder whose documents are asked for review on a line of their own. */
130
+ export interface StaleFolder {
131
+ /** The Drive folder, with everything below it. */
132
+ readonly folder: string;
133
+ /** The folder's name as the author knew it, for review. */
134
+ readonly label: string;
135
+ /** Months without an edit; 0 asks for no review below this folder. */
136
+ readonly months: number;
121
137
  }
122
138
  export interface NavigationConfiguration {
123
139
  /**
@@ -214,6 +230,12 @@ export interface McpConfiguration {
214
230
  /** Characters of JSON a `browse` tree takes at most (ADR-044). */
215
231
  readonly browseCharacters: number;
216
232
  readonly recent: McpRecentConfiguration;
233
+ /**
234
+ * The language the documents are written in, as a language tag such as
235
+ * `en`. The MCP server tells assistants to search in it; unset,
236
+ * it says nothing of the language.
237
+ */
238
+ readonly language?: string;
217
239
  }
218
240
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
219
241
  export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
@@ -224,11 +246,33 @@ export declare const MCP_DEFAULTS: Omit<McpConfiguration, 'enabled'>;
224
246
  * (ADR-043), so the two never derive it apart.
225
247
  */
226
248
  export declare function fetchCharacterLimit(mcp: McpConfiguration | undefined): number;
249
+ /**
250
+ * What people and assistants ask, recorded so editors see what the
251
+ * documentation fails to answer (ADR-051). The records live in the Worker's
252
+ * database (ADR-050), so it needs the MCP server, and with it a private
253
+ * deployment.
254
+ */
255
+ export interface InsightsConfiguration {
256
+ readonly enabled: boolean;
257
+ /** Days a record of use is kept before the Worker's schedule deletes it. */
258
+ readonly retentionDays: number;
259
+ /**
260
+ * The reranker score, 0 to 1, below which a search's best result counts
261
+ * as not found. Unset, the weekly report takes the week's lowest fifth.
262
+ */
263
+ readonly weakScore?: number;
264
+ /** Whether searches on the site are recorded as well as MCP calls. */
265
+ readonly siteSearch: boolean;
266
+ }
267
+ /** What `insights` holds when a project sets only `enabled` (ADR-051). */
268
+ export declare const INSIGHTS_DEFAULTS: Omit<InsightsConfiguration, 'enabled' | 'weakScore'>;
227
269
  export interface SiteConfiguration {
228
270
  /** Required to serve a private environment through the platform's Worker. */
229
271
  readonly signIn?: SignInConfiguration;
230
272
  /** Absent or disabled, the Worker serves no MCP or OAuth route. */
231
273
  readonly mcp?: McpConfiguration;
274
+ /** Absent or disabled, the Worker records nothing of what is asked. */
275
+ readonly insights?: InsightsConfiguration;
232
276
  /**
233
277
  * Who may read which folder (ADR-039). Absent, every document is open to
234
278
  * every reader the deployment admits, as before access rules existed.
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { readFileSync } from 'node:fs';
15
15
  import { resolve } from 'node:path';
16
- import { parseAccessConfiguration, } from './access-configuration.js';
16
+ import { isDriveFolderId, parseAccessConfiguration, } from './access-configuration.js';
17
17
  import { PROJECT_LAYOUT } from './project-layout.js';
18
18
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
19
19
  export const MCP_DEFAULTS = Object.freeze({
@@ -44,6 +44,8 @@ export const MCP_DEFAULTS = Object.freeze({
44
44
  export function fetchCharacterLimit(mcp) {
45
45
  return mcp?.enabled ? mcp.fetchCharacters : MCP_DEFAULTS.fetchCharacters;
46
46
  }
47
+ /** What `insights` holds when a project sets only `enabled` (ADR-051). */
48
+ export const INSIGHTS_DEFAULTS = Object.freeze({ retentionDays: 90, siteSearch: true });
47
49
  /** About 10,000 tokens of English: a starting value, not a measured one. */
48
50
  const LARGE_DOCUMENT_CHARACTERS = 40_000;
49
51
  export class SiteConfigurationError extends Error {
@@ -359,6 +361,8 @@ export function parseSiteConfiguration(input) {
359
361
  'defaultLocale',
360
362
  'largeImageMegabytes',
361
363
  'largeDocumentCharacters',
364
+ 'staleAfterMonths',
365
+ 'staleFolders',
362
366
  ]);
363
367
  const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
364
368
  // The marker is embedded in an HTML comment in every generated Markdown
@@ -423,8 +427,10 @@ export function parseSiteConfiguration(input) {
423
427
  'fetchCharacters',
424
428
  'browseCharacters',
425
429
  'recent',
430
+ 'language',
426
431
  ]);
427
432
  const enabled = flag(source, 'enabled', 'mcp.enabled');
433
+ const language = optionalLanguage(source.language);
428
434
  if (enabled) {
429
435
  if (!signIn) {
430
436
  fail('mcp', 'needs signIn: assistants sign in as the site does');
@@ -439,12 +445,45 @@ export function parseSiteConfiguration(input) {
439
445
  fetchCharacters: optionalNumber(source, 'fetchCharacters', 'mcp.fetchCharacters', MCP_DEFAULTS.fetchCharacters, { min: 1_000, whole: true }),
440
446
  browseCharacters: optionalNumber(source, 'browseCharacters', 'mcp.browseCharacters', MCP_DEFAULTS.browseCharacters, { min: 1_000, whole: true }),
441
447
  recent: mcpRecent(source.recent),
448
+ ...(language ? { language } : {}),
442
449
  };
443
450
  }
444
451
  const fetchLimit = fetchCharacterLimit(mcp);
452
+ let insights;
453
+ if (root.insights !== undefined) {
454
+ const source = record(root.insights, 'insights');
455
+ knownKeys(source, 'insights', [
456
+ 'enabled',
457
+ 'retentionDays',
458
+ 'weakScore',
459
+ 'siteSearch',
460
+ ]);
461
+ const enabled = flag(source, 'enabled', 'insights.enabled');
462
+ if (enabled && mcp?.enabled !== true) {
463
+ fail('insights', 'needs mcp.enabled: it records what assistants ask');
464
+ }
465
+ if (enabled && !access) {
466
+ fail('insights', 'needs access.admins: only admins read the report');
467
+ }
468
+ insights = {
469
+ enabled,
470
+ /*
471
+ * A weekly report goes when the first day it aggregates does, a week
472
+ * after it is made (ADR-051): two weeks keep it readable for one.
473
+ */
474
+ retentionDays: optionalNumber(source, 'retentionDays', 'insights.retentionDays', INSIGHTS_DEFAULTS.retentionDays, { min: 14, max: 365, whole: true }),
475
+ siteSearch: optionalFlag(source, 'siteSearch', 'insights.siteSearch', INSIGHTS_DEFAULTS.siteSearch),
476
+ ...(source.weakScore === undefined
477
+ ? {}
478
+ : {
479
+ weakScore: optionalNumber(source, 'weakScore', 'insights.weakScore', 0, { min: 0, max: 1, whole: false }),
480
+ }),
481
+ };
482
+ }
445
483
  return {
446
484
  ...(signIn ? { signIn } : {}),
447
485
  ...(mcp ? { mcp } : {}),
486
+ ...(insights ? { insights } : {}),
448
487
  ...(access ? { access } : {}),
449
488
  brand,
450
489
  deployment,
@@ -466,9 +505,57 @@ export function parseSiteConfiguration(input) {
466
505
  generatedBy,
467
506
  largeImageMegabytes: optionalMegabytes(syncSource, 'largeImageMegabytes', 'sync.largeImageMegabytes', 2),
468
507
  largeDocumentCharacters: optionalNumber(syncSource, 'largeDocumentCharacters', 'sync.largeDocumentCharacters', Math.min(LARGE_DOCUMENT_CHARACTERS, fetchLimit), { min: 1, max: fetchLimit, whole: true }),
508
+ staleAfterMonths: optionalNumber(syncSource, 'staleAfterMonths', 'sync.staleAfterMonths', 12, STALE_MONTHS),
509
+ staleFolders: optionalStaleFolders(syncSource.staleFolders),
469
510
  },
470
511
  };
471
512
  }
513
+ /** A language tag Intl can read, such as `en` or `uk`. */
514
+ function optionalLanguage(value) {
515
+ if (value === undefined) {
516
+ return undefined;
517
+ }
518
+ try {
519
+ if (typeof value === 'string') {
520
+ return new Intl.Locale(value).toString();
521
+ }
522
+ }
523
+ catch {
524
+ // Reported below.
525
+ }
526
+ return fail('mcp.language', 'must be a language tag such as "en"');
527
+ }
528
+ /** The range a line of months without an edit may take. */
529
+ const STALE_MONTHS = { min: 0, max: 120, whole: true };
530
+ function optionalStaleFolders(value) {
531
+ if (value === undefined) {
532
+ return [];
533
+ }
534
+ if (!Array.isArray(value)) {
535
+ fail('sync.staleFolders', 'must be a list');
536
+ }
537
+ const seen = new Set();
538
+ return value.map((entry, index) => {
539
+ const path = `sync.staleFolders[${index}]`;
540
+ const source = record(entry, path);
541
+ knownKeys(source, path, ['folder', 'label', 'months']);
542
+ if (!isDriveFolderId(source.folder)) {
543
+ fail(`${path}.folder`, 'must be a Drive folder ID');
544
+ }
545
+ if (seen.has(source.folder)) {
546
+ fail(`${path}.folder`, 'must not be listed twice');
547
+ }
548
+ seen.add(source.folder);
549
+ if (source.months === undefined) {
550
+ fail(`${path}.months`, 'must be a whole number from 0 to 120');
551
+ }
552
+ return {
553
+ folder: source.folder,
554
+ label: text(source, 'label', `${path}.label`),
555
+ months: optionalNumber(source, 'months', `${path}.months`, 0, STALE_MONTHS),
556
+ };
557
+ });
558
+ }
472
559
  /**
473
560
  * Parsed configurations, keyed by absolute project root.
474
561
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctcstack/ctcdocs-core",
3
- "version": "0.22.3",
3
+ "version": "0.23.1",
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",
@@ -40,6 +40,11 @@ const GROUP_ADDRESS =
40
40
  /** The characters Google uses in file and folder IDs. */
41
41
  const FOLDER_ID = /^[A-Za-z0-9_-]{1,128}$/u;
42
42
 
43
+ /** Whether a configured value has the shape of a Drive folder ID. */
44
+ export function isDriveFolderId(value: unknown): value is string {
45
+ return typeof value === 'string' && FOLDER_ID.test(value);
46
+ }
47
+
43
48
  function object(
44
49
  value: unknown,
45
50
  path: string,
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export { accessFindings } from './access-findings.js';
66
66
  export type { AccessFinding } from './access-findings.js';
67
67
  export {
68
68
  fetchCharacterLimit,
69
+ INSIGHTS_DEFAULTS,
69
70
  loadSiteConfiguration,
70
71
  MCP_DEFAULTS,
71
72
  parseSiteConfiguration,
@@ -79,12 +80,14 @@ export type {
79
80
  DeploymentEnvironmentConfigurations,
80
81
  DeploymentVisibility,
81
82
  HomeConfiguration,
83
+ InsightsConfiguration,
82
84
  McpConfiguration,
83
85
  McpRecentConfiguration,
84
86
  McpSearchConfiguration,
85
87
  NavigationConfiguration,
86
88
  SignInConfiguration,
87
89
  SiteConfiguration,
90
+ StaleFolder,
88
91
  SyncConfigurationDefaults,
89
92
  } from './site-configuration.js';
90
93
  export {
@@ -95,8 +95,21 @@ export const PLATFORM_WORKERS = {
95
95
  documentsBinding: 'KB_DOCUMENTS',
96
96
  /** The AI Search instance that indexes that bucket. */
97
97
  searchBinding: 'KB_SEARCH',
98
- /** How often the site's Worker checks that the bucket matches its build. */
98
+ /**
99
+ * How often the site's Worker checks that the bucket matches its build,
100
+ * and deletes the records of use past their retention (ADR-050).
101
+ */
99
102
  publishSchedule: '*/5 * * * *',
103
+ /**
104
+ * The database of operational state (ADR-050): records of use, and
105
+ * editors' workflow state. Never content, access or a reader's identity.
106
+ */
107
+ stateDatabaseBinding: 'KB_STATE_DB',
108
+ /**
109
+ * The platform's migrations for that database, which the binding names so
110
+ * that `wrangler d1 migrations apply` finds them.
111
+ */
112
+ stateMigrations: 'node_modules/@ctcstack/ctcdocs/worker/state/migrations',
100
113
  /** Lets the OAuth library fetch a client's metadata document safely. */
101
114
  oauthCompatibilityFlag: 'global_fetch_strictly_public',
102
115
  } as const;
@@ -15,6 +15,7 @@ import { readFileSync } from 'node:fs';
15
15
  import { resolve } from 'node:path';
16
16
 
17
17
  import {
18
+ isDriveFolderId,
18
19
  parseAccessConfiguration,
19
20
  type AccessConfiguration,
20
21
  } from './access-configuration.js';
@@ -145,6 +146,23 @@ export interface SyncConfigurationDefaults {
145
146
  * or to that limit where it is lower.
146
147
  */
147
148
  readonly largeDocumentCharacters: number;
149
+ /**
150
+ * Months without an edit in Drive after which the content health page asks
151
+ * for a document to be reviewed (ADR-049). Defaults to 12; 0 asks for none.
152
+ */
153
+ readonly staleAfterMonths: number;
154
+ /** Folders that keep their own line, for everything below them. */
155
+ readonly staleFolders: readonly StaleFolder[];
156
+ }
157
+
158
+ /** A folder whose documents are asked for review on a line of their own. */
159
+ export interface StaleFolder {
160
+ /** The Drive folder, with everything below it. */
161
+ readonly folder: string;
162
+ /** The folder's name as the author knew it, for review. */
163
+ readonly label: string;
164
+ /** Months without an edit; 0 asks for no review below this folder. */
165
+ readonly months: number;
148
166
  }
149
167
 
150
168
  export interface NavigationConfiguration {
@@ -247,6 +265,12 @@ export interface McpConfiguration {
247
265
  /** Characters of JSON a `browse` tree takes at most (ADR-044). */
248
266
  readonly browseCharacters: number;
249
267
  readonly recent: McpRecentConfiguration;
268
+ /**
269
+ * The language the documents are written in, as a language tag such as
270
+ * `en`. The MCP server tells assistants to search in it; unset,
271
+ * it says nothing of the language.
272
+ */
273
+ readonly language?: string;
250
274
  }
251
275
 
252
276
  /** What `mcp` holds when a project sets only `enabled` (ADR-042). */
@@ -280,6 +304,31 @@ export function fetchCharacterLimit(mcp: McpConfiguration | undefined): number {
280
304
  return mcp?.enabled ? mcp.fetchCharacters : MCP_DEFAULTS.fetchCharacters;
281
305
  }
282
306
 
307
+ /**
308
+ * What people and assistants ask, recorded so editors see what the
309
+ * documentation fails to answer (ADR-051). The records live in the Worker's
310
+ * database (ADR-050), so it needs the MCP server, and with it a private
311
+ * deployment.
312
+ */
313
+ export interface InsightsConfiguration {
314
+ readonly enabled: boolean;
315
+ /** Days a record of use is kept before the Worker's schedule deletes it. */
316
+ readonly retentionDays: number;
317
+ /**
318
+ * The reranker score, 0 to 1, below which a search's best result counts
319
+ * as not found. Unset, the weekly report takes the week's lowest fifth.
320
+ */
321
+ readonly weakScore?: number;
322
+ /** Whether searches on the site are recorded as well as MCP calls. */
323
+ readonly siteSearch: boolean;
324
+ }
325
+
326
+ /** What `insights` holds when a project sets only `enabled` (ADR-051). */
327
+ export const INSIGHTS_DEFAULTS: Omit<
328
+ InsightsConfiguration,
329
+ 'enabled' | 'weakScore'
330
+ > = Object.freeze({ retentionDays: 90, siteSearch: true });
331
+
283
332
  /** About 10,000 tokens of English: a starting value, not a measured one. */
284
333
  const LARGE_DOCUMENT_CHARACTERS = 40_000;
285
334
 
@@ -288,6 +337,8 @@ export interface SiteConfiguration {
288
337
  readonly signIn?: SignInConfiguration;
289
338
  /** Absent or disabled, the Worker serves no MCP or OAuth route. */
290
339
  readonly mcp?: McpConfiguration;
340
+ /** Absent or disabled, the Worker records nothing of what is asked. */
341
+ readonly insights?: InsightsConfiguration;
291
342
  /**
292
343
  * Who may read which folder (ADR-039). Absent, every document is open to
293
344
  * every reader the deployment admits, as before access rules existed.
@@ -786,6 +837,8 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
786
837
  'defaultLocale',
787
838
  'largeImageMegabytes',
788
839
  'largeDocumentCharacters',
840
+ 'staleAfterMonths',
841
+ 'staleFolders',
789
842
  ]);
790
843
  const generatedBy = text(syncSource, 'generatedBy', 'sync.generatedBy');
791
844
  // The marker is embedded in an HTML comment in every generated Markdown
@@ -868,8 +921,10 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
868
921
  'fetchCharacters',
869
922
  'browseCharacters',
870
923
  'recent',
924
+ 'language',
871
925
  ]);
872
926
  const enabled = flag(source, 'enabled', 'mcp.enabled');
927
+ const language = optionalLanguage(source.language);
873
928
  if (enabled) {
874
929
  if (!signIn) {
875
930
  fail('mcp', 'needs signIn: assistants sign in as the site does');
@@ -899,13 +954,64 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
899
954
  { min: 1_000, whole: true },
900
955
  ),
901
956
  recent: mcpRecent(source.recent),
957
+ ...(language ? { language } : {}),
902
958
  };
903
959
  }
904
960
  const fetchLimit = fetchCharacterLimit(mcp);
905
961
 
962
+ let insights: InsightsConfiguration | undefined;
963
+ if (root.insights !== undefined) {
964
+ const source = record(root.insights, 'insights');
965
+ knownKeys(source, 'insights', [
966
+ 'enabled',
967
+ 'retentionDays',
968
+ 'weakScore',
969
+ 'siteSearch',
970
+ ]);
971
+ const enabled = flag(source, 'enabled', 'insights.enabled');
972
+ if (enabled && mcp?.enabled !== true) {
973
+ fail('insights', 'needs mcp.enabled: it records what assistants ask');
974
+ }
975
+ if (enabled && !access) {
976
+ fail('insights', 'needs access.admins: only admins read the report');
977
+ }
978
+ insights = {
979
+ enabled,
980
+ /*
981
+ * A weekly report goes when the first day it aggregates does, a week
982
+ * after it is made (ADR-051): two weeks keep it readable for one.
983
+ */
984
+ retentionDays: optionalNumber(
985
+ source,
986
+ 'retentionDays',
987
+ 'insights.retentionDays',
988
+ INSIGHTS_DEFAULTS.retentionDays,
989
+ { min: 14, max: 365, whole: true },
990
+ ),
991
+ siteSearch: optionalFlag(
992
+ source,
993
+ 'siteSearch',
994
+ 'insights.siteSearch',
995
+ INSIGHTS_DEFAULTS.siteSearch,
996
+ ),
997
+ ...(source.weakScore === undefined
998
+ ? {}
999
+ : {
1000
+ weakScore: optionalNumber(
1001
+ source,
1002
+ 'weakScore',
1003
+ 'insights.weakScore',
1004
+ 0,
1005
+ { min: 0, max: 1, whole: false },
1006
+ ),
1007
+ }),
1008
+ };
1009
+ }
1010
+
906
1011
  return {
907
1012
  ...(signIn ? { signIn } : {}),
908
1013
  ...(mcp ? { mcp } : {}),
1014
+ ...(insights ? { insights } : {}),
909
1015
  ...(access ? { access } : {}),
910
1016
  brand,
911
1017
  deployment,
@@ -964,10 +1070,72 @@ export function parseSiteConfiguration(input: unknown): SiteConfiguration {
964
1070
  Math.min(LARGE_DOCUMENT_CHARACTERS, fetchLimit),
965
1071
  { min: 1, max: fetchLimit, whole: true },
966
1072
  ),
1073
+ staleAfterMonths: optionalNumber(
1074
+ syncSource,
1075
+ 'staleAfterMonths',
1076
+ 'sync.staleAfterMonths',
1077
+ 12,
1078
+ STALE_MONTHS,
1079
+ ),
1080
+ staleFolders: optionalStaleFolders(syncSource.staleFolders),
967
1081
  },
968
1082
  };
969
1083
  }
970
1084
 
1085
+ /** A language tag Intl can read, such as `en` or `uk`. */
1086
+ function optionalLanguage(value: unknown): string | undefined {
1087
+ if (value === undefined) {
1088
+ return undefined;
1089
+ }
1090
+ try {
1091
+ if (typeof value === 'string') {
1092
+ return new Intl.Locale(value).toString();
1093
+ }
1094
+ } catch {
1095
+ // Reported below.
1096
+ }
1097
+ return fail('mcp.language', 'must be a language tag such as "en"');
1098
+ }
1099
+
1100
+ /** The range a line of months without an edit may take. */
1101
+ const STALE_MONTHS = { min: 0, max: 120, whole: true } as const;
1102
+
1103
+ function optionalStaleFolders(value: unknown): StaleFolder[] {
1104
+ if (value === undefined) {
1105
+ return [];
1106
+ }
1107
+ if (!Array.isArray(value)) {
1108
+ fail('sync.staleFolders', 'must be a list');
1109
+ }
1110
+ const seen = new Set<string>();
1111
+ return value.map((entry: unknown, index) => {
1112
+ const path = `sync.staleFolders[${index}]`;
1113
+ const source = record(entry, path);
1114
+ knownKeys(source, path, ['folder', 'label', 'months']);
1115
+ if (!isDriveFolderId(source.folder)) {
1116
+ fail(`${path}.folder`, 'must be a Drive folder ID');
1117
+ }
1118
+ if (seen.has(source.folder)) {
1119
+ fail(`${path}.folder`, 'must not be listed twice');
1120
+ }
1121
+ seen.add(source.folder);
1122
+ if (source.months === undefined) {
1123
+ fail(`${path}.months`, 'must be a whole number from 0 to 120');
1124
+ }
1125
+ return {
1126
+ folder: source.folder,
1127
+ label: text(source, 'label', `${path}.label`),
1128
+ months: optionalNumber(
1129
+ source,
1130
+ 'months',
1131
+ `${path}.months`,
1132
+ 0,
1133
+ STALE_MONTHS,
1134
+ ),
1135
+ };
1136
+ });
1137
+ }
1138
+
971
1139
  /**
972
1140
  * Parsed configurations, keyed by absolute project root.
973
1141
  *