feedkeeper 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -38,6 +38,7 @@ const FEATURES = [
38
38
  "native-preferences",
39
39
  "native-preferences.edition-size",
40
40
  "edition.overview",
41
+ "edition.layout",
41
42
  "search.fts",
42
43
  "account-capabilities",
43
44
  "mcp",
@@ -0,0 +1,2 @@
1
+ -- Optional presentation plan for native editions; null for automatic and unstructured issues.
2
+ ALTER TABLE editions ADD COLUMN layout TEXT CHECK (layout IS NULL OR json_valid(layout));
@@ -500,7 +500,7 @@ export function createMcpServerForUser(userId, scope) {
500
500
  if (scope === "write")
501
501
  server.registerTool("publish_edition", {
502
502
  title: "Publish curated edition",
503
- description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged.",
503
+ description: "Publishes an ordered list of 1 to 24 accessible article IDs. Curated editions override automatic ones until expiry (default 24 hours, maximum seven days). App-only hidden sections and muted keywords are respected. Supply the revision from get_edition as expectedRevision and a UUID requestId for conflict detection and safe retries. Optional title and summary are editorial text; original articles stay unchanged. Optional layout (version 1) says how the issue is set: a character (dominant, calm or briefing) and blocks (lead, dossier, feature, standard, briefs, photo, quote) that reference the issue's article IDs at most once each; it never changes the articles.",
504
504
  inputSchema: publishEditionFields,
505
505
  }, async (input) => capabilityAction("editions", () => publishCuratedEdition(userId, input)));
506
506
  if (scope === "write")
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { NativeError } from "./requests.js";
3
+ /**
4
+ * A presentation plan for an edition. It says what the issue's writer considers important and what belongs together,
5
+ * never how a screen draws it: clients map the roles onto their own columns, sizes and type.
6
+ *
7
+ * Versioned and strict. A client that meets a block type it does not know sets that block's stories as plain ones.
8
+ */
9
+ const itemId = z.number().int().positive();
10
+ /** A short label above a headline: a classification, not a statement. */
11
+ const kicker = z.string().trim().min(1).max(40)
12
+ .refine((value) => !/[\r\n]/.test(value) && !/[.!?:;,]$/.test(value), "A kicker is one short phrase without closing punctuation");
13
+ const story = { itemId, kicker: kicker.optional() };
14
+ const block = z.discriminatedUnion("type", [
15
+ /** The story of the day; only once and only first. */
16
+ z.object({ type: z.literal("lead"), ...story }).strict(),
17
+ /** One overview topic (1-based position in `overview`) with the stories it is about. */
18
+ z.object({ type: z.literal("dossier"), topic: z.number().int().min(1).max(5), itemIds: z.array(itemId).min(1).max(4) }).strict(),
19
+ z.object({ type: z.literal("feature"), ...story }).strict(),
20
+ z.object({ type: z.literal("standard"), stories: z.array(z.object(story).strict()).min(1).max(3) }).strict(),
21
+ z.object({ type: z.literal("briefs"), itemIds: z.array(itemId).min(2).max(8), title: z.string().trim().min(1).max(60).optional() }).strict(),
22
+ z.object({ type: z.literal("photo"), itemId }).strict(),
23
+ /** A statement of an article set large. The issuer vouches that the text is quoted from the article. */
24
+ z.object({ type: z.literal("quote"), itemId, text: z.string().trim().min(1).max(160) }).strict(),
25
+ ]);
26
+ export const editionLayoutSchema = z.object({
27
+ version: z.literal(1),
28
+ character: z.enum(["dominant", "calm", "briefing"]),
29
+ blocks: z.array(block).min(1).max(24),
30
+ }).strict();
31
+ function blockItemIds(block) {
32
+ switch (block.type) {
33
+ case "standard": return block.stories.map((entry) => entry.itemId);
34
+ case "dossier":
35
+ case "briefs": return block.itemIds;
36
+ default: return [block.itemId];
37
+ }
38
+ }
39
+ /**
40
+ * Checks a well-formed plan against the issue it is published with: every story is in the issue and appears once,
41
+ * the lead stands first, and every topic exists and has one dossier at most.
42
+ */
43
+ export function checkEditionLayout(layout, itemIds, topicCount) {
44
+ const allowed = new Set(itemIds);
45
+ const used = new Set();
46
+ const topics = new Set();
47
+ layout.blocks.forEach((block, index) => {
48
+ if (block.type === "lead" && index !== 0)
49
+ throw new NativeError("invalid_layout");
50
+ if (block.type === "dossier") {
51
+ if (block.topic > topicCount)
52
+ throw new NativeError("invalid_layout_reference");
53
+ if (topics.has(block.topic))
54
+ throw new NativeError("invalid_layout");
55
+ topics.add(block.topic);
56
+ }
57
+ for (const id of blockItemIds(block)) {
58
+ if (!allowed.has(id))
59
+ throw new NativeError("invalid_layout_reference");
60
+ if (used.has(id))
61
+ throw new NativeError("invalid_layout");
62
+ used.add(id);
63
+ }
64
+ });
65
+ }
66
+ //# sourceMappingURL=editionLayout.js.map
@@ -7,6 +7,7 @@ import { canAccessItem } from "../feeds/repository.js";
7
7
  import { decodeEntities } from "../feeds/text.js";
8
8
  import { editionPeriod, nextEditionBoundary, selectEdition } from "./editionSelection.js";
9
9
  import { getNativePreferences } from "./preferences.js";
10
+ import { checkEditionLayout, editionLayoutSchema } from "./editionLayout.js";
10
11
  import { NativeError, nativeRequest } from "./requests.js";
11
12
  export const overviewSchema = z.array(z.object({
12
13
  heading: z.string().trim().min(1).max(160),
@@ -24,7 +25,8 @@ function serialize(row) {
24
25
  id: row.edition_id, revision: row.revision, source: row.source,
25
26
  createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at,
26
27
  period: row.period?.split(":")[1] ?? editionPeriod(new Date(row.created_at), prefs.timeZone).period,
27
- title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, preferencesRevision: row.preferences_revision,
28
+ title: row.title, summary: row.summary, overview: row.overview ? JSON.parse(row.overview) : null, layout: row.layout ? JSON.parse(row.layout) : null,
29
+ preferencesRevision: row.preferences_revision,
28
30
  itemIds: JSON.parse(row.item_ids),
29
31
  };
30
32
  }
@@ -43,18 +45,19 @@ function checkRevision(userId, expectedRevision, now) {
43
45
  if (expectedRevision !== undefined && current.revision !== expectedRevision)
44
46
  throw new NativeError("revision_conflict", 409, current);
45
47
  }
46
- function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null) {
48
+ function writeEdition(userId, itemIds, source, expiry, now, title = null, summary = null, overview = null, layout = null) {
47
49
  const prefs = getNativePreferences(userId);
48
50
  const id = randomUUID();
49
51
  const revision = (storedEdition(userId)?.revision ?? 0) + 1;
50
52
  const created = now.toISOString();
51
- db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview)
52
- VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?)
53
+ db.prepare(`INSERT INTO editions (user_id, edition_id, item_ids, revision, source, status, created_at, updated_at, expires_at, period, title, summary, preferences_revision, overview, layout)
54
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)
53
55
  ON CONFLICT(user_id) DO UPDATE SET edition_id = excluded.edition_id, item_ids = excluded.item_ids,
54
56
  revision = excluded.revision, source = excluded.source, status = 'active', created_at = excluded.created_at,
55
57
  updated_at = excluded.updated_at, expires_at = excluded.expires_at, period = excluded.period,
56
- title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview`)
57
- .run(userId, id, JSON.stringify(itemIds), revision, source, created, created, expiry.toISOString(), editionPeriod(now, prefs.timeZone).key, title, summary, prefs.revision, overview ? JSON.stringify(overview.map((block, index) => ({ id: `topic-${index + 1}`, ...block }))) : null);
58
+ title = excluded.title, summary = excluded.summary, preferences_revision = excluded.preferences_revision, overview = excluded.overview,
59
+ layout = excluded.layout`)
60
+ .run(userId, id, JSON.stringify(itemIds), revision, source, created, created, expiry.toISOString(), editionPeriod(now, prefs.timeZone).key, title, summary, prefs.revision, overview ? JSON.stringify(overview.map((block, index) => ({ id: `topic-${index + 1}`, ...block }))) : null, layout ? JSON.stringify(layout) : null);
58
61
  db.prepare("INSERT INTO edition_history (user_id, edition_id, item_ids, revision, created_at) VALUES (?, ?, ?, ?, ?)")
59
62
  .run(userId, id, JSON.stringify(itemIds), revision, created);
60
63
  db.prepare(`DELETE FROM edition_history WHERE user_id = ? AND edition_id NOT IN
@@ -71,11 +74,14 @@ export const publishEditionFields = {
71
74
  title: z.string().trim().min(1).max(200).optional(),
72
75
  summary: z.string().trim().min(1).max(2000).optional(),
73
76
  overview: overviewSchema.optional(),
77
+ layout: editionLayoutSchema.optional(),
74
78
  };
75
79
  const publishSchema = z.object(publishEditionFields).strict().refine((input) => !(input.expiresAt && input.durationHours), "Use expiresAt or durationHours, not both");
76
80
  /** Both automatic and MCP editions obey the app's hard exclusions. Read/saved articles
77
81
  * may be selected deliberately by an agent, but foreign and muted items cannot be published. */
78
82
  export function publishCuratedEdition(userId, input, now = new Date()) {
83
+ if (input.layout !== undefined && !editionLayoutSchema.safeParse(input.layout).success)
84
+ throw new NativeError("invalid_layout");
79
85
  const parsed = publishSchema.safeParse(input);
80
86
  if (!parsed.success)
81
87
  throw new NativeError("invalid_input");
@@ -88,6 +94,8 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
88
94
  block.segments.some((segment) => segment.itemId !== undefined && !data.itemIds.includes(segment.itemId)))) {
89
95
  throw new NativeError("invalid_overview_reference");
90
96
  }
97
+ if (data.layout)
98
+ checkEditionLayout(data.layout, data.itemIds, data.overview?.length ?? 0);
91
99
  const prefs = getNativePreferences(userId);
92
100
  for (const id of data.itemIds) {
93
101
  if (!canAccessItem(userId, id))
@@ -103,7 +111,7 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
103
111
  const expiry = data.expiresAt ? new Date(data.expiresAt) : new Date(now.getTime() + (data.durationHours ?? 24) * 3_600_000);
104
112
  if (expiry.getTime() <= now.getTime() || expiry.getTime() > now.getTime() + 168 * 3_600_000)
105
113
  throw new NativeError("invalid_expiry");
106
- return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null);
114
+ return writeEdition(userId, data.itemIds, "curated", expiry, now, data.title ?? null, data.summary ?? (data.overview?.map((block) => `${block.heading}: ${block.segments.map((segment) => segment.text).join("")}`).join("\n\n").slice(0, 2000).replace(/[\uD800-\uDBFF]$/, "") ?? null), data.overview ?? null, data.layout ?? null);
107
115
  }, now);
108
116
  }
109
117
  /** Compatibility entry point for existing internal callers. New agents supply a
@@ -127,9 +135,76 @@ export function deleteEdition(userId) {
127
135
  dismissEdition(userId);
128
136
  return existed;
129
137
  }
138
+ /** The automatic edition looks at every candidate; smaller limits are shortlists for an agent or the curator. */
139
+ const AUTOMATIC_EDITION_LIMIT = 4000;
140
+ /** How often each feed appeared in the editions of the last 72 hours, so a feed that was shown a lot waits its turn. */
141
+ function recentSourceUse(userId, itemIds) {
142
+ const use = new Map();
143
+ for (let start = 0; start < itemIds.length; start += 500) {
144
+ const chunk = itemIds.slice(start, start + 500);
145
+ const rows = db.prepare(`SELECT i.feed_id FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
146
+ WHERE i.id IN (${chunk.map(() => "?").join(",")})`).all(userId, ...chunk);
147
+ for (const row of rows)
148
+ use.set(row.feed_id, (use.get(row.feed_id) ?? 0) + 1);
149
+ }
150
+ return use;
151
+ }
152
+ /**
153
+ * Takes candidates round by round, one per source: volume says nothing about relevance, so a feed with three
154
+ * articles a week competes on equal terms with one that posts eighty. Sources that were shown less lately (and are
155
+ * not yet in the head of the list) go first within a round; inside a source the better scored article goes first,
156
+ * and an article offered before only after the new ones.
157
+ */
158
+ export function takeFairly(rest, head, recentUse) {
159
+ const queues = new Map();
160
+ for (const item of [...rest].sort((a, b) => Number(a.previouslySelected) - Number(b.previouslySelected) || b.score - a.score || b.id - a.id)) {
161
+ const queue = queues.get(item.subscriptionId);
162
+ if (queue)
163
+ queue.push(item);
164
+ else
165
+ queues.set(item.subscriptionId, [item]);
166
+ }
167
+ const inHead = new Map();
168
+ for (const item of head)
169
+ inHead.set(item.subscriptionId, (inHead.get(item.subscriptionId) ?? 0) + 1);
170
+ const order = [...queues.keys()].sort((a, b) => (inHead.get(a) ?? 0) - (inHead.get(b) ?? 0) || (recentUse.get(a) ?? 0) - (recentUse.get(b) ?? 0) ||
171
+ queues.get(b)[0].score - queues.get(a)[0].score || a - b);
172
+ const result = [];
173
+ for (let round = 0; result.length < rest.length; round++) {
174
+ for (const source of order) {
175
+ const item = queues.get(source)[round];
176
+ if (item)
177
+ result.push(item);
178
+ }
179
+ }
180
+ return result;
181
+ }
182
+ /**
183
+ * A source may fill at most its fair share of the shortlist (the size divided by the number of sources), however
184
+ * much it publishes; the surplus of busy feeds is left out instead of crowding out the rest. A shortlist that would
185
+ * become too small to choose from is topped up in the same fair order, so no account ends up with an empty one.
186
+ */
187
+ export function fairShortlist(head, fairOrder, limit, sources) {
188
+ const share = Math.max(2, Math.ceil(limit / Math.max(1, sources)));
189
+ const used = new Map();
190
+ const kept = [];
191
+ const surplus = [];
192
+ for (const item of [...head, ...fairOrder]) {
193
+ const count = used.get(item.subscriptionId) ?? 0;
194
+ if (count < share) {
195
+ used.set(item.subscriptionId, count + 1);
196
+ kept.push(item);
197
+ }
198
+ else
199
+ surplus.push(item);
200
+ }
201
+ const minimum = Math.min(limit, 24);
202
+ return [...kept, ...surplus.slice(0, Math.max(0, minimum - kept.length))].slice(0, limit);
203
+ }
130
204
  export function editionCandidates(userId, now = new Date(), limit = 200) {
131
205
  const prefs = getNativePreferences(userId);
132
- const previous = new Set(db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids)));
206
+ const previousItemIds = db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids));
207
+ const previous = new Set(previousItemIds);
133
208
  // Fetch bounded candidates per source, so a busy feed cannot hide the others.
134
209
  const rows = db.prepare(`SELECT * FROM (
135
210
  SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
@@ -153,11 +228,12 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
153
228
  score: Math.pow(0.5, Math.max(0, now.getTime() - Date.parse(row.published)) / (18 * 3_600_000)) *
154
229
  (row.folder_id !== null && prefs.preferredFolderIds.includes(row.folder_id) ? 1.4 : 1),
155
230
  }));
156
- // Candidate requests expose a diversified shortlist rather than only the busiest source.
157
- if (limit < scored.length) {
231
+ // Candidate requests expose a shortlist in which every source has a fair chance, not only the busiest one.
232
+ if (limit < AUTOMATIC_EDITION_LIMIT) {
158
233
  const sorted = selectEdition(scored, { ...prefs, editionSize: Math.min(limit, 24), readingMinutes: null });
159
234
  const selected = new Set(sorted.map((item) => item.id));
160
- return [...sorted, ...scored.filter((item) => !selected.has(item.id)).sort((a, b) => b.score - a.score || b.id - a.id)].slice(0, limit);
235
+ const rest = scored.filter((item) => !selected.has(item.id));
236
+ return fairShortlist(sorted, takeFairly(rest, sorted, recentSourceUse(userId, previousItemIds)), limit, new Set(scored.map((item) => item.subscriptionId)).size);
161
237
  }
162
238
  return scored.sort((a, b) => b.score - a.score || b.id - a.id);
163
239
  }
@@ -187,7 +263,7 @@ export function generateEdition(userId, input = {}, now = new Date()) {
187
263
  if (row?.status === "active" && !state.edition) {
188
264
  db.prepare("UPDATE editions SET status = 'expired', revision = revision + 1, updated_at = ? WHERE user_id = ?").run(now.toISOString(), userId);
189
265
  }
190
- const chosen = selectEdition(editionCandidates(userId, now, 4000), prefs);
266
+ const chosen = selectEdition(editionCandidates(userId, now, AUTOMATIC_EDITION_LIMIT), prefs);
191
267
  if (!chosen.length)
192
268
  return state.edition;
193
269
  if (state.edition && JSON.stringify(state.edition.itemIds) === JSON.stringify(chosen.map((item) => item.id)))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }