@anthusai/papyrus 1.3.0 → 1.4.0-next.10

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.
Files changed (141) hide show
  1. package/amplify/data/resource.js +134 -3
  2. package/amplify/data/resource.ts +145 -3
  3. package/amplify/data/schema.js +131 -0
  4. package/amplify/data/schema.ts +142 -0
  5. package/amplify/functions/category-action/handler.ts +272 -20
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/content-actions/handler.py +23 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -1
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  12. package/amplify/functions/renditions/requirements.txt +1 -1
  13. package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
  14. package/amplify/functions/shared/curation-delegation.ts +115 -0
  15. package/amplify/functions/shared/python-bundle.js +3 -3
  16. package/amplify/functions/shared/python-bundle.ts +3 -3
  17. package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
  18. package/amplify/functions/shared/slack-events.ts +2 -2
  19. package/amplify/publication/renditions-function.js +1 -1
  20. package/amplify/publication/renditions-function.ts +1 -1
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  22. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  23. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  24. package/app/[year]/[month]/[day]/page.tsx +3 -0
  25. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  26. package/app/api/archive/editions/route.ts +2 -0
  27. package/app/api/auth/prospectus/accounts/route.ts +9 -0
  28. package/app/api/local-preview/derive/route.ts +28 -0
  29. package/app/api/local-preview/media/route.ts +25 -0
  30. package/app/archive/page.tsx +3 -0
  31. package/app/globals.css +28 -28
  32. package/app/layout.tsx +8 -5
  33. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  34. package/app/newsroom/[section]/page.tsx +8 -1
  35. package/app/newsroom/articles/page.tsx +10 -0
  36. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  37. package/app/newsroom/insights/page.tsx +4 -12
  38. package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
  39. package/app/page.tsx +30 -1
  40. package/components/administration-delegations-panel.tsx +156 -0
  41. package/components/curation-delegation-client.ts +104 -0
  42. package/components/item-stream-view.tsx +63 -0
  43. package/components/markus-ir-preview.tsx +47 -2
  44. package/components/news-desk-client-provider.tsx +17 -2
  45. package/components/news-desk-page.tsx +0 -1
  46. package/components/news-desk-taxonomy-client.ts +154 -240
  47. package/components/newspaper.tsx +4 -4
  48. package/components/newsroom-article-editor.tsx +138 -40
  49. package/components/newsroom-assignment-detail.tsx +79 -0
  50. package/components/newsroom-assignments-view.tsx +1 -1
  51. package/components/newsroom-board-view.tsx +153 -43
  52. package/components/newsroom-client-shell.tsx +8 -1
  53. package/components/newsroom-newsprint-board.css +5 -46
  54. package/components/newsroom-ops-overview.tsx +27 -5
  55. package/components/newsroom-organizer-shell.tsx +36 -13
  56. package/components/newsroom-organizer.css +269 -13
  57. package/components/newsroom-references-view.tsx +118 -23
  58. package/components/newsroom-topics-view.tsx +1 -1
  59. package/components/priming-proof-page.tsx +17 -8
  60. package/components/prospectus-account-chooser.tsx +131 -0
  61. package/components/reference-cyclotron-priming.tsx +85 -0
  62. package/components/reference-cyclotron-status.tsx +5 -5
  63. package/components/reference-cyclotron-suggestion.tsx +143 -0
  64. package/components/reference-live-agreement.tsx +62 -0
  65. package/components/reference-section-suggestion.tsx +104 -0
  66. package/components/story-reporting-trail-client.ts +91 -0
  67. package/components/story-reporting-trail.tsx +115 -0
  68. package/components/topic-steering-workspace.tsx +280 -679
  69. package/components/ui/sheet.tsx +10 -4
  70. package/components/use-paged-content-item-versions.ts +151 -0
  71. package/components/wiki-concepts-view.tsx +153 -0
  72. package/components/wiki-index-client.ts +51 -0
  73. package/infra/amplify-app-shell.js +17 -3
  74. package/infra/amplify-app-shell.ts +18 -3
  75. package/infra/site-config.js +6 -1
  76. package/infra/site-config.ts +7 -1
  77. package/lib/approved-priming-proof-reports.ts +6 -3
  78. package/lib/approved-priming-proofs/papyrus.json +90 -0
  79. package/lib/article-flow.ts +95 -0
  80. package/lib/article-frame-solver.ts +519 -0
  81. package/lib/blog-feature-solver.ts +1 -109
  82. package/lib/category-repository.ts +1 -1
  83. package/lib/chrome-text.ts +504 -0
  84. package/lib/content-actions-client.ts +3 -0
  85. package/lib/content-actions-demo.ts +55 -0
  86. package/lib/content-item-lifecycle.ts +34 -5
  87. package/lib/content-source-context.ts +9 -1
  88. package/lib/curation-delegation-helpers.ts +69 -0
  89. package/lib/cyclotron-suggestions.ts +263 -0
  90. package/lib/front-matter-fields.ts +42 -0
  91. package/lib/front-mosaic.ts +1133 -0
  92. package/lib/furniture.ts +385 -0
  93. package/lib/graphql-content-repository.ts +42 -7
  94. package/lib/item-stream.ts +47 -0
  95. package/lib/labels.ts +37 -0
  96. package/lib/layout-config.ts +237 -0
  97. package/lib/layout-plan.ts +40 -11
  98. package/lib/layout-scenarios.ts +21 -11
  99. package/lib/layout-types.ts +457 -0
  100. package/lib/layout-warnings.ts +8 -0
  101. package/lib/local-preview-section-fixtures.ts +72 -0
  102. package/lib/local-preview-seed.ts +286 -0
  103. package/lib/local-preview-tenant.ts +27 -0
  104. package/lib/local-preview.ts +3 -0
  105. package/lib/newspaper-layout.ts +17 -3410
  106. package/lib/newsroom-articles.ts +2 -0
  107. package/lib/newsroom-assignments.ts +14 -1
  108. package/lib/newsroom-board.ts +99 -47
  109. package/lib/newsroom-demo-dashboard.ts +1 -1
  110. package/lib/newsroom-forum-routes.ts +2 -43
  111. package/lib/newsroom-index-filters.ts +3 -40
  112. package/lib/newsroom-media-backend.ts +5 -0
  113. package/lib/newsroom-nav.ts +1 -3
  114. package/lib/newsroom-organizer-nav.ts +9 -16
  115. package/lib/newsroom-section-routing.ts +1 -1
  116. package/lib/newsroom-sections.ts +12 -0
  117. package/lib/papyrus-web-locations.ts +3 -24
  118. package/lib/pretext-layout.ts +22 -0
  119. package/lib/priming-proof-report.ts +1 -0
  120. package/lib/prospectus-account-client.ts +90 -0
  121. package/lib/prospectus-sign-in-routes.ts +45 -0
  122. package/lib/prospectus-site-identity.ts +13 -1
  123. package/lib/prospectus-tenant.ts +22 -0
  124. package/lib/reader-static-params.ts +2 -1
  125. package/lib/relevance-decisions.ts +29 -0
  126. package/lib/relevance-status.ts +73 -0
  127. package/lib/reporting-story-budget.ts +2 -2
  128. package/lib/semantic-graph.ts +5 -5
  129. package/lib/site-brand.ts +28 -1
  130. package/lib/site-env.ts +5 -0
  131. package/lib/site-section-suggestions.ts +120 -0
  132. package/lib/site-stack.ts +1 -1
  133. package/lib/story-reporting-trail.ts +106 -0
  134. package/lib/tenant-keys.ts +32 -0
  135. package/lib/text-columns.ts +251 -0
  136. package/lib/wiki.ts +56 -0
  137. package/package.json +46 -3
  138. package/renderers/pretext/presentation-shell.tsx +1 -1
  139. package/renderers/pretext/rhythm-article-page.tsx +1 -18
  140. package/routes.manifest.json +34 -15
  141. package/app/newsroom/work/page.tsx +0 -10
@@ -3,12 +3,10 @@ import { PAPYRUS_OBJECT_KINDS, type PapyrusObjectKind } from "./papyrus-object-k
3
3
  import {
4
4
  buildNewsroomIndexWebPath,
5
5
  effectiveAssignmentsIndexFilters,
6
- effectiveInsightsIndexFilters,
7
6
  effectiveMessagesIndexFilters,
8
7
  effectiveReferencesIndexFilters,
9
8
  normalizeReferenceIndexOrder,
10
9
  type AssignmentsIndexFilters,
11
- type InsightsIndexFilters,
12
10
  type MessagesIndexFilters,
13
11
  type NewsroomIndexTab,
14
12
  type ReferencesIndexFilters,
@@ -27,7 +25,6 @@ export type PapyrusWebUiContext = {
27
25
  const NEWSROOM_TAB_IDS = new Set([
28
26
  "overview",
29
27
  "messages",
30
- "insights",
31
28
  "assignments",
32
29
  "references",
33
30
  "topics",
@@ -123,21 +120,10 @@ export function webPathToPapyrusLocation(webPath: string): PapyrusWebUiContext {
123
120
  });
124
121
  }
125
122
 
126
- if ((tab === "references" || tab === "messages" || tab === "assignments" || tab === "insights") && segments.length === 2) {
123
+ if ((tab === "references" || tab === "messages" || tab === "assignments") && segments.length === 2) {
127
124
  return newsroomIndexLocation(tab as NewsroomIndexTab, normalized, url.searchParams);
128
125
  }
129
126
 
130
- if (tab === "insights" && segments[2]) {
131
- const threadId = decodeURIComponent(segments[2]);
132
- const objectUri = `papyrus://message/${encodeURIComponent(threadId)}`;
133
- return location(objectUri, normalized, {
134
- papyrusObjectUri: objectUri,
135
- newsroomTab: "insights",
136
- viewMode: "detail",
137
- label: `Insight thread ${threadId}`,
138
- });
139
- }
140
-
141
127
  if ((tab === "references" || tab === "messages" || tab === "assignments") && segments[2]) {
142
128
  const objectId = decodeURIComponent(segments[2]);
143
129
  const kind = tab === "references" ? "reference" : tab === "messages" ? "message" : "assignment";
@@ -277,7 +263,7 @@ export function papyrusUriToWebPath(uri: string): PapyrusUriToWebPathResult {
277
263
  }
278
264
 
279
265
  if (NEWSROOM_TAB_IDS.has(id)) {
280
- if (id === "references" || id === "messages" || id === "assignments" || id === "insights") {
266
+ if (id === "references" || id === "messages" || id === "assignments") {
281
267
  const mapped = newsroomIndexUriToWebPath(raw, `${id}/index`);
282
268
  if (mapped) return mapped;
283
269
  }
@@ -347,9 +333,6 @@ function readIndexFiltersFromSearchParams(tab: NewsroomIndexTab, searchParams: U
347
333
  kind: searchParams.get("kind")?.trim() ?? undefined,
348
334
  domain: searchParams.get("domain")?.trim() ?? undefined,
349
335
  });
350
- if (tab === "insights") return effectiveInsightsIndexFilters({
351
- domain: searchParams.get("domain")?.trim() ?? undefined,
352
- });
353
336
  return effectiveAssignmentsIndexFilters({
354
337
  status: searchParams.get("status")?.trim() ?? undefined,
355
338
  type: searchParams.get("type")?.trim() ?? undefined,
@@ -368,9 +351,6 @@ function buildNewsroomIndexLocationUri(tab: NewsroomIndexTab, filters: Record<st
368
351
  const normalized = filters as MessagesIndexFilters;
369
352
  if (normalized.kind?.trim()) segments.push("kind", normalized.kind.trim());
370
353
  if (normalized.domain?.trim()) segments.push("domain", normalized.domain.trim());
371
- } else if (tab === "insights") {
372
- const normalized = filters as InsightsIndexFilters;
373
- if (normalized.domain?.trim()) segments.push("domain", normalized.domain.trim());
374
354
  } else {
375
355
  const normalized = filters as AssignmentsIndexFilters;
376
356
  if (normalized.status?.trim()) segments.push("status", normalized.status.trim());
@@ -400,7 +380,7 @@ function parseIndexUriTail(objectId: string): { tab: NewsroomIndexTab; filters:
400
380
  const segments = objectId.split("/").filter(Boolean);
401
381
  if (!segments.length) return null;
402
382
  const tab = segments[0];
403
- if (tab !== "references" && tab !== "messages" && tab !== "assignments" && tab !== "insights") return null;
383
+ if (tab !== "references" && tab !== "messages" && tab !== "assignments") return null;
404
384
  if (segments.length === 1) {
405
385
  return { tab, filters: readIndexFiltersFromSearchParams(tab, new URLSearchParams()) };
406
386
  }
@@ -414,7 +394,6 @@ function parseIndexUriTail(objectId: string): { tab: NewsroomIndexTab; filters:
414
394
  }
415
395
  if (tab === "references") return { tab, filters: effectiveReferencesIndexFilters(filters) };
416
396
  if (tab === "messages") return { tab, filters: effectiveMessagesIndexFilters(filters) };
417
- if (tab === "insights") return { tab, filters: effectiveInsightsIndexFilters(filters) };
418
397
  return { tab, filters: effectiveAssignmentsIndexFilters(filters) };
419
398
  }
420
399
 
@@ -9,6 +9,27 @@ import {
9
9
 
10
10
  export { layoutNextLine, prepareWithSegments, type LayoutCursor, type PreparedTextWithSegments };
11
11
 
12
+ const preparedTextCache = new Map<string, PreparedTextWithSegments>();
13
+ let preparedTextCacheMissCount = 0;
14
+
15
+ export function getPreparedTextCacheMissCount(): number {
16
+ return preparedTextCacheMissCount;
17
+ }
18
+
19
+ export function prepareWithSegmentsCached(
20
+ text: string,
21
+ font: string,
22
+ options?: { whiteSpace?: "normal" | "pre-wrap" },
23
+ ): PreparedTextWithSegments {
24
+ const key = `${options?.whiteSpace ?? "normal"}\u0000${font}\u0000${text}`;
25
+ const cached = preparedTextCache.get(key);
26
+ if (cached) return cached;
27
+ preparedTextCacheMissCount += 1;
28
+ const prepared = prepareWithSegments(text, font, options);
29
+ preparedTextCache.set(key, prepared);
30
+ return prepared;
31
+ }
32
+
12
33
  export type TextLine = {
13
34
  text: string;
14
35
  width: number;
@@ -184,5 +205,6 @@ function clamp(value: number, min: number, max: number): number {
184
205
  }
185
206
 
186
207
  export function clearPretextMeasurementCache(): void {
208
+ preparedTextCache.clear();
187
209
  clearCache();
188
210
  }
@@ -36,6 +36,7 @@ export const primingProofReportSchema = z.object({
36
36
  fingerprint: z.string().min(1),
37
37
  frozenAt: z.string().min(1),
38
38
  source: z.string().min(1),
39
+ design: z.string().min(1).optional(),
39
40
  }),
40
41
  priming: z.object({
41
42
  acceptedExamples: z.number().int().min(0),
@@ -0,0 +1,90 @@
1
+ export interface ProspectusPublishingAccountChoice {
2
+ accountId: string;
3
+ name?: string;
4
+ role: string;
5
+ }
6
+
7
+ export type ProspectusAccountState =
8
+ | { status: "unavailable" }
9
+ | { status: "signedOut"; signInUrl: string }
10
+ | {
11
+ status: "signedIn";
12
+ publishingAccounts: ProspectusPublishingAccountChoice[];
13
+ selectedAccountId: string | null;
14
+ cyclotronLabAccess: boolean;
15
+ };
16
+
17
+ type FetchImplementation = (input: string, init?: RequestInit) => Promise<Response>;
18
+
19
+ const SESSION_PATH = "/api/auth/prospectus/session";
20
+ const SELECTION_PATH = "/api/auth/prospectus/selection";
21
+ const ACCOUNTS_PATH = "/api/auth/prospectus/accounts";
22
+ const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
23
+
24
+ export function prospectusSignInUrl(returnPath: string): string {
25
+ return `${SIGN_IN_PATH}?returnTo=${encodeURIComponent(returnPath)}`;
26
+ }
27
+
28
+ export async function loadProspectusAccountState(
29
+ returnPath: string,
30
+ fetchImplementation: FetchImplementation = fetch,
31
+ ): Promise<ProspectusAccountState> {
32
+ let response: Response;
33
+ try {
34
+ response = await fetchImplementation(SESSION_PATH, { credentials: "same-origin", cache: "no-store" });
35
+ } catch {
36
+ return { status: "unavailable" };
37
+ }
38
+ if (!response.ok) return { status: "unavailable" };
39
+ const body = await response.json().catch(() => null) as
40
+ | { signedIn?: boolean; publishingAccounts?: ProspectusPublishingAccountChoice[]; selectedAccountId?: string | null; cyclotronLabAccess?: boolean }
41
+ | null;
42
+ if (!body) return { status: "unavailable" };
43
+ if (!body.signedIn) return { status: "signedOut", signInUrl: prospectusSignInUrl(returnPath) };
44
+ return {
45
+ status: "signedIn",
46
+ publishingAccounts: Array.isArray(body.publishingAccounts) ? body.publishingAccounts : [],
47
+ selectedAccountId: body.selectedAccountId ?? null,
48
+ cyclotronLabAccess: body.cyclotronLabAccess === true,
49
+ };
50
+ }
51
+
52
+ export async function selectProspectusAccount(
53
+ accountId: string,
54
+ fetchImplementation: FetchImplementation = fetch,
55
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
56
+ const response = await fetchImplementation(SELECTION_PATH, {
57
+ method: "POST",
58
+ credentials: "same-origin",
59
+ headers: { "content-type": "application/json" },
60
+ body: JSON.stringify({ accountId }),
61
+ });
62
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
63
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
64
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
65
+ }
66
+
67
+ /**
68
+ * Create a publishing account for the signed-in person and select it. Each call carries a fresh idempotency key,
69
+ * so a retried request after a dropped response cannot create a second account.
70
+ */
71
+ export async function createProspectusAccount(
72
+ name: string,
73
+ fetchImplementation: FetchImplementation = fetch,
74
+ idempotencyKey: string = crypto.randomUUID(),
75
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
76
+ let response: Response;
77
+ try {
78
+ response = await fetchImplementation(ACCOUNTS_PATH, {
79
+ method: "POST",
80
+ credentials: "same-origin",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ name: name.trim() || undefined, idempotencyKey }),
83
+ });
84
+ } catch {
85
+ return { error: "The publishing account could not be created. Check your connection and try again." };
86
+ }
87
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
88
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "The publishing account could not be created." };
89
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
90
+ }
@@ -282,3 +282,48 @@ export async function handleProspectusAccountSelection(request: NextRequest, opt
282
282
  return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
283
283
  }
284
284
  }
285
+
286
+ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
+ const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
+ const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
+
290
+ export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
291
+ const runtime = await signInRuntimeFor(request, options);
292
+ if (!runtime) return signInUnavailableResponse();
293
+ if (request.headers.get("origin") !== request.nextUrl.origin) {
294
+ return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
+ }
296
+ const nowInSeconds = currentTimeInSeconds(options);
297
+ const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
298
+ if (!session) return signInFailedResponse(401, "signed_out", "Sign in first.");
299
+ const body = await request.json().catch(() => null) as { name?: unknown; idempotencyKey?: unknown } | null;
300
+ const requestedName = typeof body?.name === "string" ? body.name.trim() : "";
301
+ const accountName = requestedName || DEFAULT_PUBLISHING_ACCOUNT_NAME;
302
+ if (accountName.length > MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH) {
303
+ return signInFailedResponse(400, "account_name_too_long", "Name the account in 150 characters or fewer.");
304
+ }
305
+ const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey : "";
306
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
307
+ return signInFailedResponse(400, "idempotency_key_required", "Send a UUID idempotency key.");
308
+ }
309
+ try {
310
+ const created = await runtime.accounts.createAccount(session.accessToken, accountName, idempotencyKey);
311
+ const selection = await freshlyAuthorizedPublishingSelection(runtime, session, created.account.id, siteProductFor(options));
312
+ if (!selection) return signInFailedResponse(403, "not_a_publishing_account", "The new account does not grant publishing access on this site.");
313
+ const response = NextResponse.json(
314
+ { selectedAccountId: selection.accountId, role: selection.role, publicationCapabilities: selection.publicationCapabilities },
315
+ { status: 201, headers: NO_STORE_HEADERS },
316
+ );
317
+ const selectedSession: ProspectusSignedInSession = { ...session, selectedAccountId: selection.accountId };
318
+ response.cookies.set(
319
+ PROSPECTUS_SESSION_COOKIE,
320
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, selectedSession, session.accessTokenExpiresAt),
321
+ sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
322
+ );
323
+ return response;
324
+ } catch (error) {
325
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
326
+ console.error(`Prospectus account creation failed (${error instanceof Error ? error.name : "error"}).`);
327
+ return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
328
+ }
329
+ }
@@ -49,6 +49,7 @@ export interface ProspectusRequest {
49
49
  body?: unknown;
50
50
  humanAccessToken?: string;
51
51
  identityToken?: string;
52
+ idempotencyKey?: string;
52
53
  }
53
54
 
54
55
  export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
@@ -84,7 +85,7 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
84
85
  }
85
86
  const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
86
87
  const fetcher = options.fetcher ?? fetch;
87
- return async ({ path, method, body, humanAccessToken, identityToken }) => {
88
+ return async ({ path, method, body, humanAccessToken, identityToken, idempotencyKey }) => {
88
89
  const credentials = await runtimeCredentials();
89
90
  if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
90
91
  throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
@@ -95,6 +96,7 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
95
96
  host: url.host,
96
97
  ...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
97
98
  ...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
99
+ ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
98
100
  ...(payload ? { "content-type": "application/json" } : {}),
99
101
  };
100
102
  const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
@@ -144,6 +146,16 @@ export class ProspectusSiteAccounts {
144
146
  }) as Promise<ProspectusAccountAuthorization>;
145
147
  }
146
148
 
149
+ createAccount(humanAccessToken: string, accountName: string, idempotencyKey: string): Promise<{ account: { id: string; name: string; role: ProspectusRole } }> {
150
+ return this.transport({
151
+ path: "/v1/accounts",
152
+ method: "POST",
153
+ body: { name: accountName },
154
+ humanAccessToken,
155
+ idempotencyKey,
156
+ }) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
157
+ }
158
+
147
159
  accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
148
160
  return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
149
161
  person: ProspectusPerson | null;
@@ -0,0 +1,22 @@
1
+ import {
2
+ PROSPECTUS_SESSION_COOKIE,
3
+ loadProspectusSessionSealingSecret,
4
+ openProspectusCookie,
5
+ type ProspectusSignedInSession,
6
+ } from "./prospectus-session";
7
+ import { tenantIdForProspectusAccount } from "./tenant-keys";
8
+
9
+ type CookieReader = { get(name: string): { value: string } | undefined };
10
+
11
+ /**
12
+ * The tenant of the publishing account the signed-in person selected, read from the sealed Prospectus session
13
+ * cookie. Returns undefined on a site without Prospectus, with no session, or with no account selected, so the
14
+ * caller falls back to the site's own tenant (PAPYRUS_TENANT_ID).
15
+ */
16
+ export async function selectedTenantIdFromCookies(cookies: CookieReader, loadSecret: () => Promise<string | null> = loadProspectusSessionSealingSecret): Promise<string | undefined> {
17
+ const sealingSecret = await loadSecret();
18
+ if (!sealingSecret) return undefined;
19
+ const session = openProspectusCookie<ProspectusSignedInSession>(sealingSecret, PROSPECTUS_SESSION_COOKIE, cookies.get(PROSPECTUS_SESSION_COOKIE)?.value);
20
+ if (!session || session.accessTokenExpiresAt * 1000 <= Date.now()) return undefined;
21
+ return tenantIdForProspectusAccount(session.selectedAccountId);
22
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ContentRepository, EditionContent } from "./content-types";
2
2
  import { graphqlContentRepository } from "./graphql-content-repository";
3
- import { SITE_BRAND } from "./site-brand";
3
+ import { SITE_BRAND, editionsEnabled } from "./site-brand";
4
4
  import { getContentSource } from "./site-env";
5
5
  import type { RendererConfig } from "./renderer-config";
6
6
 
@@ -40,6 +40,7 @@ const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
40
40
  type EnumerationRepository = Pick<ContentRepository, "listPublishedEditions" | "loadEditionContent">;
41
41
 
42
42
  function shouldEnumerateAtBuild(): boolean {
43
+ if (!editionsEnabled()) return false;
43
44
  if (process.env.NODE_ENV === "development") return false;
44
45
  return siteEnumeratesReaderRoutesAtBuild();
45
46
  }
@@ -2,6 +2,15 @@ import type { SemanticRelationRecord } from "./category-repository";
2
2
 
3
3
  export const RELEVANCE_RELATION = "relevance_decision_is";
4
4
 
5
+ /** What produced a suggestion beyond the model version, as the sweep recorded it. */
6
+ export type RelevanceProvenance = {
7
+ labelSet: string[];
8
+ doctrineFingerprint: string | null;
9
+ teachingFingerprint: string | null;
10
+ teachingLabels: number;
11
+ primingExamples: number;
12
+ };
13
+
5
14
  /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
6
15
  export type RelevanceDecisionView = {
7
16
  decisionRelationId: string;
@@ -19,6 +28,10 @@ export type RelevanceDecisionView = {
19
28
  /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
20
29
  reviewControl: "thumbs" | "labels";
21
30
  cyclotronId: string | null;
31
+ /** When the cyclotron made the suggestion, if the decision recorded it. */
32
+ decidedAt: string | null;
33
+ /** Null for decisions made before provenance was recorded. */
34
+ provenance: RelevanceProvenance | null;
22
35
  };
23
36
 
24
37
  /** What an editor submits from the review control. */
@@ -49,6 +62,20 @@ function parseMetadata(value: unknown): Record<string, unknown> {
49
62
  }
50
63
  }
51
64
 
65
+ function parseProvenance(value: unknown): RelevanceProvenance | null {
66
+ const record = parseMetadata(value);
67
+ if (!Object.keys(record).length) return null;
68
+ const count = (entry: unknown) => (Number.isFinite(Number(entry)) ? Number(entry) : 0);
69
+ const text = (entry: unknown) => (typeof entry === "string" && entry ? entry : null);
70
+ return {
71
+ labelSet: Array.isArray(record.labelSet) ? record.labelSet.map(String) : [],
72
+ doctrineFingerprint: text(record.doctrineFingerprint),
73
+ teachingFingerprint: text(record.teachingFingerprint),
74
+ teachingLabels: count(record.teachingLabels),
75
+ primingExamples: count(record.primingExamples),
76
+ };
77
+ }
78
+
52
79
  /**
53
80
  * Current decisions keyed by reference lineage, from relations the Newsroom
54
81
  * already loads: no extra request per row.
@@ -77,6 +104,8 @@ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]):
77
104
  question: typeof metadata.question === "string" ? metadata.question : null,
78
105
  reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
79
106
  cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
107
+ decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
108
+ provenance: parseProvenance(metadata.provenance),
80
109
  });
81
110
  }
82
111
  return decisions;
@@ -61,3 +61,76 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
61
61
  lastChange: null,
62
62
  pending: { decisionsAwaitingReview: 1, staleSince: null },
63
63
  };
64
+
65
+ /**
66
+ * What the Newsroom assumes until a decision names the cyclotron: the defaults
67
+ * of the steering config's relevanceCyclotron block (src/papyrus_content/relevance_cyclotron.py).
68
+ */
69
+ export const DEFAULT_RELEVANCE_CYCLOTRON = { id: "papyrus-relevance", labels: ["include", "exclude"] } as const;
70
+
71
+ /** The payload kind of a priming request; the sweep lists the cyclotron's payloads and filters on it. */
72
+ export const PRIMING_REQUEST_KIND = "cyclotron-priming-request";
73
+
74
+ /** The attachment role the sweep writes a request's outcome under. */
75
+ export const PRIMING_OUTCOME_ROLE = "priming_outcome";
76
+
77
+ /** The KnowledgeRawPayload an editor writes to ask the next sweep to prime the cyclotron with one URL. */
78
+ export function primingRequestPayloadId(cyclotronId: string, requestId: string): string {
79
+ return `knowledge-raw-payload-cyclotron-priming-request-${safeId(cyclotronId)}-${safeId(requestId)}`;
80
+ }
81
+
82
+ export type PrimingRequest = {
83
+ requestId: string;
84
+ url: string;
85
+ label: string;
86
+ explanation: string;
87
+ requestedBy: string;
88
+ requestedAt: string;
89
+ };
90
+
91
+ export type PrimingOutcome =
92
+ | { state: "applied"; version: number; at: string; itemId?: string }
93
+ | { state: "failed"; message: string; at: string };
94
+
95
+ /** A saved request with the sweep's outcome, once there is one. */
96
+ export type PrimingRequestView = { request: PrimingRequest; outcome: PrimingOutcome | null };
97
+
98
+ export function buildPrimingRequest(
99
+ input: { url: string; label: string; explanation?: string },
100
+ requestedBy: string,
101
+ labels: readonly string[],
102
+ now: Date = new Date(),
103
+ requestId: string = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
104
+ ): PrimingRequest {
105
+ let parsed: URL | null = null;
106
+ try {
107
+ parsed = new URL(input.url.trim());
108
+ } catch {
109
+ parsed = null;
110
+ }
111
+ if (!parsed || parsed.protocol !== "https:") throw new Error("Paste an https URL.");
112
+ if (!labels.includes(input.label)) throw new Error(`Choose a label: ${labels.join(" or ")}.`);
113
+ return {
114
+ requestId,
115
+ url: parsed.toString(),
116
+ label: input.label,
117
+ explanation: (input.explanation ?? "").trim(),
118
+ requestedBy,
119
+ requestedAt: now.toISOString(),
120
+ };
121
+ }
122
+
123
+ export function isPrimingOutcome(value: unknown): value is PrimingOutcome {
124
+ if (!value || typeof value !== "object") return false;
125
+ const outcome = value as Record<string, unknown>;
126
+ return (outcome.state === "applied" && typeof outcome.version === "number")
127
+ || (outcome.state === "failed" && typeof outcome.message === "string");
128
+ }
129
+
130
+ /** In words: waiting for the next sweep, applied as version N, or failed with the reason. */
131
+ export function primingStateText(view: PrimingRequestView): string {
132
+ if (!view.outcome) return "Waiting for the next sweep";
133
+ return view.outcome.state === "applied"
134
+ ? `Applied as version ${view.outcome.version}`
135
+ : `Failed: ${view.outcome.message}`;
136
+ }
@@ -106,7 +106,7 @@ export type ReportingStoryBudgetInput = {
106
106
  messagePayloads?: Record<string, unknown>;
107
107
  };
108
108
 
109
- const REPORTING_ASSIGNMENT_TYPE = "reporting.edition-candidate";
109
+ const REPORTING_ASSIGNMENT_TYPE = "reporting.candidate";
110
110
  const REPORTING_PACKET_KIND = "reporting_context_packet";
111
111
  const RESEARCH_PACKET_KIND = "research_packet";
112
112
 
@@ -331,7 +331,7 @@ function buildResearchPacketIdsByReportingAssignment(assignments: AssignmentReco
331
331
  if (relation.subjectKind === "assignment" && relation.objectKind === "assignment") {
332
332
  const reporting = assignmentById.get(relation.subjectId);
333
333
  const research = assignmentById.get(relation.objectId);
334
- if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.edition-candidate") continue;
334
+ if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.candidate") continue;
335
335
  for (const packetId of producedResearchPacketsByAssignment.get(research.id) ?? []) {
336
336
  result.set(reporting.id, addToSet(result.get(reporting.id), packetId));
337
337
  }
@@ -41,7 +41,7 @@ export type SemanticPredicateId =
41
41
  | "has_editorial_form"
42
42
  | "about"
43
43
  | "comment"
44
- | "insight_about"
44
+ | "note_about"
45
45
  | "ingestion_rationale"
46
46
  | "uses_evidence"
47
47
  | "uses_signal"
@@ -77,7 +77,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
77
77
  { id: "reference_summary_1000_tokens", label: "1000-token reference summary", group: "summarization", inverseLabel: "1000-token summary for", contextPackTags: ["reference_curation", "research", "context_ranking"] },
78
78
  { id: "mentions", label: "mentions", group: "ontology", inverseLabel: "mentioned by" },
79
79
  { id: "has_editorial_form", label: "has editorial form", group: "editorial", inverseLabel: "items by editorial form", contextPackTags: ["editing", "publication", "assignment_context"] },
80
- { id: "insight_about", label: "insight about", group: "knowledge", inverseLabel: "insights", contextPackTags: ["research", "reference_graph", "editing"] },
80
+ { id: "note_about", label: "note about", group: "knowledge", inverseLabel: "notes", contextPackTags: ["research", "reference_graph", "editing"] },
81
81
  { id: "about", label: "about", group: "commentary", inverseLabel: "commentary" },
82
82
  { id: "comment", label: "comments on", group: "commentary", inverseLabel: "commented on by", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
83
83
  { id: "ingestion_rationale", label: "ingestion rationale for", group: "commentary", inverseLabel: "ingestion rationale", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
@@ -317,13 +317,13 @@ export class SemanticGraphSnapshot {
317
317
  return ranked.map((entry) => entry.message);
318
318
  }
319
319
 
320
- insightsFor(kind: string, lineageId: string): MessageRecord[] {
320
+ notesFor(kind: string, lineageId: string): MessageRecord[] {
321
321
  return this.incoming(kind, lineageId)
322
322
  .filter((relation) => relation.subjectKind === "message")
323
- .filter((relation) => relationTypeKey(relation) === "insight_about" || relation.predicate === "insight_about")
323
+ .filter((relation) => relationTypeKey(relation) === "note_about" || relation.predicate === "note_about")
324
324
  .map((relation) => this.messagesById.get(relation.subjectId))
325
325
  .filter((message): message is MessageRecord => Boolean(message))
326
- .filter((message) => message.messageKind === "insight" && message.messageDomain === "knowledge")
326
+ .filter((message) => message.messageKind === "note" && message.messageDomain === "knowledge")
327
327
  .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
328
328
  }
329
329
 
package/lib/site-brand.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isLocalPreviewEnabled } from "./local-preview";
2
+ import { areEditionsEnabled } from "./site-env";
1
3
  import type { ComponentType } from "react";
2
4
  import type { EditionPresentationFormat } from "./content-types";
3
5
  import type { Article, ArticleVideoAsset } from "./articles";
@@ -22,6 +24,8 @@ export type SiteBrandId = string;
22
24
 
23
25
  /** Brand-specific replacements for generic Pretext components. */
24
26
  export type SiteBrandComponents = {
27
+ /** The mark shown at the top of the newsroom CMS sidebar in place of the app title. */
28
+ NewsroomLogo?: ComponentType<{ className?: string }>;
25
29
  PictogramFigure?: ComponentType<PictogramFigureProps>;
26
30
  BlogPageBackground?: ComponentType<BlogPageBackgroundProps>;
27
31
  };
@@ -78,8 +82,15 @@ export type ContentItemName = {
78
82
 
79
83
  export const DEFAULT_CONTENT_ITEM_NAME: ContentItemName = { singular: "Story", plural: "Stories" };
80
84
 
85
+ /** Browser-tab icon files under `public/`, as site-root paths. The dark variant defaults to the light one. */
86
+ export type SiteBrandIcons = { light: string; dark?: string; appleTouch?: string };
87
+
88
+ export const DEFAULT_SITE_BRAND_ICONS: Required<Pick<SiteBrandIcons, "light" | "dark">> & { fallback: string } = { light: "/icon-light.png", dark: "/icon-dark.png", fallback: "/icon.png" };
89
+
81
90
  export type SiteBrand = {
82
91
  id: SiteBrandId;
92
+ /** The browser-tab icon. Omit to use the Papyrus plant. */
93
+ icons?: SiteBrandIcons;
83
94
  appTitle: string;
84
95
  appDescription: string;
85
96
  mastheadTitle: string;
@@ -147,6 +158,11 @@ export type SiteBrand = {
147
158
  fonts?: SiteBrandFont[];
148
159
  demoEdition?: SiteBrandDemoEdition;
149
160
  video?: SiteBrandVideo;
161
+ /**
162
+ * Editions are an optional setting, off unless `{ enabled: true }`. When off the
163
+ * publication is a plain stream of items: no edition date routes, archive or masthead date.
164
+ */
165
+ editions?: { enabled: boolean };
150
166
  };
151
167
 
152
168
  const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
@@ -184,11 +200,17 @@ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
184
200
  * The Papyrus reference brand plus whatever the publication registered in its
185
201
  * own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
186
202
  */
187
- const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
203
+ const REGISTERED_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
188
204
  ...REFERENCE_SITE_BRANDS,
189
205
  ...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
190
206
  };
191
207
 
208
+ const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = isLocalPreviewEnabled()
209
+ ? Object.fromEntries(
210
+ Object.entries(REGISTERED_SITE_BRANDS).map(([id, brand]) => [id, { ...brand, newsroomLayout: "organizer" as const }]),
211
+ )
212
+ : REGISTERED_SITE_BRANDS;
213
+
192
214
  export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
193
215
  if (!value) return null;
194
216
  const trimmed = value.trim();
@@ -237,6 +259,11 @@ export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
237
259
 
238
260
  export const rootRoute = getRootRoute();
239
261
 
262
+ /** The brand setting wins when present; otherwise the PAPYRUS_EDITIONS_ENABLED switch decides. */
263
+ export function editionsEnabled(brand: Pick<SiteBrand, "editions"> = SITE_BRAND): boolean {
264
+ return brand.editions?.enabled ?? areEditionsEnabled();
265
+ }
266
+
240
267
  export function enforcePresentation(presentation: EditionPresentationFormat): EditionPresentationFormat {
241
268
  return SITE_BRAND.forcedPresentation ?? presentation;
242
269
  }
package/lib/site-env.ts CHANGED
@@ -20,6 +20,11 @@ export function getSiteEnv(environment: EnvironmentVariables = process.env): Sit
20
20
  return configured as SiteEnv;
21
21
  }
22
22
 
23
+ export function areEditionsEnabled(environment: EnvironmentVariables = process.env): boolean {
24
+ const configured = (environment.PAPYRUS_EDITIONS_ENABLED ?? "").trim().toLowerCase();
25
+ return configured === "true" || configured === "1" || configured === "yes" || configured === "on";
26
+ }
27
+
23
28
  export function getContentSource(environment: EnvironmentVariables = process.env): ContentSource {
24
29
  const configured = (environment.PAPYRUS_CONTENT_SOURCE ?? "").trim();
25
30
  if (!configured) return "published";