@anthusai/papyrus 5.58.0 → 5.60.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.
Files changed (53) hide show
  1. package/amplify/data/message-actions.js +72 -0
  2. package/amplify/data/message-actions.ts +74 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +8 -5
  9. package/amplify/data/resource.ts +8 -5
  10. package/amplify/data/schema.js +14 -7
  11. package/amplify/data/schema.ts +14 -7
  12. package/components/conversation-context.tsx +29 -2
  13. package/components/conversation-provider.tsx +5 -1
  14. package/components/conversation-thread.tsx +133 -19
  15. package/components/conversations-memory-source.ts +43 -6
  16. package/components/conversations-network-source.ts +13 -1
  17. package/components/conversations-source.ts +15 -3
  18. package/components/conversations-view.tsx +437 -76
  19. package/components/list-detail-layout.tsx +44 -0
  20. package/components/needs-me-bodies.tsx +14 -14
  21. package/components/needs-me-feed.tsx +8 -18
  22. package/components/news-desk-taxonomy-client.ts +142 -57
  23. package/components/newsroom-article-editor.tsx +10 -4
  24. package/components/newsroom-organizer.css +10 -1
  25. package/components/newsroom-references-view.tsx +41 -100
  26. package/components/notification-cards.tsx +4 -4
  27. package/components/notification-center.tsx +5 -7
  28. package/components/proposals-view.tsx +4 -4
  29. package/components/reference-conversation.tsx +5 -0
  30. package/components/reference-feed.tsx +69 -85
  31. package/components/reference-review-card.tsx +1 -1
  32. package/components/references-page-loading.tsx +12 -6
  33. package/components/topic-steering-workspace.tsx +8 -24
  34. package/components/use-conversation.ts +36 -2
  35. package/components/use-feed-preferences.ts +33 -0
  36. package/lib/category-repository.ts +7 -38
  37. package/lib/console-chat-client.ts +8 -178
  38. package/lib/conversation.ts +73 -2
  39. package/lib/needs-me-items.ts +4 -4
  40. package/lib/needs-me-ranking.ts +9 -22
  41. package/lib/newsroom-board.ts +10 -1
  42. package/lib/newsroom-demo-dashboard.ts +0 -7
  43. package/lib/newsroom-organizer-nav.ts +2 -2
  44. package/lib/post-message.ts +54 -0
  45. package/lib/proposals-admin.ts +2 -2
  46. package/lib/reference-feed-ranking.ts +77 -160
  47. package/lib/reference-review-response.ts +19 -0
  48. package/lib/review-drafts.ts +2 -2
  49. package/lib/review-fixture.ts +1 -1
  50. package/lib/review-requests.ts +4 -4
  51. package/lib/semantic-graph.ts +1 -1
  52. package/package.json +5 -1
  53. package/routes.manifest.json +1 -1
@@ -1,40 +1,22 @@
1
1
  /**
2
- * How the References feed is ordered, and which cards belong in which tab.
3
- *
4
- * Plain words, as the "How this feed is ranked" popover says them:
5
- * 1. The Cyclotron's prediction becomes a probability that you will Accept (P). A prediction of Accept at 80% is
6
- * P = 0.8; a prediction of Reject at 80% is P = 0.2; no prediction is P = 0.5.
7
- * 2. Old items lose their say. The score is 0.5 + (P - 0.5) * w, where w = exp(-age / tau) is a freshness weight.
8
- * Age pulls an item toward "uncertain" (0.5), never to the bottom.
9
- * 3. tau is learned from how old the References you accepted were when you accepted them
10
- * (tau = median age / ln 2). With fewer than 10 accepted examples nothing is guessed: no decay.
11
- * 4. Highest score first. The strongest Reject predictions end up last. Ties go to the newest item.
12
- * 5. Unrated items older than 3 tau move to "Earlier" at the bottom. Nothing is deleted.
13
- *
14
- * The same recipe ranks monitor stories (`learn_freshness` in src/papyrus_content/ideas/monitor_stories.py).
2
+ * How the References feed is ordered, in plain words (the "How this feed is ranked" popover says them too):
3
+ * - One list, two sorts. "Best bets" is the Cyclotron's confidence that you will Accept (P), highest first; a prediction of
4
+ * Accept at 80% is P = 0.8, a prediction of Reject at 80% is P = 0.2, no prediction is P = 0.5. Age does not change it.
5
+ * - "Newest" is reverse chronological by when the Reference arrived.
6
+ * - Ties go to the newest item, then to a stable id order.
7
+ * - "Unrated only" shows References nobody has answered; "Include rated" adds the answered ones to the same list.
8
+ * Nothing is cut off by age, and age never changes the order of Best bets: the original confidence is what ranks.
15
9
  */
16
10
  import type { ReferenceRecord } from "./category-repository";
17
11
  import type { RelevanceDecisionView } from "./relevance-decisions";
18
- import { isAutoAccepted, normalizeReferenceCurationStatus } from "./reference-policy";
12
+ import { normalizeReferenceCurationStatus } from "./reference-policy";
19
13
  import { referenceLineageId } from "./newsroom-references";
20
14
 
21
- /** Fewer accepted examples than this and there is no freshness weight: a constant is never guessed. */
22
- export const MINIMUM_FRESHNESS_EXAMPLES = 10;
23
- /** An unrated item whose age passes this many tau has a weight below exp(-3), about 5%, and moves to Earlier. */
24
- export const EARLIER_AFTER_TAUS = 3;
25
15
  /** The probability that stands for "no prediction". */
26
16
  export const UNDECIDED_PROBABILITY = 0.5;
27
17
  /** A prediction that recorded a label but no confidence is a mild lean, not a coin toss and not a certainty. */
28
18
  export const UNRECORDED_CONFIDENCE = 0.6;
29
19
 
30
- const DAY_MILLISECONDS = 86_400_000;
31
-
32
- export type FeedFreshness = {
33
- /** Null when there were too few accepted examples to learn from: no decay at all. */
34
- tauDays: number | null;
35
- medianAgeDays: number | null;
36
- examples: number;
37
- };
38
20
 
39
21
  function timeOf(value: string | null | undefined): number | null {
40
22
  if (!value) return null;
@@ -52,35 +34,6 @@ export function referenceDisplayTime(reference: ReferenceRecord): number | null
52
34
  return timeOf(reference.sourcePublishedAt) ?? timeOf(reference.sourceUpdatedAt) ?? referenceArrivalTime(reference);
53
35
  }
54
36
 
55
- /**
56
- * tau from the age at acceptance of accepted References: median / ln 2, since the median age of an exponential
57
- * exp(-age / tau) is tau * ln 2. `ignore` keeps the item an editor just rated from moving the very ranking they are looking at.
58
- */
59
- export function learnFreshness(references: ReferenceRecord[], ignore: ReadonlySet<string> = new Set()): FeedFreshness {
60
- const ages: number[] = [];
61
- for (const reference of references) {
62
- if (normalizeReferenceCurationStatus(reference.curationStatus) !== "accepted" || ignore.has(referenceLineageId(reference))) continue;
63
- if (isAutoAccepted(reference)) continue; // an automatic acceptance happens after the editors' usual time, so it says nothing about how fast they act
64
- const arrived = referenceArrivalTime(reference);
65
- const decided = timeOf(reference.curationStatusUpdatedAt);
66
- if (arrived === null || decided === null) continue;
67
- const age = (decided - arrived) / DAY_MILLISECONDS;
68
- if (age > 0) ages.push(age);
69
- }
70
- if (ages.length < MINIMUM_FRESHNESS_EXAMPLES) return { tauDays: null, medianAgeDays: null, examples: ages.length };
71
- ages.sort((left, right) => left - right);
72
- const middle = Math.floor(ages.length / 2);
73
- const median = ages.length % 2 ? ages[middle] : (ages[middle - 1] + ages[middle]) / 2;
74
- return { tauDays: median / Math.LN2, medianAgeDays: median, examples: ages.length };
75
- }
76
-
77
- /** exp(-age / tau) at `now`; 1 without a learned tau or an unreadable arrival. */
78
- export function freshnessWeight(reference: ReferenceRecord, freshness: FeedFreshness, now: number): number {
79
- const arrived = referenceArrivalTime(reference);
80
- if (freshness.tauDays === null || arrived === null) return 1;
81
- return Math.exp(-Math.max(now - arrived, 0) / DAY_MILLISECONDS / freshness.tauDays);
82
- }
83
-
84
37
  /** P(the editor accepts), from the Cyclotron's decision: its confidence is in the label it chose. */
85
38
  export function acceptProbability(decision: RelevanceDecisionView | undefined): number {
86
39
  if (!decision) return UNDECIDED_PROBABILITY;
@@ -93,144 +46,108 @@ export type FeedItem = {
93
46
  reference: ReferenceRecord;
94
47
  lineageId: string;
95
48
  decision: RelevanceDecisionView | undefined;
96
- /** P(accept) before freshness. */
49
+ /** P(accept): the Cyclotron's confidence that the editor accepts. The Best bets sort key. */
97
50
  probability: number;
98
- weight: number;
99
- /** 0.5 + (P - 0.5) * weight. Highest is first. */
100
- score: number;
101
- /** True when this unrated item is older than the Earlier threshold. */
102
- earlier: boolean;
51
+ /** True when a human has already answered (accepted or rejected). */
52
+ rated: boolean;
103
53
  };
104
54
 
105
- export function scoreFeedItem(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined, freshness: FeedFreshness, now: number): FeedItem {
106
- const probability = acceptProbability(decision);
107
- const weight = freshnessWeight(reference, freshness, now);
108
- const arrived = referenceArrivalTime(reference);
109
- const earlier = freshness.tauDays !== null && arrived !== null && (now - arrived) / DAY_MILLISECONDS > EARLIER_AFTER_TAUS * freshness.tauDays;
55
+ export type FeedSort = "best" | "newest";
56
+ export type FeedShow = "unrated" | "all";
57
+ export const DEFAULT_FEED_SORT: FeedSort = "best";
58
+ export const DEFAULT_FEED_SHOW: FeedShow = "unrated";
59
+
60
+ export function scoreFeedItem(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): FeedItem {
61
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
110
62
  return {
111
63
  reference,
112
64
  lineageId: referenceLineageId(reference),
113
65
  decision,
114
- probability,
115
- weight,
116
- score: UNDECIDED_PROBABILITY + (probability - UNDECIDED_PROBABILITY) * weight,
117
- earlier,
66
+ probability: acceptProbability(decision),
67
+ rated: status === "accepted" || status === "rejected",
118
68
  };
119
69
  }
120
70
 
121
- /** Highest score first; ties go to the newest item, then to a stable id order. */
122
- export function compareFeedItems(left: FeedItem, right: FeedItem): number {
123
- if (right.score !== left.score) return right.score - left.score;
124
- const newest = (referenceDisplayTime(right.reference) ?? 0) - (referenceDisplayTime(left.reference) ?? 0);
125
- return newest || left.lineageId.localeCompare(right.lineageId);
71
+ /** Every canonical Reference that is not archived, scored. Unordered: use `orderFeedItems`. */
72
+ export function buildFeedItems(references: ReferenceRecord[], decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined): FeedItem[] {
73
+ const items: FeedItem[] = [];
74
+ for (const reference of references) {
75
+ if (normalizeReferenceCurationStatus(reference.curationStatus) === "archived") continue;
76
+ items.push(scoreFeedItem(reference, decisions?.get(referenceLineageId(reference))));
77
+ }
78
+ return items;
126
79
  }
127
80
 
128
- export type ReferenceFeedTabKey = "feed" | "rated" | "earlier";
81
+ /** Best bets: highest P(accept) first; ties go to the newest item, then to a stable id order. */
82
+ export function compareBestBets(left: FeedItem, right: FeedItem): number {
83
+ if (right.probability !== left.probability) return right.probability - left.probability;
84
+ return compareNewest(left, right);
85
+ }
129
86
 
130
- export type ReferenceFeedPartition = {
131
- freshness: FeedFreshness;
132
- /** Pending items, plus the ones rated during this visit (they keep their place), best prediction first. */
133
- feed: FeedItem[];
134
- /** Everything an editor has answered, most recent answer first. */
135
- rated: FeedItem[];
136
- /** Unrated items past the freshness threshold, ranked like the feed. Still ratable. */
137
- earlier: FeedItem[];
138
- };
87
+ /** Newest: latest arrival first; ties go to the higher P(accept), then to a stable id order. */
88
+ export function compareNewest(left: FeedItem, right: FeedItem): number {
89
+ const newest = (referenceArrivalTime(right.reference) ?? 0) - (referenceArrivalTime(left.reference) ?? 0);
90
+ if (newest) return newest;
91
+ if (right.probability !== left.probability) return right.probability - left.probability;
92
+ return left.lineageId.localeCompare(right.lineageId);
93
+ }
139
94
 
140
- /**
141
- * Splits canonical References into the three tabs.
142
- * `visitRated` holds lineage ids rated since the page opened: they stay in Feed, in place, showing the answer.
143
- */
144
- export function partitionReferenceFeed(
145
- references: ReferenceRecord[],
146
- decisions: ReadonlyMap<string, RelevanceDecisionView> | undefined,
147
- options: { now: number; visitRated?: ReadonlySet<string>; tauDaysOverride?: number | null },
148
- ): ReferenceFeedPartition {
149
- const visitRated = options.visitRated ?? new Set<string>();
150
- const learned = learnFreshness(references, visitRated);
151
- const freshness: FeedFreshness = options.tauDaysOverride && options.tauDaysOverride > 0
152
- ? { ...learned, tauDays: options.tauDaysOverride }
153
- : learned;
154
- const feed: FeedItem[] = [];
155
- const rated: FeedItem[] = [];
156
- const earlier: FeedItem[] = [];
157
- for (const reference of references) {
158
- const status = normalizeReferenceCurationStatus(reference.curationStatus);
159
- const lineageId = referenceLineageId(reference);
160
- const item = scoreFeedItem(reference, decisions?.get(lineageId), freshness, options.now);
161
- if (status === "archived") continue;
162
- if (status === "accepted" || status === "rejected") {
163
- rated.push(item);
164
- if (visitRated.has(lineageId)) (item.earlier ? earlier : feed).push(item);
165
- } else if (item.earlier) earlier.push(item);
166
- else feed.push(item);
167
- }
168
- feed.sort(compareFeedItems);
169
- earlier.sort(compareFeedItems);
170
- rated.sort((left, right) => (timeOf(right.reference.curationStatusUpdatedAt) ?? 0) - (timeOf(left.reference.curationStatusUpdatedAt) ?? 0)
171
- || left.lineageId.localeCompare(right.lineageId));
172
- return { freshness, feed, rated, earlier };
95
+ export function orderFeedItems(items: FeedItem[], sort: FeedSort): FeedItem[] {
96
+ return [...items].sort(sort === "newest" ? compareNewest : compareBestBets);
173
97
  }
174
98
 
175
- export type FeedGroupKey = "top" | "more" | "least";
99
+ /** The visible list: ordered by `sort`; "Unrated only" keeps the items answered during this visit, which stay in place. */
100
+ export function visibleFeedItems(items: FeedItem[], sort: FeedSort, show: FeedShow, visitRated: ReadonlySet<string> = new Set()): FeedItem[] {
101
+ return orderFeedItems(items.filter((item) => show === "all" || !item.rated || visitRated.has(item.lineageId)), sort);
102
+ }
176
103
 
177
- /**
178
- * The feed's sections, from the ranked list (already best first). No fixed counts: Top predictions are the Accepts at least half as
179
- * strong as the strongest one, Least likely are the predicted Rejects, and More for you is everything between (weaker Accepts and
180
- * items with no prediction). A group with no members is absent.
181
- */
182
- export function groupFeedItems(items: FeedItem[]): Array<{ key: FeedGroupKey; items: FeedItem[] }> {
183
- const strongest = items.reduce((best, item) => Math.max(best, item.score - UNDECIDED_PROBABILITY), 0);
184
- const groups: Record<FeedGroupKey, FeedItem[]> = { top: [], more: [], least: [] };
185
- for (const item of items) {
186
- const strength = item.score - UNDECIDED_PROBABILITY;
187
- if (strength < 0) groups.least.push(item);
188
- else if (strength > 0 && strength >= strongest / 2) groups.top.push(item);
189
- else groups.more.push(item);
190
- }
191
- return (["top", "more", "least"] as const).filter((key) => groups[key].length).map((key) => ({ key, items: groups[key] }));
104
+ /** The lead card is the first card, unless it is a predicted Reject. */
105
+ export function feedLeadId(items: FeedItem[]): string | undefined {
106
+ return items[0] && items[0].probability >= UNDECIDED_PROBABILITY ? items[0].lineageId : undefined;
192
107
  }
193
108
 
194
109
  /**
195
110
  * The order of the feed, taken once per visit when it loads and then left alone: selecting, rating, a background refresh and a cache
196
- * revalidation never re-rank it. Items that arrive later are counted as new and wait behind a "N new items: show" pill.
111
+ * revalidation never re-rank it, in either sort. Items that arrive later are counted as new and wait behind a "N new items: show" pill.
197
112
  */
198
113
  export type FeedOrderSnapshot = {
199
- /** Every Reference lineage the snapshot knew about (pending, rated and earlier): anything else is new. */
114
+ /** Every Reference lineage the snapshot knew about: anything else is new. */
200
115
  known: ReadonlySet<string>;
201
- feed: string[];
202
- earlier: string[];
203
- groups: Array<{ key: FeedGroupKey; ids: string[] }>;
204
- leadId: string | undefined;
116
+ /** The ones nobody had answered when the snapshot was taken: they stay listed under "Unrated only" after being rated this visit. */
117
+ unrated: ReadonlySet<string>;
118
+ best: string[];
119
+ newest: string[];
120
+ /** The lead card of each sort, taken with the order: it is never a predicted Reject when the snapshot is taken. */
121
+ lead: { best: string | undefined; newest: string | undefined };
205
122
  };
206
123
 
207
- export function takeFeedOrderSnapshot(partition: ReferenceFeedPartition): FeedOrderSnapshot {
208
- const groups = groupFeedItems(partition.feed);
124
+ export function takeFeedOrderSnapshot(items: FeedItem[]): FeedOrderSnapshot {
125
+ const unrated = items.filter((item) => !item.rated);
126
+ const best = orderFeedItems(items, "best");
127
+ const newest = orderFeedItems(items, "newest");
209
128
  return {
210
- known: new Set([...partition.feed, ...partition.earlier, ...partition.rated].map((item) => item.lineageId)),
211
- feed: partition.feed.map((item) => item.lineageId),
212
- earlier: partition.earlier.map((item) => item.lineageId),
213
- groups: groups.map((group) => ({ key: group.key, ids: group.items.map((item) => item.lineageId) })),
214
- leadId: groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined,
129
+ known: new Set(items.map((item) => item.lineageId)),
130
+ unrated: new Set(unrated.map((item) => item.lineageId)),
131
+ best: best.map((item) => item.lineageId),
132
+ newest: newest.map((item) => item.lineageId),
133
+ lead: { best: feedLeadId(orderFeedItems(unrated, "best")), newest: feedLeadId(orderFeedItems(unrated, "newest")) },
215
134
  };
216
135
  }
217
136
 
218
- /** The live partition with its feed order, sections and lead taken from the snapshot; records and scores stay current. */
219
- export function applyFeedOrderSnapshot(snapshot: FeedOrderSnapshot, live: ReferenceFeedPartition): {
220
- partition: ReferenceFeedPartition;
221
- groups: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
137
+ /** The visible list in the snapshot's order; records and predictions stay current. */
138
+ export function applyFeedOrderSnapshot(snapshot: FeedOrderSnapshot, live: FeedItem[], sort: FeedSort, show: FeedShow): {
139
+ items: FeedItem[];
222
140
  leadId: string | undefined;
223
141
  newCount: number;
224
142
  } {
225
- const byId = new Map<string, FeedItem>();
226
- for (const item of [...live.rated, ...live.earlier, ...live.feed]) byId.set(item.lineageId, item);
227
- const pick = (ids: string[]) => ids.flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
228
- const groups = snapshot.groups.map((group) => ({ key: group.key, items: pick(group.ids) })).filter((group) => group.items.length);
229
- const newCount = [...live.feed, ...live.earlier].filter((item) => !snapshot.known.has(item.lineageId)).length;
143
+ const byId = new Map(live.map((item) => [item.lineageId, item] as const));
144
+ const items = (sort === "newest" ? snapshot.newest : snapshot.best)
145
+ .filter((id) => show === "all" || snapshot.unrated.has(id))
146
+ .flatMap((id) => { const item = byId.get(id); return item ? [item] : []; });
147
+ const lead = snapshot.lead[sort];
230
148
  return {
231
- partition: { ...live, feed: pick(snapshot.feed), earlier: pick(snapshot.earlier) },
232
- groups,
233
- leadId: snapshot.leadId && byId.has(snapshot.leadId) ? snapshot.leadId : undefined,
234
- newCount,
149
+ items,
150
+ leadId: lead && items.some((item) => item.lineageId === lead) ? lead : undefined,
151
+ newCount: live.filter((item) => !item.rated && !snapshot.known.has(item.lineageId)).length,
235
152
  };
236
153
  }
@@ -0,0 +1,19 @@
1
+ // What the newsroom checks in a saved vote's response. A vote is recorded on the Reference itself and writes no Message
2
+ // or relation (Messages come only from people), so the response carries only the outcome.
3
+ export type ReferenceReviewResult = {
4
+ ok?: boolean | null;
5
+ action?: string | null;
6
+ referenceId?: string | null;
7
+ status?: string | null;
8
+ reasonCode?: string | null;
9
+ };
10
+
11
+ export function assertReferenceReviewSaved(data: ReferenceReviewResult | null | undefined, referenceId: string): ReferenceReviewResult {
12
+ if (!data?.ok) {
13
+ throw new Error(`Reference curation review was not saved for ${referenceId}.`);
14
+ }
15
+ if (data.referenceId && data.referenceId !== referenceId) {
16
+ throw new Error(`Reference review response did not match ${referenceId}.`);
17
+ }
18
+ return data;
19
+ }
@@ -44,7 +44,7 @@ export type DraftView = {
44
44
 
45
45
  export const MAXIMUM_SOURCES = 5;
46
46
  const MAXIMUM_EVIDENCE = 3;
47
- const KIND_WORDS: Record<string, string> = { story_draft: "Story draft", new_concept: "New concept", rename: "Rename", merge: "Merge", split: "Split", new_column: "New column", element_symbol: "Element symbol" };
47
+ const KIND_WORDS: Record<string, string> = { story_draft: "Proposed Story", new_concept: "New concept", rename: "Rename", merge: "Merge", split: "Split", new_column: "New column", element_symbol: "Element symbol" };
48
48
 
49
49
  /** JSON fields arrive as strings, and a field written through `JSON.stringify` of a string arrives encoded twice: unwrap until it is not text. */
50
50
  function parseJson(value: unknown, depth = 0): unknown {
@@ -170,7 +170,7 @@ export function rejectPath(state: string): string[] | null {
170
170
  /** The sentence for a draft that cannot move, or null when it can. A hold is named, never hidden. */
171
171
  export function cannotMoveReason(view: Pick<DraftView, "state" | "held" | "decided">): string | null {
172
172
  if (view.decided) return null;
173
- if (view.held) return "This draft is on hold in Proposals. Release the hold there before you decide it.";
173
+ if (view.held) return "This Proposed Story is on hold in Proposals. Release the hold there before you decide it.";
174
174
  return null;
175
175
  }
176
176
 
@@ -18,7 +18,7 @@ export const FIXTURE_REVIEW_REQUESTS: ReviewRequest[] = [
18
18
  history: [], appliedIn: null, updatedAt: "2026-10-23T07:00:00Z",
19
19
  },
20
20
  {
21
- id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend story draft is ready",
21
+ id: "rr-soft-1", subject: { type: "proposal", id: "proposal-9" }, mode: "soft_review", reason: "A trend Proposed Story is ready",
22
22
  ask: null, raisedBy: "trend-pipeline", raisedAt: "2026-10-22T07:00:00Z", deadline: "2026-10-25T07:00:00Z", state: "open", answer: null,
23
23
  links: [], history: [], appliedIn: null, updatedAt: "2026-10-22T07:00:00Z",
24
24
  },
@@ -164,12 +164,12 @@ export function nextText(request: ReviewRequest): string {
164
164
  if (request.state === "open") {
165
165
  if (request.mode === "hard_gate") return "Waiting for you. Nothing continues until you accept or reject.";
166
166
  if (request.mode === "soft_review") {
167
- if (drafts) return request.deadline ? `Closes on ${formatWhen(request.deadline)} if nobody answers. The drafts stay in Proposals.` : "No deadline. The drafts stay in Proposals until an editor decides them.";
167
+ if (drafts) return request.deadline ? `Closes on ${formatWhen(request.deadline)} if nobody answers. The Proposed Stories stay in Proposals.` : "No deadline. The Proposed Stories stay in Proposals until an editor decides them.";
168
168
  return request.deadline ? `If nobody answers by ${formatWhen(request.deadline)}, this closes and ${request.raisedBy} carries on without a review.` : "No deadline. Stays open until someone answers it.";
169
169
  }
170
170
  return "Nothing is waiting on this. It stays here until you acknowledge it.";
171
171
  }
172
- if (request.state === "timed_out") return drafts ? `Closed with no answer on ${formatWhen(request.updatedAt)}. The drafts stay in Proposals.` : `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
172
+ if (request.state === "timed_out") return drafts ? `Closed with no answer on ${formatWhen(request.updatedAt)}. The Proposed Stories stay in Proposals.` : `Proceeded without review on ${formatWhen(request.updatedAt)}.`;
173
173
  if (request.state === "withdrawn") return "Withdrawn by the step that raised it.";
174
174
  const answer = request.answer;
175
175
  return answer ? `${answerWords(answer, request)} by ${answer.by} on ${formatWhen(answer.at)}.` : "Answered.";
@@ -295,7 +295,7 @@ export function sectionReviews(requests: ReviewRequest[]): NotificationSections
295
295
  export type NotificationSummary = { waiting: number; drafts: number; otherReviews: number; decidedBySystem: number; unread: number };
296
296
 
297
297
  /**
298
- * "N waiting for you · N AI drafts to review · N other reviews open · N recently decided by the system".
298
+ * "N waiting for you · N Proposed Stories to review · N other reviews open · N recently decided by the system".
299
299
  * Waiting = hard gates plus Cyclotron items held for review (both stay put until a person acts). `draftsToDecide` is the number of
300
300
  * drafts in open draft reviews that nobody has decided yet.
301
301
  */
@@ -305,7 +305,7 @@ export function summarize(sections: NotificationSections, held: CyclotronEntry[]
305
305
  }
306
306
 
307
307
  export function summaryText(summary: NotificationSummary): string {
308
- return `${summary.waiting} waiting for you · ${summary.drafts} AI ${summary.drafts === 1 ? "draft" : "drafts"} to review · ${summary.otherReviews} other ${summary.otherReviews === 1 ? "review" : "reviews"} open · ${summary.decidedBySystem} recently decided by the system`;
308
+ return `${summary.waiting} waiting for you · ${summary.drafts} ${summary.drafts === 1 ? "Proposed Story" : "Proposed Stories"} to review · ${summary.otherReviews} other ${summary.otherReviews === 1 ? "review" : "reviews"} open · ${summary.decidedBySystem} recently decided by the system`;
309
309
  }
310
310
 
311
311
  export function isUnread(request: ReviewRequest, readIds: ReadonlySet<string>): boolean {
@@ -294,7 +294,7 @@ export class SemanticGraphSnapshot {
294
294
  messagesFor(kind: string, lineageId: string): MessageRecord[] {
295
295
  const incomingCommentMessages = this.incoming(kind, lineageId)
296
296
  .filter((relation) => relation.subjectKind === "message")
297
- .filter((relation) => ["comment", "ingestion_rationale"].includes(relationTypeKey(relation)) || ["comment", "ingestion_rationale"].includes(relation.predicate))
297
+ .filter((relation) => relationTypeKey(relation) === "comment" || relation.predicate === "comment")
298
298
  .map((relation) => this.messagesById.get(relation.subjectId))
299
299
  .filter((message): message is MessageRecord => Boolean(message));
300
300
  const producedMessages = this.outgoing(kind, lineageId)
package/package.json CHANGED
@@ -96,6 +96,7 @@
96
96
  "./components/editorial-findings-panel": "./components/editorial-findings-panel.tsx",
97
97
  "./components/editorial-preview-popover": "./components/editorial-preview-popover.tsx",
98
98
  "./components/item-stream-view": "./components/item-stream-view.tsx",
99
+ "./components/list-detail-layout": "./components/list-detail-layout.tsx",
99
100
  "./components/markdown-context": "./components/markdown-context.tsx",
100
101
  "./components/markus-body-editor": "./components/markus-body-editor.tsx",
101
102
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
@@ -200,6 +201,7 @@
200
201
  "./components/use-console-visibility": "./components/use-console-visibility.ts",
201
202
  "./components/use-conversation": "./components/use-conversation.ts",
202
203
  "./components/use-editorial-checks": "./components/use-editorial-checks.ts",
204
+ "./components/use-feed-preferences": "./components/use-feed-preferences.ts",
203
205
  "./components/use-media-query": "./components/use-media-query.ts",
204
206
  "./components/use-near-viewport": "./components/use-near-viewport.ts",
205
207
  "./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
@@ -327,6 +329,7 @@
327
329
  "./lib/papyrus-web-locations": "./lib/papyrus-web-locations.ts",
328
330
  "./lib/pilobol-sample": "./lib/pilobol-sample.ts",
329
331
  "./lib/plugin-settings": "./lib/plugin-settings.ts",
332
+ "./lib/post-message": "./lib/post-message.ts",
330
333
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
331
334
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
332
335
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
@@ -360,6 +363,7 @@
360
363
  "./lib/reference-preview": "./lib/reference-preview.ts",
361
364
  "./lib/reference-review-details": "./lib/reference-review-details.ts",
362
365
  "./lib/reference-review-request": "./lib/reference-review-request.ts",
366
+ "./lib/reference-review-response": "./lib/reference-review-response.ts",
363
367
  "./lib/reference-source-preview": "./lib/reference-source-preview.ts",
364
368
  "./lib/relevance-decisions": "./lib/relevance-decisions.ts",
365
369
  "./lib/relevance-status": "./lib/relevance-status.ts",
@@ -517,7 +521,7 @@
517
521
  "optional": true
518
522
  }
519
523
  },
520
- "version": "5.58.0",
524
+ "version": "5.60.0",
521
525
  "dependencies": {
522
526
  "@aws-amplify/adapter-nextjs": "^1.7.3",
523
527
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.58.0",
2
+ "version": "5.60.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",