openvibers/openvibe.blog Commits
Blog: a Budget recipes desk from OpenVibe.Food's recipe library (Food's blog on openvibe.blog) (#61)
Changes
@@@ -172,11 +172,13 @@ indexed (`noindex`, not in the sitemaps).| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before || Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) || Developer digest | `nodejs-blog` | Mondays, for the week before |++| Budget recipes | OpenVibe.Food's recipe library (`/recipes/library.json`, `OV_FOOD_URL`; each recipe a `food.recipe` source) | Mondays, 08:00 UTC, for the week before (3 recipes at least) || Is This Real? (satire) | none | daily, 14:00 UTC |Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desksread is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed--item is dropped). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the++item is dropped); the budget-recipes desk asks OpenVibe.Food for the recipes added since the newest one Blog holds (the++owner's "interconnect food with blog where it creates its own blogs"). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so thefirst pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to`blog.write_digest`; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, andevery cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to@@@ -65,6 +65,8 @@ function load(env = process.env) {aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },++ // OpenVibe.Food's recipe library feed (/recipes/library.json, public): the budget-recipes desk. Empty = off.++ food: { url: trim(env.OV_FOOD_URL || ''), timeoutMs: int(env.BLOG_FOOD_TIMEOUT_MS, 10000) },// The AI desks (server/desks/desks.js): pull, write and prune every intervalMs where the worker runs. Off with// BLOG_DESKS=off; the digests also need OV_SOURCES_INTERNAL_URL, every desk needs OV_AI_INTERNAL_URL.desks: { enabled: env.BLOG_DESKS !== 'off', intervalMs: Math.max(60_000, int(env.BLOG_DESKS_INTERVAL_MS, 15 * 60_000)) },@@@ -10,6 +10,7 @@* categories the Sources categories its sources are in (one change cursor each)* sources the Sources source keys it reads (a source that is disabled or empty just contributes nothing)* min, max fewer items than min: no post for that period; at most max items go to the AI (the newest)++ * feed instead of Sources: 'food-library', OpenVibe.Food's recipe library (/recipes/library.json)*/const DESKS = [@@@ -38,6 +39,11 @@ const DESKS = [about: 'New and growing open-source and community campaigns on Open Collective, week by week: what they raise money for and what they have received, as Open Collective states it.',categories: ['crowdfunding'], sources: ['opencollective-accounts'],},++ {++ key: 'recipes', title: 'Budget recipes', every: 'week', hour: 8, min: 3, max: 20, feed: 'food-library',++ about: 'The week\'s new recipes in OpenVibe.Food\'s library: cheap, simple dishes made only from its food list, each with what to buy and what that typically costs. Written by AI from the recipes themselves; check a recipe before you cook it.',++ categories: [], sources: ['food-library'],++ },{key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,about: 'Releases and news from the Node.js project blog, week by week.',@@@ -92,7 +92,8 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolconst ai = createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokens, fetch: fetchImpl });const sourcesOn = Boolean(config.sources && config.sources.internalUrl && config.oauth.clientSecret);const cursor = ingest.createChangeCursor(db, { prefix: 'blog', now });-- const wanted = new Set(catalog.DESKS.flatMap((d) => d.sources));++ const wanted = new Set(catalog.DESKS.filter((d) => !d.feed).flatMap((d) => d.sources));++ const foodOn = Boolean(config.food && config.food.url);const categories = [...new Set(catalog.DESKS.flatMap((d) => d.categories))];const clients = new Map(categories.map((cat) => [cat, ingest.createSourcesClient({ config: { ...config, sources: { ...config.sources, category: cat } }, fetchImpl, now })]));const stats = { lastPullAt: null, lastComposeAt: null, lastError: null, written: 0 };@@@ -137,6 +138,42 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolreturn { pulled };}++ /**++ * OpenVibe.Food's recipe library (the budget-recipes desk): the recipes added since the newest one Blog holds (or++ * two weeks back), each as a desk item `food:<slug>` dated when Food added it.++ */++ async function pullFood() {++ if (!foodOn) return 0;++ const newest = await db.value("SELECT max(published_at) FROM blog_desk_items WHERE source_key = 'food-library'");++ const since = newest || new Date(now() - 14 * 86_400_000).toISOString();++ let res;++ try {++ res = await fetchImpl(`${config.food.url}/recipes/library.json?since=${encodeURIComponent(since)}&limit=100`, {++ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(config.food.timeoutMs || 10000),++ });++ } catch (err) {++ stats.lastError = `pull food-library: ${err.message}`;++ return 0;++ }++ if (!res.ok) { stats.lastError = `pull food-library: HTTP ${res.status}`; return 0; }++ const body = await res.json().catch(() => ({}));++ let n = 0;++ for (const x of Array.isArray(body.recipes) ? body.recipes : []) {++ if (!x || !x.slug || !x.title || !x.url || Number.isNaN(Date.parse(x.created_at))) continue;++ const facts = [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `about ${x.minutes} min` : null,++ x.cost != null ? `$${Number(x.cost).toFixed(2)} to buy${x.cost_partial ? ' (some prices unknown)' : ''}` : null].filter(Boolean).join(', ');++ const summary = [facts, Array.isArray(x.ingredients) && x.ingredients.length ? `Ingredients: ${x.ingredients.slice(0, 15).join(', ')}.` : null, x.reviewed ? 'Checked by a person.' : null].filter(Boolean).join(' ');++ const at = Date.parse(x.created_at);++ await db.query(`INSERT INTO blog_desk_items (item_id, source_key, title, url, summary, published_at, fields, revision, changed_at, updated_at)++ VALUES ($1, 'food-library', $2, $3, $4, $5, $6::jsonb, 1, $7, $8)++ ON CONFLICT (item_id) DO UPDATE SET title = excluded.title, summary = excluded.summary, fields = excluded.fields, updated_at = excluded.updated_at`,++ [`food:${String(x.slug).slice(0, 100)}`, clean(x.title, 500), String(x.url).slice(0, 500), summary.slice(0, 2000), new Date(at).toISOString(),++ JSON.stringify({ theme: clean(x.theme, 200), cost: x.cost, serves: x.serves, minutes: x.minutes, reviewed: Boolean(x.reviewed) }), at, now()]);++ n++;++ }++ return n;++ }++// ── Posts ───────────────────────────────────────────────const shape = (r) => (r ? {@@@ -178,7 +215,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolconst r = await ai.run('blog.write_digest', {desk: desk.title, about: desk.about, period: label,sources: items.map((it) => ({-- source_type: 'sources.item', source_id: it.item_id,++ source_type: desk.feed === 'food-library' ? 'food.recipe' : 'sources.item', source_id: it.item_id,...(it.url ? { url: it.url } : {}), ...(it.title ? { title: clean(it.title, 500) } : {}),...(it.published_at ? { published_at: String(it.published_at).slice(0, 40) } : {}),snippet: snippetOf(it),@@@ -240,11 +277,10 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolif (!ai.enabled) return [];const out = [];const at = now();-- if (sourcesOn) {-- for (const desk of catalog.DESKS) {-- const p = await writeDigest(desk, duePeriod(desk, at));-- if (p) out.push(p);-- }++ for (const desk of catalog.DESKS) {++ if (desk.feed === 'food-library' ? !foodOn : !sourcesOn) continue;++ const p = await writeDigest(desk, duePeriod(desk, at));++ if (p) out.push(p);}const p = await writeSatire(catalog.SATIRE, duePeriod(catalog.SATIRE, at));if (p) out.push(p);@@@ -262,6 +298,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolrunning = true;try {const p = await pull();++ p.pulled += await pullFood();const written = await compose();await prune();return { ...p, written: written.length };@@@ -278,7 +315,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolreturn {enabled: Boolean(c.enabled && ai.enabled),stats,-- pull, compose, tick, prune,++ pull, pullFood, compose, tick, prune,/** Published posts of a desk, newest first (staff also see hidden ones). */async list(deskKey, { limit = 20, offset = 0, staff = false } = {}) {const states = staff ? ['published', 'hidden'] : ['published'];nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * The budget-recipes desk (server/desks/, feed 'food-library'): the week's new recipes from OpenVibe.Food's library++ * (/recipes/library.json), written up by blog.write_digest with each recipe as a `food.recipe` source.++ * - off without OV_FOOD_URL; the pull asks Food for what is new since the newest recipe Blog holds;++ * - a week with fewer recipes than the desk's minimum gets no post; the post cites Food's recipe pages.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const at = (s) => Date.parse(s);++++function stub(handler) {++ return new Promise((resolve) => {++ const s = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const out = handler(req, raw ? JSON.parse(raw) : null) || { status: 404, body: {} };++ res.writeHead(out.status || 200, { 'content-type': 'application/json' });++ res.end(JSON.stringify(out.body));++ });++ }).listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, close: () => new Promise((r) => s.close(r)) }));++ });++}++++(async () => {++ const recipe = (slug, created_at, extra = {}) => ({++ slug, url: `https://openvibe.food/recipes/library/${slug}`, title: slug.replace(/-/g, ' '), theme: 'cheap dinners',++ serves: 2, minutes: 20, cost: 4.5, cost_partial: false, ingredients: ['rice', 'beans', 'onion'], reviewed: false, created_at, ...extra,++ });++ const library = [++ recipe('beans-and-rice', '2026-10-06T10:00:00.000Z', { reviewed: true }),++ recipe('lentil-soup', '2026-10-08T10:00:00.000Z'),++ recipe('egg-fried-rice', '2026-10-11T10:00:00.000Z'),++ recipe('next-week-pasta', '2026-10-13T10:00:00.000Z'),++ ];++ const asked = [];++ const food = await stub((req) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname !== '/recipes/library.json') return null;++ const since = u.searchParams.get('since');++ asked.push(since);++ return { body: { recipes: library.filter((x) => x.created_at >= since) } };++ });++ const runs = [];++ const ai = await stub((req, body) => {++ if (req.method === 'POST' && req.url.startsWith('/api/v1/runs')) {++ runs.push(body);++ const output = body.workflow === 'blog.write_satire'++ ? { title: 'Satire', dek: 'x', body_markdown: 'Made up.', tags: [] }++ : { title: 'Lentil soup and two rice dishes under five dollars', dek: 'Three cheap dinners.', sections: [++ { heading: 'Dinners', paragraphs: [{ text: 'Beans and rice feeds two for about $4.50.', citations: [0] }, { text: 'Lentil soup and egg fried rice use the same pantry.', citations: [1, 2] }] },++ ], citations: [0], gaps: [] };++ return { status: 201, body: { run: { id: `run_${runs.length}`, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, provenance: { model: 'gpt-test' }, output } } };++ }++ if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };++ return null;++ });++++ const off = await boot({ env: { OV_AI_INTERNAL_URL: ai.url } });++ await check('off without OV_FOOD_URL: nothing is asked of Food', async () => {++ off.clock.set(at('2026-10-15T09:00:00Z'));++ await off.ctx.desks.tick();++ assert.deepStrictEqual(asked, []);++ assert.strictEqual((await off.ctx.desks.list('recipes')).total, 0);++ });++ await off.close();++ runs.length = 0;++++ const t = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, OV_FOOD_URL: food.url } });++ t.clock.set(at('2026-10-15T09:00:00Z'));++ const desks = t.ctx.desks;++++ await check('the week\'s recipes become one cited post; the next pull asks only for what is newer', async () => {++ const r = await desks.tick();++ assert.ok(r, 'the tick ran');++ assert.match(asked[0], /^2026-10-01T09:00:00/, 'the first pull reaches two weeks back');++ const digest = runs.find((x) => x.workflow === 'blog.write_digest' && x.input.desk === 'Budget recipes');++ assert.ok(digest, JSON.stringify(runs.map((x) => x.input && x.input.desk)));++ assert.deepStrictEqual(digest.input.sources.map((s) => s.source_id), ['food:egg-fried-rice', 'food:lentil-soup', 'food:beans-and-rice'], 'the week of 5 to 11 October, newest first');++ assert.ok(digest.input.sources.every((s) => s.source_type === 'food.recipe' && s.url.startsWith('https://openvibe.food/recipes/library/')));++ assert.ok(digest.input.sources[2].snippet.includes('$4.50 to buy') && digest.input.sources[2].snippet.includes('Checked by a person.'));++ const posts = (await desks.list('recipes')).posts;++ assert.strictEqual(posts.length, 1);++ assert.strictEqual(posts[0].title, 'Lentil soup and two rice dishes under five dollars');++ const page = await t.get(`/desks/recipes/${posts[0].slug}`);++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('https://openvibe.food/recipes/library/lentil-soup'), 'it links the recipes');++ await desks.tick();++ assert.match(asked[asked.length - 1], /^2026-10-13T10:00:00/, 'then only what is newer than the newest recipe Blog holds');++ });++++ await check('a week below the minimum gets no post', async () => {++ t.clock.set(at('2026-10-22T09:00:00Z'));++ await desks.tick();++ assert.strictEqual((await desks.list('recipes')).total, 1, 'one recipe in the week of 12 October is not enough');++ });++++ await t.close();++ await food.close();++ await ai.close();++ done();++})();@@@ -63,6 +63,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured); dead after the SDK move','server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names','server/clients/community.js': 'OpenVibe.Community (configured)',++ 'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)','server/clients/media.js': 'OpenVibe.Media (configured)','server/clients/network.js': 'OpenVibe.Network (configured)',};
@@ -172,11 +172,13 @@ indexed (`noindex`, not in the sitemaps).
@@ -172,11 +172,13 @@ indexed (`noindex`, not in the sitemaps).
| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |
| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |
| Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) |
| Crowdfunding digest | `opencollective-accounts` | Mondays, for the week before (3 items at least) |
| Developer digest | `nodejs-blog` | Mondays, for the week before |
| Developer digest | `nodejs-blog` | Mondays, for the week before |
+| Budget recipes | OpenVibe.Food's recipe library (`/recipes/library.json`, `OV_FOOD_URL`; each recipe a `food.recipe` source) | Mondays, 08:00 UTC, for the week before (3 recipes at least) |
| Is This Real? (satire) | none | daily, 14:00 UTC |
| Is This Real? (satire) | none | daily, 14:00 UTC |
Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks
Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks
read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed
read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed
-item is dropped). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the
+item is dropped); the budget-recipes desk asks OpenVibe.Food for the recipes added since the newest one Blog holds (the
+owner's "interconnect food with blog where it creates its own blogs"). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the
first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to
first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to
`blog.write_digest`; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, and
`blog.write_digest`; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, and
every cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to
every cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to
@@ -65,6 +65,8 @@ function load(env = process.env) {
@@ -65,6 +65,8 @@ function load(env = process.env) {
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).
// OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).
sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },
sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },
+ // OpenVibe.Food's recipe library feed (/recipes/library.json, public): the budget-recipes desk. Empty = off.
+ food: { url: trim(env.OV_FOOD_URL || ''), timeoutMs: int(env.BLOG_FOOD_TIMEOUT_MS, 10000) },
// The AI desks (server/desks/desks.js): pull, write and prune every intervalMs where the worker runs. Off with
// The AI desks (server/desks/desks.js): pull, write and prune every intervalMs where the worker runs. Off with
// BLOG_DESKS=off; the digests also need OV_SOURCES_INTERNAL_URL, every desk needs OV_AI_INTERNAL_URL.
// BLOG_DESKS=off; the digests also need OV_SOURCES_INTERNAL_URL, every desk needs OV_AI_INTERNAL_URL.
desks: { enabled: env.BLOG_DESKS !== 'off', intervalMs: Math.max(60_000, int(env.BLOG_DESKS_INTERVAL_MS, 15 * 60_000)) },
desks: { enabled: env.BLOG_DESKS !== 'off', intervalMs: Math.max(60_000, int(env.BLOG_DESKS_INTERVAL_MS, 15 * 60_000)) },
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* categories the Sources categories its sources are in (one change cursor each)
* categories the Sources categories its sources are in (one change cursor each)
* sources the Sources source keys it reads (a source that is disabled or empty just contributes nothing)
* sources the Sources source keys it reads (a source that is disabled or empty just contributes nothing)
* min, max fewer items than min: no post for that period; at most max items go to the AI (the newest)
* min, max fewer items than min: no post for that period; at most max items go to the AI (the newest)
+ * feed instead of Sources: 'food-library', OpenVibe.Food's recipe library (/recipes/library.json)
*/
*/
const DESKS = [
const DESKS = [
@@ -38,6 +39,11 @@ const DESKS = [
@@ -38,6 +39,11 @@ const DESKS = [
about: 'New and growing open-source and community campaigns on Open Collective, week by week: what they raise money for and what they have received, as Open Collective states it.',
about: 'New and growing open-source and community campaigns on Open Collective, week by week: what they raise money for and what they have received, as Open Collective states it.',
categories: ['crowdfunding'], sources: ['opencollective-accounts'],
categories: ['crowdfunding'], sources: ['opencollective-accounts'],
},
},
+ {
+ key: 'recipes', title: 'Budget recipes', every: 'week', hour: 8, min: 3, max: 20, feed: 'food-library',
+ about: 'The week\'s new recipes in OpenVibe.Food\'s library: cheap, simple dishes made only from its food list, each with what to buy and what that typically costs. Written by AI from the recipes themselves; check a recipe before you cook it.',
+ categories: [], sources: ['food-library'],
+ },
{
{
key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,
key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,
about: 'Releases and news from the Node.js project blog, week by week.',
about: 'Releases and news from the Node.js project blog, week by week.',
@@ -92,7 +92,8 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -92,7 +92,8 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokens, fetch: fetchImpl });
const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokens, fetch: fetchImpl });
const sourcesOn = Boolean(config.sources && config.sources.internalUrl && config.oauth.clientSecret);
const sourcesOn = Boolean(config.sources && config.sources.internalUrl && config.oauth.clientSecret);
const cursor = ingest.createChangeCursor(db, { prefix: 'blog', now });
const cursor = ingest.createChangeCursor(db, { prefix: 'blog', now });
- const wanted = new Set(catalog.DESKS.flatMap((d) => d.sources));
+ const wanted = new Set(catalog.DESKS.filter((d) => !d.feed).flatMap((d) => d.sources));
+ const foodOn = Boolean(config.food && config.food.url);
const categories = [...new Set(catalog.DESKS.flatMap((d) => d.categories))];
const categories = [...new Set(catalog.DESKS.flatMap((d) => d.categories))];
const clients = new Map(categories.map((cat) => [cat, ingest.createSourcesClient({ config: { ...config, sources: { ...config.sources, category: cat } }, fetchImpl, now })]));
const clients = new Map(categories.map((cat) => [cat, ingest.createSourcesClient({ config: { ...config, sources: { ...config.sources, category: cat } }, fetchImpl, now })]));
const stats = { lastPullAt: null, lastComposeAt: null, lastError: null, written: 0 };
const stats = { lastPullAt: null, lastComposeAt: null, lastError: null, written: 0 };
@@ -137,6 +138,42 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -137,6 +138,42 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
return { pulled };
return { pulled };
}
}
+ /**
+ * OpenVibe.Food's recipe library (the budget-recipes desk): the recipes added since the newest one Blog holds (or
+ * two weeks back), each as a desk item `food:<slug>` dated when Food added it.
+ */
+ async function pullFood() {
+ if (!foodOn) return 0;
+ const newest = await db.value("SELECT max(published_at) FROM blog_desk_items WHERE source_key = 'food-library'");
+ const since = newest || new Date(now() - 14 * 86_400_000).toISOString();
+ let res;
+ try {
+ res = await fetchImpl(`${config.food.url}/recipes/library.json?since=${encodeURIComponent(since)}&limit=100`, {
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(config.food.timeoutMs || 10000),
+ });
+ } catch (err) {
+ stats.lastError = `pull food-library: ${err.message}`;
+ return 0;
+ }
+ if (!res.ok) { stats.lastError = `pull food-library: HTTP ${res.status}`; return 0; }
+ const body = await res.json().catch(() => ({}));
+ let n = 0;
+ for (const x of Array.isArray(body.recipes) ? body.recipes : []) {
+ if (!x || !x.slug || !x.title || !x.url || Number.isNaN(Date.parse(x.created_at))) continue;
+ const facts = [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `about ${x.minutes} min` : null,
+ x.cost != null ? `$${Number(x.cost).toFixed(2)} to buy${x.cost_partial ? ' (some prices unknown)' : ''}` : null].filter(Boolean).join(', ');
+ const summary = [facts, Array.isArray(x.ingredients) && x.ingredients.length ? `Ingredients: ${x.ingredients.slice(0, 15).join(', ')}.` : null, x.reviewed ? 'Checked by a person.' : null].filter(Boolean).join(' ');
+ const at = Date.parse(x.created_at);
+ await db.query(`INSERT INTO blog_desk_items (item_id, source_key, title, url, summary, published_at, fields, revision, changed_at, updated_at)
+ VALUES ($1, 'food-library', $2, $3, $4, $5, $6::jsonb, 1, $7, $8)
+ ON CONFLICT (item_id) DO UPDATE SET title = excluded.title, summary = excluded.summary, fields = excluded.fields, updated_at = excluded.updated_at`,
+ [`food:${String(x.slug).slice(0, 100)}`, clean(x.title, 500), String(x.url).slice(0, 500), summary.slice(0, 2000), new Date(at).toISOString(),
+ JSON.stringify({ theme: clean(x.theme, 200), cost: x.cost, serves: x.serves, minutes: x.minutes, reviewed: Boolean(x.reviewed) }), at, now()]);
+ n++;
+ }
+ return n;
+ }
+
// ── Posts ───────────────────────────────────────────────
// ── Posts ───────────────────────────────────────────────
const shape = (r) => (r ? {
const shape = (r) => (r ? {
@@ -178,7 +215,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -178,7 +215,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
const r = await ai.run('blog.write_digest', {
const r = await ai.run('blog.write_digest', {
desk: desk.title, about: desk.about, period: label,
desk: desk.title, about: desk.about, period: label,
sources: items.map((it) => ({
sources: items.map((it) => ({
- source_type: 'sources.item', source_id: it.item_id,
+ source_type: desk.feed === 'food-library' ? 'food.recipe' : 'sources.item', source_id: it.item_id,
...(it.url ? { url: it.url } : {}), ...(it.title ? { title: clean(it.title, 500) } : {}),
...(it.url ? { url: it.url } : {}), ...(it.title ? { title: clean(it.title, 500) } : {}),
...(it.published_at ? { published_at: String(it.published_at).slice(0, 40) } : {}),
...(it.published_at ? { published_at: String(it.published_at).slice(0, 40) } : {}),
snippet: snippetOf(it),
snippet: snippetOf(it),
@@ -240,11 +277,10 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -240,11 +277,10 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
if (!ai.enabled) return [];
if (!ai.enabled) return [];
const out = [];
const out = [];
const at = now();
const at = now();
- if (sourcesOn) {
- for (const desk of catalog.DESKS) {
- const p = await writeDigest(desk, duePeriod(desk, at));
- if (p) out.push(p);
- }
+ for (const desk of catalog.DESKS) {
+ if (desk.feed === 'food-library' ? !foodOn : !sourcesOn) continue;
+ const p = await writeDigest(desk, duePeriod(desk, at));
+ if (p) out.push(p);
}
}
const p = await writeSatire(catalog.SATIRE, duePeriod(catalog.SATIRE, at));
const p = await writeSatire(catalog.SATIRE, duePeriod(catalog.SATIRE, at));
if (p) out.push(p);
if (p) out.push(p);
@@ -262,6 +298,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -262,6 +298,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
running = true;
running = true;
try {
try {
const p = await pull();
const p = await pull();
+ p.pulled += await pullFood();
const written = await compose();
const written = await compose();
await prune();
await prune();
return { ...p, written: written.length };
return { ...p, written: written.length };
@@ -278,7 +315,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -278,7 +315,7 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
return {
return {
enabled: Boolean(c.enabled && ai.enabled),
enabled: Boolean(c.enabled && ai.enabled),
stats,
stats,
- pull, compose, tick, prune,
+ pull, pullFood, compose, tick, prune,
/** Published posts of a desk, newest first (staff also see hidden ones). */
/** Published posts of a desk, newest first (staff also see hidden ones). */
async list(deskKey, { limit = 20, offset = 0, staff = false } = {}) {
async list(deskKey, { limit = 20, offset = 0, staff = false } = {}) {
const states = staff ? ['published', 'hidden'] : ['published'];
const states = staff ? ['published', 'hidden'] : ['published'];
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * The budget-recipes desk (server/desks/, feed 'food-library'): the week's new recipes from OpenVibe.Food's library
+ * (/recipes/library.json), written up by blog.write_digest with each recipe as a `food.recipe` source.
+ * - off without OV_FOOD_URL; the pull asks Food for what is new since the newest recipe Blog holds;
+ * - a week with fewer recipes than the desk's minimum gets no post; the post cites Food's recipe pages.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const at = (s) => Date.parse(s);
+
+function stub(handler) {
+ return new Promise((resolve) => {
+ const s = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const out = handler(req, raw ? JSON.parse(raw) : null) || { status: 404, body: {} };
+ res.writeHead(out.status || 200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(out.body));
+ });
+ }).listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, close: () => new Promise((r) => s.close(r)) }));
+ });
+}
+
+(async () => {
+ const recipe = (slug, created_at, extra = {}) => ({
+ slug, url: `https://openvibe.food/recipes/library/${slug}`, title: slug.replace(/-/g, ' '), theme: 'cheap dinners',
+ serves: 2, minutes: 20, cost: 4.5, cost_partial: false, ingredients: ['rice', 'beans', 'onion'], reviewed: false, created_at, ...extra,
+ });
+ const library = [
+ recipe('beans-and-rice', '2026-10-06T10:00:00.000Z', { reviewed: true }),
+ recipe('lentil-soup', '2026-10-08T10:00:00.000Z'),
+ recipe('egg-fried-rice', '2026-10-11T10:00:00.000Z'),
+ recipe('next-week-pasta', '2026-10-13T10:00:00.000Z'),
+ ];
+ const asked = [];
+ const food = await stub((req) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname !== '/recipes/library.json') return null;
+ const since = u.searchParams.get('since');
+ asked.push(since);
+ return { body: { recipes: library.filter((x) => x.created_at >= since) } };
+ });
+ const runs = [];
+ const ai = await stub((req, body) => {
+ if (req.method === 'POST' && req.url.startsWith('/api/v1/runs')) {
+ runs.push(body);
+ const output = body.workflow === 'blog.write_satire'
+ ? { title: 'Satire', dek: 'x', body_markdown: 'Made up.', tags: [] }
+ : { title: 'Lentil soup and two rice dishes under five dollars', dek: 'Three cheap dinners.', sections: [
+ { heading: 'Dinners', paragraphs: [{ text: 'Beans and rice feeds two for about $4.50.', citations: [0] }, { text: 'Lentil soup and egg fried rice use the same pantry.', citations: [1, 2] }] },
+ ], citations: [0], gaps: [] };
+ return { status: 201, body: { run: { id: `run_${runs.length}`, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, provenance: { model: 'gpt-test' }, output } } };
+ }
+ if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };
+ return null;
+ });
+
+ const off = await boot({ env: { OV_AI_INTERNAL_URL: ai.url } });
+ await check('off without OV_FOOD_URL: nothing is asked of Food', async () => {
+ off.clock.set(at('2026-10-15T09:00:00Z'));
+ await off.ctx.desks.tick();
+ assert.deepStrictEqual(asked, []);
+ assert.strictEqual((await off.ctx.desks.list('recipes')).total, 0);
+ });
+ await off.close();
+ runs.length = 0;
+
+ const t = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, OV_FOOD_URL: food.url } });
+ t.clock.set(at('2026-10-15T09:00:00Z'));
+ const desks = t.ctx.desks;
+
+ await check('the week\'s recipes become one cited post; the next pull asks only for what is newer', async () => {
+ const r = await desks.tick();
+ assert.ok(r, 'the tick ran');
+ assert.match(asked[0], /^2026-10-01T09:00:00/, 'the first pull reaches two weeks back');
+ const digest = runs.find((x) => x.workflow === 'blog.write_digest' && x.input.desk === 'Budget recipes');
+ assert.ok(digest, JSON.stringify(runs.map((x) => x.input && x.input.desk)));
+ assert.deepStrictEqual(digest.input.sources.map((s) => s.source_id), ['food:egg-fried-rice', 'food:lentil-soup', 'food:beans-and-rice'], 'the week of 5 to 11 October, newest first');
+ assert.ok(digest.input.sources.every((s) => s.source_type === 'food.recipe' && s.url.startsWith('https://openvibe.food/recipes/library/')));
+ assert.ok(digest.input.sources[2].snippet.includes('$4.50 to buy') && digest.input.sources[2].snippet.includes('Checked by a person.'));
+ const posts = (await desks.list('recipes')).posts;
+ assert.strictEqual(posts.length, 1);
+ assert.strictEqual(posts[0].title, 'Lentil soup and two rice dishes under five dollars');
+ const page = await t.get(`/desks/recipes/${posts[0].slug}`);
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('https://openvibe.food/recipes/library/lentil-soup'), 'it links the recipes');
+ await desks.tick();
+ assert.match(asked[asked.length - 1], /^2026-10-13T10:00:00/, 'then only what is newer than the newest recipe Blog holds');
+ });
+
+ await check('a week below the minimum gets no post', async () => {
+ t.clock.set(at('2026-10-22T09:00:00Z'));
+ await desks.tick();
+ assert.strictEqual((await desks.list('recipes')).total, 1, 'one recipe in the week of 12 October is not enough');
+ });
+
+ await t.close();
+ await food.close();
+ await ai.close();
+ done();
+})();
@@ -63,6 +63,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -63,6 +63,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured); dead after the SDK move',
'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured); dead after the SDK move',
'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',
'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',
'server/clients/community.js': 'OpenVibe.Community (configured)',
'server/clients/community.js': 'OpenVibe.Community (configured)',
+ 'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
};
};