feedkeeper 0.25.1 → 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 +1 -1
- package/server/dist/api/v1/index.js +1 -0
- package/server/dist/db/migrations/0034_edition_layout.sql +2 -0
- package/server/dist/mcp/server.js +1 -1
- package/server/dist/native/editionLayout.js +66 -0
- package/server/dist/native/editions.js +15 -7
- package/server/package.json +1 -1
package/package.json
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|