← back to Marketing Command Center
engine: brain (candidates+llm+generate), scheduler (CAS tick+crash sweep), Engine panel UI
0b73831cc9456c3ceddb0e349d8950650337f738 · 2026-07-21 14:52:46 -0700 · Steve
Files touched
M .gitignoreM data/engine-queue.jsonA modules/engine/candidates.jsA modules/engine/generate.jsM modules/engine/index.jsA modules/engine/llm.jsA modules/engine/scheduler.jsA public/panels/engine.htmlA public/panels/engine.js
Diff
commit 0b73831cc9456c3ceddb0e349d8950650337f738
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 21 14:52:46 2026 -0700
engine: brain (candidates+llm+generate), scheduler (CAS tick+crash sweep), Engine panel UI
---
.gitignore | 2 +
data/engine-queue.json | 661 ++++++++++++++++++++++++++++++++++++++++++-
modules/engine/candidates.js | 185 ++++++++++++
modules/engine/generate.js | 117 ++++++++
modules/engine/index.js | 8 +-
modules/engine/llm.js | 233 +++++++++++++++
modules/engine/scheduler.js | 177 ++++++++++++
public/panels/engine.html | 123 ++++++++
public/panels/engine.js | 450 +++++++++++++++++++++++++++++
9 files changed, 1954 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 6999c27..b68d3ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,5 @@ data/sources/
__pycache__/
*.pyc
data/cc-import-fm-clients.json
+data/engine-queue.json
+data/engine-config.json
diff --git a/data/engine-queue.json b/data/engine-queue.json
index 0637a08..24b2a83 100644
--- a/data/engine-queue.json
+++ b/data/engine-queue.json
@@ -1 +1,660 @@
-[]
\ No newline at end of file
+[
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866952319027-instagram",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:12.270Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866952319027",
+ "dwSku": "",
+ "handle": "onda-pearl-and-silver-dwtt-73419",
+ "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+ "vendor": "Thibaut",
+ "productType": "Wallcovering",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+ },
+ "channel": "instagram",
+ "caption": "Subtle shimmer meets fluid movement in Onda, a pearl and silver wallcovering from Thibaut. Let light dance across its surface and transform your space.",
+ "hashtags": [
+ "#Thibaut",
+ "#Onda",
+ "#PearlAndSilver",
+ "#Wallcovering",
+ "#WatermarkCollection",
+ "#InteriorDesign",
+ "#LuxuryLiving"
+ ],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "videoUrl": null,
+ "suggestedSlot": "11:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866952319027-facebook",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:12.271Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866952319027",
+ "dwSku": "",
+ "handle": "onda-pearl-and-silver-dwtt-73419",
+ "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+ "vendor": "Thibaut",
+ "productType": "Wallcovering",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+ },
+ "channel": "facebook",
+ "caption": "Onda from Thibaut brings a sense of motion and elegance to any room. Its pearl and silver tones catch the light in a way that feels both modern and timeless.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "videoUrl": null,
+ "suggestedSlot": "09:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866952319027-bluesky",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:12.273Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866952319027",
+ "dwSku": "",
+ "handle": "onda-pearl-and-silver-dwtt-73419",
+ "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+ "vendor": "Thibaut",
+ "productType": "Wallcovering",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+ },
+ "channel": "bluesky",
+ "caption": "Onda in pearl and silver from Thibaut — where light meets movement in a whisper of sheen.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "videoUrl": null,
+ "suggestedSlot": "10:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866952319027-youtube",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:12.274Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866952319027",
+ "dwSku": "",
+ "handle": "onda-pearl-and-silver-dwtt-73419",
+ "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+ "vendor": "Thibaut",
+ "productType": "Wallcovering",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+ },
+ "channel": "youtube",
+ "caption": "Onda Wallcovering in Pearl and Silver",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "videoUrl": null,
+ "suggestedSlot": "12:00",
+ "scheduledAt": null,
+ "privacy": "public",
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": {
+ "description": "Experience the fluidity of Onda, a Thibaut wallcovering in pearl and silver. Its soft sheen and dynamic texture add depth and movement to any interior. Let the light guide your design story."
+ }
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866952319027-tiktok",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:12.275Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866952319027",
+ "dwSku": "",
+ "handle": "onda-pearl-and-silver-dwtt-73419",
+ "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+ "vendor": "Thibaut",
+ "productType": "Wallcovering",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+ },
+ "channel": "tiktok",
+ "caption": "Watch light move across Onda’s pearl and silver surface — pure elegance in motion.",
+ "hashtags": [
+ "#Thibaut",
+ "#Onda",
+ "#Wallcovering",
+ "#InteriorDesign",
+ "#LuxuryLiving"
+ ],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "videoUrl": null,
+ "suggestedSlot": "18:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "gated",
+ "gateReason": "App Review pending — posts private-only (SELF_ONLY)",
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866952319027-threads",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:12.275Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866952319027",
+ "dwSku": "",
+ "handle": "onda-pearl-and-silver-dwtt-73419",
+ "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+ "vendor": "Thibaut",
+ "productType": "Wallcovering",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+ },
+ "channel": "threads",
+ "caption": "Onda from Thibaut is more than a wallcovering — it's a play of light and texture. Its pearl and silver tones offer a quiet sophistication that evolves with the day’s changing light.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+ "videoUrl": null,
+ "suggestedSlot": "12:30",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "gated",
+ "gateReason": "App Review pending — live publish requires Meta approval",
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866951958579-instagram",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:36.354Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866951958579",
+ "dwSku": "",
+ "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+ "title": "Bahama Cloth II, Medium Celadon | Plains",
+ "vendor": "Plains",
+ "productType": "Fabric",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+ },
+ "channel": "instagram",
+ "caption": "A gentle blend of cotton and linen, Bahama Cloth II in Medium Celadon brings soft texture and subtle depth to any interior. Perfect for adding warmth and character to a room.",
+ "hashtags": [
+ "#BahamaClothII",
+ "#PlainsDesign",
+ "#FabricElegance",
+ "#NeutralTones",
+ "#CottonLinenBlend",
+ "#InteriorDesign"
+ ],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "videoUrl": null,
+ "suggestedSlot": "11:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866951958579-facebook",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:36.357Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866951958579",
+ "dwSku": "",
+ "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+ "title": "Bahama Cloth II, Medium Celadon | Plains",
+ "vendor": "Plains",
+ "productType": "Fabric",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+ },
+ "channel": "facebook",
+ "caption": "Bahama Cloth II in Medium Celadon offers a tactile, organic feel with a refined sheen. Ideal for layering light and texture in your space.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "videoUrl": null,
+ "suggestedSlot": "09:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866951958579-bluesky",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:36.358Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866951958579",
+ "dwSku": "",
+ "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+ "title": "Bahama Cloth II, Medium Celadon | Plains",
+ "vendor": "Plains",
+ "productType": "Fabric",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+ },
+ "channel": "bluesky",
+ "caption": "Soft, subtle, and rich in texture—Bahama Cloth II in Medium Celadon is a quiet luxury for walls that demand attention.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "videoUrl": null,
+ "suggestedSlot": "10:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866951958579-youtube",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:36.359Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866951958579",
+ "dwSku": "",
+ "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+ "title": "Bahama Cloth II, Medium Celadon | Plains",
+ "vendor": "Plains",
+ "productType": "Fabric",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+ },
+ "channel": "youtube",
+ "caption": "Bahama Cloth II in Medium Celadon | Plains Fabric",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "videoUrl": null,
+ "suggestedSlot": "12:00",
+ "scheduledAt": null,
+ "privacy": "public",
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": {
+ "description": "Explore the gentle sheen and soft hand of Bahama Cloth II in Medium Celadon. A textile that breathes and adds warmth to any room."
+ }
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866951958579-tiktok",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:36.360Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866951958579",
+ "dwSku": "",
+ "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+ "title": "Bahama Cloth II, Medium Celadon | Plains",
+ "vendor": "Plains",
+ "productType": "Fabric",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+ },
+ "channel": "tiktok",
+ "caption": "Soft textures, warm tones, and quiet luxury. #BahamaClothII",
+ "hashtags": [
+ "#PlainsDesign",
+ "#FabricLuxury",
+ "#InteriorTextures",
+ "#MediumCeladon",
+ "#Wallcovering"
+ ],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "videoUrl": null,
+ "suggestedSlot": "18:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "gated",
+ "gateReason": "App Review pending — posts private-only (SELF_ONLY)",
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866951958579-threads",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:50:36.361Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866951958579",
+ "dwSku": "",
+ "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+ "title": "Bahama Cloth II, Medium Celadon | Plains",
+ "vendor": "Plains",
+ "productType": "Fabric",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+ },
+ "channel": "threads",
+ "caption": "Bahama Cloth II in Medium Celadon from Plains is a textile that moves with light and offers a soft, tactile experience. Ideal for creating depth and warmth in any room.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+ "videoUrl": null,
+ "suggestedSlot": "12:30",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "gated",
+ "gateReason": "App Review pending — live publish requires Meta approval",
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866357973043-instagram",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:51:03.670Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866357973043",
+ "dwSku": "",
+ "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+ "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+ "vendor": "Tres Tintas",
+ "productType": "Wallcoverings",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+ },
+ "channel": "instagram",
+ "caption": "Croci PU2909-2 from Tres Tintas brings a touch of elegance with its soft, textured finish and subtle movement in the light. A perfect addition to any room that demands sophistication.",
+ "hashtags": [
+ "#CrociPU2909-2",
+ "#TresTintas",
+ "#NonWovenWallcovering",
+ "#LuxuryDesign",
+ "#MadeInSpain",
+ "#Wallcovering"
+ ],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "videoUrl": null,
+ "suggestedSlot": "11:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866357973043-facebook",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:51:03.672Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866357973043",
+ "dwSku": "",
+ "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+ "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+ "vendor": "Tres Tintas",
+ "productType": "Wallcoverings",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+ },
+ "channel": "facebook",
+ "caption": "The Croci PU2909-2 wallcovering from Tres Tintas offers a refined texture and a depth of character that invites closer inspection. Its understated design makes it a choice for any interior space.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "videoUrl": null,
+ "suggestedSlot": "09:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866357973043-bluesky",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:51:03.673Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866357973043",
+ "dwSku": "",
+ "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+ "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+ "vendor": "Tres Tintas",
+ "productType": "Wallcoverings",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+ },
+ "channel": "bluesky",
+ "caption": "Croci PU2909-2 from Tres Tintas—textured, subtle, and full of quiet sophistication.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "videoUrl": null,
+ "suggestedSlot": "10:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866357973043-youtube",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:51:03.674Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866357973043",
+ "dwSku": "",
+ "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+ "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+ "vendor": "Tres Tintas",
+ "productType": "Wallcoverings",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+ },
+ "channel": "youtube",
+ "caption": "Croci PU2909-2 Wallcovering Close-Up",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "videoUrl": null,
+ "suggestedSlot": "12:00",
+ "scheduledAt": null,
+ "privacy": "public",
+ "status": "suggested",
+ "gateReason": null,
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": {
+ "description": "Explore the refined texture and depth of the Croci PU2909-2 wallcovering from Tres Tintas. Its subtle movement in the light adds a layer of sophistication to any space."
+ }
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866357973043-tiktok",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:51:03.675Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866357973043",
+ "dwSku": "",
+ "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+ "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+ "vendor": "Tres Tintas",
+ "productType": "Wallcoverings",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+ },
+ "channel": "tiktok",
+ "caption": "Croci PU2909-2 from Tres Tintas—textured, refined, and full of quiet elegance.",
+ "hashtags": [
+ "#TresTintas",
+ "#Wallcovering",
+ "#NonWoven",
+ "#LuxuryDesign",
+ "#MadeInSpain"
+ ],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "videoUrl": null,
+ "suggestedSlot": "18:00",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "gated",
+ "gateReason": "App Review pending — posts private-only (SELF_ONLY)",
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ },
+ {
+ "id": "eng-20260721-gid-shopify-Product-7866357973043-threads",
+ "date": "2026-07-21",
+ "createdAt": "2026-07-21T21:51:03.676Z",
+ "product": {
+ "shopifyId": "gid://shopify/Product/7866357973043",
+ "dwSku": "",
+ "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+ "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+ "vendor": "Tres Tintas",
+ "productType": "Wallcoverings",
+ "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+ },
+ "channel": "threads",
+ "caption": "The Croci PU2909-2 wallcovering from Tres Tintas invites you to experience a blend of texture, light, and movement. It's a design that speaks softly but leaves a lasting impression.",
+ "hashtags": [],
+ "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+ "videoUrl": null,
+ "suggestedSlot": "12:30",
+ "scheduledAt": null,
+ "privacy": null,
+ "status": "gated",
+ "gateReason": "App Review pending — live publish requires Meta approval",
+ "source": {
+ "llm": "qwen3:14b@mac2",
+ "cost": 0
+ },
+ "attempts": 0,
+ "lastError": null,
+ "outboxId": null,
+ "postedAt": null,
+ "detail": null
+ }
+]
\ No newline at end of file
diff --git a/modules/engine/candidates.js b/modules/engine/candidates.js
new file mode 100644
index 0000000..7c0de42
--- /dev/null
+++ b/modules/engine/candidates.js
@@ -0,0 +1,185 @@
+// Engine candidates — pick the day's candidate products from the local dw_unified
+// Postgres mirror (READ-ONLY). Feeds generate.js. Everything here degrades
+// gracefully: a missing pg module, an unreachable DB, or an empty result all
+// resolve to [] rather than throwing, so the daily engine never crashes the MCC.
+//
+// Pipeline: SQL pull (recent ACTIVE, published, imaged products) → JS filters
+// (a) leak-deny drop, (b) Kravet-family drop, (c) no-repeat-window drop,
+// (d) variety pick (prefer unseen vendor + unseen product_type) → N products.
+const store = require('./store');
+const config = require('./config');
+const { isLeaky } = require('../../lib/leak-deny');
+const { isKravet } = require('../../lib/kravet.js');
+
+// ── Postgres (lazy; dw_unified is canonical on Kamatera, /tmp mirror locally) ──
+// Mirrors modules/calendars/lib.js: env-first, DATABASE_URL wins, else /tmp
+// socket to the dw_unified mirror. require('pg') is inside a try/catch so a host
+// without the pg module simply yields no candidates instead of crashing mount.
+let _pool = null;
+let _pgUnavailable = false;
+function pool() {
+ if (_pool) return _pool;
+ if (_pgUnavailable) return null;
+ let Pool;
+ try { ({ Pool } = require('pg')); }
+ catch (e) { console.error('[engine candidates] pg module missing —', e.message); _pgUnavailable = true; return null; }
+ try {
+ _pool = process.env.DATABASE_URL
+ ? new Pool({ connectionString: process.env.DATABASE_URL, max: 3 })
+ : new Pool({
+ host: process.env.PGHOST || '/tmp',
+ database: process.env.PGDATABASE || 'dw_unified',
+ user: process.env.PGUSER || process.env.USER || 'stevestudio2',
+ max: 3,
+ });
+ _pool.on('error', (e) => console.error('[engine candidates pool]', e.message)); // log, never crash the MCC
+ return _pool;
+ } catch (e) {
+ console.error('[engine candidates] pool init failed —', e.message);
+ _pgUnavailable = true;
+ return null;
+ }
+}
+
+// Base query — most-recent ACTIVE, online-store-published, imaged products within
+// a lookback window. The interval days are a sanitized integer interpolated into
+// the SQL (pg can't parameterize an interval literal); we hard-coerce to a small
+// positive integer so nothing user-derived reaches the query text.
+function buildSql(lookbackDays) {
+ const days = Math.min(3650, Math.max(1, parseInt(lookbackDays, 10) || 14));
+ return `
+ SELECT shopify_id, dw_sku, title, vendor, product_type, image_url, handle, tags,
+ created_at_shopify
+ FROM shopify_products
+ WHERE status = 'ACTIVE'
+ AND online_store_published = true
+ AND image_url IS NOT NULL
+ AND created_at_shopify >= now() - interval '${days} days'
+ ORDER BY created_at_shopify DESC
+ LIMIT 200`;
+}
+
+// Escalation ladder — config.lookbackDays is the PREFERENCE/floor. If the primary
+// window yields nothing (recent products simply haven't landed — the newest
+// published+imaged SKU can be weeks old), widen the window so the daily engine
+// never silently produces zero posts. DTD verdict (2026-07-21): resilient widen.
+function lookbackLadder(base) {
+ const b = Math.max(1, parseInt(base, 10) || 14);
+ const ladder = [b, 30, 60, 90, 180];
+ // de-dupe + keep strictly increasing from the base
+ return [...new Set(ladder.filter(d => d >= b))].sort((a, c) => a - c);
+}
+
+// Parse a PG text[] / CSV tags column into a clean string[] (same handling as
+// modules/calendars/lib.js parseTags).
+function parseTags(raw) {
+ if (!raw) return [];
+ if (Array.isArray(raw)) return raw.map(t => String(t).trim()).filter(Boolean);
+ let s = String(raw).trim();
+ if (s.startsWith('{') && s.endsWith('}')) s = s.slice(1, -1);
+ const out = []; let cur = '', inq = false;
+ for (const ch of s) {
+ if (ch === '"') { inq = !inq; continue; }
+ if (ch === ',' && !inq) { out.push(cur); cur = ''; continue; }
+ cur += ch;
+ }
+ if (cur) out.push(cur);
+ return out.map(t => t.trim()).filter(Boolean);
+}
+
+// (c) Build the set of product identifiers already queued within noRepeatDays so
+// we never re-feature the same product. Scans the existing queue's item.product.
+function recentlyFeaturedKeys(noRepeatDays) {
+ const days = Math.max(0, parseInt(noRepeatDays, 10) || 0);
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
+ const keys = new Set();
+ for (const it of store.load()) {
+ const t = new Date(it.createdAt || `${it.date}T00:00:00`).getTime();
+ if (!Number.isFinite(t) || t < cutoff) continue;
+ const p = it.product || {};
+ if (p.dwSku) keys.add(`sku:${String(p.dwSku).toLowerCase()}`);
+ if (p.shopifyId) keys.add(`sid:${String(p.shopifyId)}`);
+ }
+ return keys;
+}
+
+function seenKeyOf(row) {
+ const out = [];
+ if (row.dw_sku) out.push(`sku:${String(row.dw_sku).toLowerCase()}`);
+ if (row.shopify_id) out.push(`sid:${String(row.shopify_id)}`);
+ return out;
+}
+
+// (d) Variety pick — greedy: at each step prefer a row whose vendor AND
+// product_type are both still unseen, then unseen vendor, then unseen type, then
+// anything. Preserves the recency ORDER within each preference tier.
+function pickVariety(rows, n) {
+ const chosen = [];
+ const seenVendor = new Set();
+ const seenType = new Set();
+ const remaining = rows.slice();
+ const vendorOf = r => String(r.vendor || '').trim().toLowerCase();
+ const typeOf = r => String(r.product_type || '').trim().toLowerCase();
+
+ while (chosen.length < n && remaining.length) {
+ let idx = remaining.findIndex(r => !seenVendor.has(vendorOf(r)) && !seenType.has(typeOf(r)));
+ if (idx < 0) idx = remaining.findIndex(r => !seenVendor.has(vendorOf(r)));
+ if (idx < 0) idx = remaining.findIndex(r => !seenType.has(typeOf(r)));
+ if (idx < 0) idx = 0;
+ const [r] = remaining.splice(idx, 1);
+ seenVendor.add(vendorOf(r));
+ seenType.add(typeOf(r));
+ chosen.push(r);
+ }
+ return chosen;
+}
+
+// pickCandidates() — the day's products. Never throws; [] on any failure.
+async function pickCandidates() {
+ const cfg = config.getConfig();
+ const p = pool();
+ if (!p) return []; // pg module / DB unavailable — degrade gracefully
+
+ let rows = [];
+ try {
+ // Walk the lookback ladder — stop at the first window that yields rows that
+ // survive the leak + Kravet + no-repeat filters.
+ const noRepeat = recentlyFeaturedKeys(cfg.noRepeatDays);
+ for (const days of lookbackLadder(cfg.lookbackDays)) {
+ const res = await p.query(buildSql(days));
+ const filtered = res.rows.filter(r => {
+ // (a) leak-deny — never surface an upstream private-label name
+ if (isLeaky(r.vendor) || isLeaky(r.title)) return false;
+ // (b) Kravet-family — brand-controlled imagery is hard-blocked from reposting
+ if (isKravet(r.vendor, r.title)) return false;
+ // (c) no-repeat window — skip products already queued recently
+ if (seenKeyOf(r).some(k => noRepeat.has(k))) return false;
+ return true;
+ });
+ if (filtered.length) { rows = filtered; break; }
+ }
+ } catch (e) {
+ console.error('[engine candidates] query failed —', e.message);
+ return [];
+ }
+
+ if (!rows.length) return [];
+
+ // (d) variety pick down to productsPerDay
+ const n = Math.max(1, parseInt(cfg.productsPerDay, 10) || 3);
+ const picked = pickVariety(rows, n);
+
+ return picked.map(r => ({
+ shopifyId: r.shopify_id != null ? String(r.shopify_id) : '',
+ dwSku: r.dw_sku || '',
+ handle: r.handle || '',
+ title: r.title || '',
+ vendor: r.vendor || '',
+ productType: r.product_type || '',
+ imageUrl: r.image_url || '',
+ productUrl: `https://designerwallcoverings.com/products/${r.handle || ''}`,
+ tags: parseTags(r.tags),
+ }));
+}
+
+module.exports = { pickCandidates };
diff --git a/modules/engine/generate.js b/modules/engine/generate.js
new file mode 100644
index 0000000..a3dfb28
--- /dev/null
+++ b/modules/engine/generate.js
@@ -0,0 +1,117 @@
+// Engine generator — build today's suggestion queue. Idempotent: if today already
+// has items it returns them unchanged. Otherwise it picks candidate products,
+// writes per-channel captions, and upserts one queue item per product×channel.
+// Never throws on empty candidates (pg down / no rows) — returns [].
+const store = require('./store');
+const config = require('./config');
+const { pickCandidates } = require('./candidates');
+const { captionsFor } = require('./llm');
+
+const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads'];
+
+// local YYYY-MM-DD — must match modules/engine/index.js today() (local, not UTC).
+function today() {
+ const d = new Date();
+ const p = n => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
+}
+
+// compact yyyymmdd for the id prefix
+function ymd(dateStr) {
+ return String(dateStr).replace(/-/g, '');
+}
+
+// sanitize the sku/id segment of the id so it's a safe, stable token
+function idSegment(product) {
+ const raw = product.dwSku || product.shopifyId || 'unknown';
+ return String(raw).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
+}
+
+// build one queue item for a product×channel per the documented store.js shape
+function buildItem(product, channel, caps, dateStr) {
+ const cfg = config.getConfig();
+ const GATED = config.GATED_CHANNELS;
+ const slots = cfg.slots || {};
+ const suggestedSlot = (slots[channel] && slots[channel][0]) || null;
+
+ const ch = caps[channel] || {};
+ let caption, hashtags = [], description = null;
+
+ if (channel === 'youtube') {
+ // youtube: title goes in caption, description in its own field
+ caption = ch.title || '';
+ description = ch.description || null;
+ } else {
+ caption = ch.caption || '';
+ hashtags = Array.isArray(ch.hashtags) ? ch.hashtags : [];
+ }
+
+ return {
+ id: `eng-${ymd(dateStr)}-${idSegment(product)}-${channel}`,
+ date: dateStr,
+ createdAt: new Date().toISOString(),
+ product: {
+ shopifyId: product.shopifyId || '',
+ dwSku: product.dwSku || '',
+ handle: product.handle || '',
+ title: product.title || '',
+ vendor: product.vendor || '',
+ productType: product.productType || '',
+ imageUrl: product.imageUrl || '',
+ productUrl: product.productUrl || '',
+ },
+ channel,
+ caption,
+ hashtags,
+ mediaUrl: product.imageUrl || null,
+ videoUrl: null,
+ suggestedSlot,
+ scheduledAt: null,
+ privacy: channel === 'youtube' ? 'public' : null,
+ status: GATED[channel] ? 'gated' : 'suggested',
+ gateReason: GATED[channel] || null,
+ source: { llm: caps._source || 'template', cost: 0 },
+ attempts: 0,
+ lastError: null,
+ outboxId: null,
+ postedAt: null,
+ detail: description !== null ? { description } : null,
+ };
+}
+
+// generateToday() — idempotent daily generation. Returns today's items.
+async function generateToday() {
+ const dateStr = today();
+
+ // idempotent: today already generated → return as-is
+ const existing = store.load().filter(it => it.date === dateStr);
+ if (existing.length) return existing;
+
+ let products = [];
+ try {
+ products = await pickCandidates();
+ } catch (e) {
+ console.error('[engine generate] pickCandidates failed —', e.message);
+ products = [];
+ }
+ if (!products.length) return []; // pg down / empty — no throw
+
+ // captions are heavy (local LLM) — run sequentially, one product at a time
+ for (const product of products) {
+ let caps;
+ try {
+ caps = await captionsFor(product);
+ } catch (e) {
+ console.error('[engine generate] captionsFor failed —', e.message);
+ continue; // llm is never-blocking, but be defensive
+ }
+ for (const channel of CHANNELS) {
+ const item = buildItem(product, channel, caps, dateStr);
+ store.upsert(item); // id-idempotent
+ }
+ }
+
+ return store.load().filter(it => it.date === dateStr);
+}
+
+module.exports = { generateToday };
diff --git a/modules/engine/index.js b/modules/engine/index.js
index 5a0dc88..f132a92 100644
--- a/modules/engine/index.js
+++ b/modules/engine/index.js
@@ -55,9 +55,15 @@ module.exports = {
const todays = items.filter(it => it.date === d);
const counts = { suggested: 0, approved: 0, posting: 0, posted: 0, failed: 0, skipped: 0, gated: 0 };
for (const it of todays) if (counts[it.status] != null) counts[it.status]++;
+ // Pull the real last-tick timestamp from the scheduler if it's built.
+ let lastTick = null;
+ try {
+ const scheduler = require('./scheduler');
+ if (scheduler && typeof scheduler.lastTick === 'function') lastTick = scheduler.lastTick();
+ } catch { /* scheduler not built yet — null */ }
res.json({
scheduler: process.env.ENGINE_SCHEDULER !== 'off',
- lastTick: null, // scheduler agent will wire the real last-tick timestamp
+ lastTick,
counts,
total: items.length,
});
diff --git a/modules/engine/llm.js b/modules/engine/llm.js
new file mode 100644
index 0000000..d6ce64f
--- /dev/null
+++ b/modules/engine/llm.js
@@ -0,0 +1,233 @@
+// Engine LLM — never-blocking local caption writer. Given a product, returns
+// per-channel captions in the DW brand voice. Tries the local Ollama primary
+// (Mac2), then the fallback (Mac1), then a deterministic template. EVERY caption
+// is leak-cleaned + trimmed to the channel limit regardless of source, so output
+// is always safe and non-empty.
+const { clean } = require('../../lib/leak-deny');
+const { CHANNEL_LIMITS } = require('./config');
+
+const PRIMARY = process.env.OLLAMA_PRIMARY || 'http://127.0.0.1:11434';
+const FALLBACK = process.env.OLLAMA_FALLBACK || 'http://192.168.1.133:11434';
+const MODEL = process.env.ENGINE_MODEL || 'qwen3:14b';
+const TIMEOUT_MS = 45000;
+
+const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads'];
+
+// ── DW brand-voice system prompt ─────────────────────────────────────────────
+const SYSTEM = `You are the social copywriter for Designer Wallcoverings, a luxury wallcoverings retailer.
+Voice rules (HARD):
+- Say "wallcovering" — never "wallpaper" — when speaking generically.
+- No generic superlatives: never "best", "amazing", "stunning", "gorgeous", "must-have".
+- Use sensory, material language: texture, light, movement, hand, depth, sheen.
+- NEVER mention price, cost, sale, or discount.
+Per-channel voice:
+- instagram: polished and editorial, 5-8 hashtags.
+- facebook: warm and conversational, a full sentence or two.
+- bluesky: witty and spare, <= 240 characters, no hashtags needed.
+- youtube: a video TITLE (<= 90 characters) plus a 2-sentence description.
+- tiktok: punchy and immediate, <= 120 characters, 3-5 hashtags.
+- threads: conversational and human, <= 400 characters.
+Return ONLY valid JSON, no prose, no code fences.`;
+
+function userPrompt(product) {
+ const p = {
+ title: product.title || '',
+ vendor: product.vendor || '',
+ productType: product.productType || '',
+ tags: Array.isArray(product.tags) ? product.tags.slice(0, 12) : [],
+ };
+ return `Write social captions for this Designer Wallcoverings product:
+${JSON.stringify(p, null, 2)}
+
+Output EXACTLY this JSON shape (fill every field, obey the per-channel voice + limits):
+{
+ "instagram": { "caption": "...", "hashtags": ["...", "..."] },
+ "facebook": { "caption": "..." },
+ "bluesky": { "caption": "..." },
+ "youtube": { "title": "...", "description": "..." },
+ "tiktok": { "caption": "...", "hashtags": ["...", "..."] },
+ "threads": { "caption": "..." }
+}`;
+}
+
+// ── Ollama chat call ─────────────────────────────────────────────────────────
+async function ollamaChat(base, product) {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(`${base}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal: ctrl.signal,
+ body: JSON.stringify({
+ model: MODEL,
+ think: false,
+ stream: false,
+ format: 'json',
+ options: { temperature: 0.8, num_predict: 900 },
+ messages: [
+ { role: 'system', content: SYSTEM },
+ { role: 'user', content: userPrompt(product) },
+ ],
+ }),
+ });
+ if (!res.ok) throw new Error(`ollama ${base} HTTP ${res.status}`);
+ const data = await res.json();
+ const content = data && data.message && data.message.content;
+ if (!content) throw new Error(`ollama ${base} empty content`);
+ return parseModelJson(content);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+// Defensive JSON parse — strip code fences, pull the first {...} block.
+function parseModelJson(raw) {
+ let s = String(raw).trim();
+ s = s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
+ try { return JSON.parse(s); } catch {}
+ const start = s.indexOf('{');
+ const end = s.lastIndexOf('}');
+ if (start >= 0 && end > start) {
+ try { return JSON.parse(s.slice(start, end + 1)); } catch {}
+ }
+ throw new Error('unparseable model JSON');
+}
+
+// ── deterministic template fallback (never empty) ────────────────────────────
+function hashtagsFor(product) {
+ const tags = [];
+ const push = (t) => {
+ const h = '#' + String(t).replace(/[^A-Za-z0-9]+/g, '');
+ if (h.length > 1 && !tags.includes(h)) tags.push(h);
+ };
+ push('DesignerWallcoverings');
+ if (product.productType) push(product.productType.replace(/\s+/g, ''));
+ for (const t of (product.tags || [])) {
+ const val = t.includes(':') ? t.split(':').slice(1).join(':').trim() : t;
+ if (val) push(val);
+ if (tags.length >= 8) break;
+ }
+ push('InteriorDesign');
+ push('LuxuryInteriors');
+ return tags.slice(0, 8);
+}
+
+function templateCaptions(product) {
+ const title = product.title || 'This design';
+ const vendor = product.vendor || '';
+ const type = product.productType || 'wallcovering';
+ const by = vendor ? ` by ${vendor}` : '';
+ const tags = hashtagsFor(product);
+
+ return {
+ instagram: {
+ caption: `New arrival — ${title}${by}. ${type} with presence: texture, light, movement.`,
+ hashtags: tags,
+ },
+ facebook: {
+ caption: `Just in: ${title}${by}. A ${type} that plays with light and texture across the wall — worth a closer look.`,
+ },
+ bluesky: {
+ caption: `${title}${by} — a ${type} built on texture, depth, and the way light moves across it.`,
+ },
+ youtube: {
+ title: `${title}${by} — ${type}`.slice(0, 90),
+ description: `A closer look at ${title}${by}, a ${type} where texture and light do the work. See how it shifts across the wall.`,
+ },
+ tiktok: {
+ caption: `${title}${by} — texture you can feel.`,
+ hashtags: tags.slice(0, 5),
+ },
+ threads: {
+ caption: `New in: ${title}${by}. A ${type} that reads differently as the light moves — texture, depth, hand. What room would you put it in?`,
+ },
+ };
+}
+
+// ── post-processing: strip quotes, leak-clean, trim to channel limit ─────────
+function stripQuotes(s) {
+ return String(s == null ? '' : s).trim().replace(/^["'“”]+|["'“”]+$/g, '').trim();
+}
+
+// trim to `limit` chars at a word boundary (no mid-word cut); leak-cleaned first.
+function tidy(text, limit) {
+ let s = clean(stripQuotes(text));
+ if (!limit || s.length <= limit) return s;
+ let cut = s.slice(0, limit);
+ const lastSpace = cut.lastIndexOf(' ');
+ if (lastSpace > limit * 0.5) cut = cut.slice(0, lastSpace);
+ return cut.trim();
+}
+
+function tidyHashtags(list) {
+ if (!Array.isArray(list)) return [];
+ return list
+ .map(h => clean(stripQuotes(h)).replace(/\s+/g, ''))
+ .map(h => (h && !h.startsWith('#') ? '#' + h : h))
+ .filter(h => h && h.length > 1);
+}
+
+// Merge model output over the template so a missing channel/field is always
+// filled, then post-process EVERY field regardless of source.
+function finalize(modelOut, product) {
+ const tmpl = templateCaptions(product);
+ const m = modelOut && typeof modelOut === 'object' ? modelOut : {};
+ const pick = (ch, field) => {
+ const v = m[ch] && m[ch][field];
+ return (v != null && String(v).trim()) ? v : tmpl[ch][field];
+ };
+ const L = CHANNEL_LIMITS;
+
+ return {
+ instagram: {
+ caption: tidy(pick('instagram', 'caption'), L.instagram),
+ hashtags: tidyHashtags((m.instagram && m.instagram.hashtags) || tmpl.instagram.hashtags),
+ },
+ facebook: {
+ caption: tidy(pick('facebook', 'caption'), L.facebook),
+ },
+ bluesky: {
+ caption: tidy(pick('bluesky', 'caption'), L.bluesky),
+ },
+ youtube: {
+ title: tidy(pick('youtube', 'title'), L.youtube),
+ description: tidy(pick('youtube', 'description'), 5000),
+ },
+ tiktok: {
+ caption: tidy(pick('tiktok', 'caption'), L.tiktok),
+ hashtags: tidyHashtags((m.tiktok && m.tiktok.hashtags) || tmpl.tiktok.hashtags).slice(0, 5),
+ },
+ threads: {
+ caption: tidy(pick('threads', 'caption'), L.threads),
+ },
+ };
+}
+
+// captionsFor(product) — the public entry. Primary → fallback → template.
+// Always resolves; never rejects.
+async function captionsFor(product) {
+ let modelOut = null;
+ let source = 'template';
+
+ try {
+ modelOut = await ollamaChat(PRIMARY, product);
+ source = `${MODEL}@mac2`;
+ } catch (e1) {
+ console.error('[engine llm] primary failed —', e1.message);
+ try {
+ modelOut = await ollamaChat(FALLBACK, product);
+ source = `${MODEL}@mac1`;
+ } catch (e2) {
+ console.error('[engine llm] fallback failed —', e2.message);
+ modelOut = null;
+ source = 'template';
+ }
+ }
+
+ const out = finalize(modelOut, product);
+ out._source = source;
+ return out;
+}
+
+module.exports = { captionsFor };
diff --git a/modules/engine/scheduler.js b/modules/engine/scheduler.js
new file mode 100644
index 0000000..dddd8a6
--- /dev/null
+++ b/modules/engine/scheduler.js
@@ -0,0 +1,177 @@
+// Engine scheduler (agent 3). Fires APPROVED, due suggestions through the same
+// live rails as /publish (channels.postLive). Safe-by-construction:
+// • ENGINE_SCHEDULER=off → never starts the interval (dev / test / staging).
+// • Every state change goes through store.transition() — the compare-and-set
+// that makes double-posting impossible (two ticks racing the same item: one
+// wins the approved→posting claim, the loser's transition returns null).
+// • Items are processed SEQUENTIALLY (never in parallel) to avoid platform
+// rate spikes.
+// • attempts is capped at 1: a failed post is NEVER auto-refired — Steve must
+// re-approve. This is deliberate. A post that threw/failed may still have
+// LANDED on the platform (the adapter succeeded but the response was lost),
+// so silently retrying risks a duplicate live post.
+// • CRASH SWEEP on start: any item stuck in 'posting' (the process died
+// mid-post) is moved to 'failed' with a verify-first note — NEVER
+// auto-refired, for the same land-before-crash reason.
+const store = require('./store');
+
+let _timer = null;
+let _started = false;
+let _ticking = false;
+let _lastTick = null;
+
+// ── logging ──────────────────────────────────────────────────────────────────
+// Matches the repo's bracketed-prefix console style ([mcc]/[engine]).
+const log = (...a) => console.log('[engine]', ...a);
+
+// summarize(detail) — best-effort one-line error from an adapter result array
+// when postLive returned no top-level `error` string (it doesn't on the happy
+// path, but a per-adapter failure lives inside detail[].error).
+function summarize(detail) {
+ try {
+ if (Array.isArray(detail)) {
+ const errs = detail.map(d => d && d.error).filter(Boolean);
+ if (errs.length) return errs.join('; ');
+ }
+ if (detail && typeof detail === 'object' && detail.error) return String(detail.error);
+ } catch { /* fall through */ }
+ return 'post failed';
+}
+
+// ── crash sweep ────────────────────────────────────────────────────────────────
+// Any item left in 'posting' means the process died between the approved→posting
+// claim and the posting→posted|failed resolve. The post MAY have landed, so we
+// move it to 'failed' (never auto-refire) and flag it for manual verification.
+function _sweep() {
+ let swept = 0;
+ for (const it of store.load()) {
+ if (it.status === 'posting') {
+ const done = store.transition(it.id, 'posting', 'failed', {
+ lastError: 'interrupted mid-post — verify on platform before re-approving',
+ });
+ if (done) { swept++; log('crash-sweep: posting →', it.id, 'moved to failed (verify before re-approve)'); }
+ }
+ }
+ if (swept) log(`crash-sweep swept ${swept} interrupted item(s)`);
+ return swept;
+}
+
+// ── content mapping ────────────────────────────────────────────────────────────
+// Build the channels.postLive `content` object from a queue item.
+// Caption = the caption text, then (only if hashtags exist) a blank line + the
+// hashtags joined by a single space.
+function buildContent(item) {
+ const tags = Array.isArray(item.hashtags) ? item.hashtags.filter(Boolean) : [];
+ const caption = tags.length
+ ? [item.caption, tags.join(' ')].filter(Boolean).join('\n\n')
+ : (item.caption || '');
+ return {
+ caption,
+ mediaUrl: item.mediaUrl,
+ videoUrl: item.videoUrl,
+ privacy: item.privacy || undefined,
+ title: item.channel === 'youtube' ? item.caption : undefined,
+ description: item.description || undefined,
+ };
+}
+
+// dueApproved() — items ready to fire this tick: approved, scheduled in the past,
+// and under the attempts cap (never re-fire a failed post).
+function dueApproved(items) {
+ const now = Date.now();
+ return items.filter(it =>
+ it.status === 'approved' &&
+ it.scheduledAt &&
+ Number.isFinite(Date.parse(it.scheduledAt)) &&
+ Date.parse(it.scheduledAt) <= now &&
+ (it.attempts || 0) < 1
+ );
+}
+
+// ── tick ────────────────────────────────────────────────────────────────────────
+async function _tickOnce() {
+ _lastTick = new Date().toISOString();
+ const items = store.load();
+ const due = dueApproved(items);
+ if (!due.length) return;
+
+ // require channels lazily + freshly so a test can monkey-patch postLive.
+ const channels = require('../channels');
+
+ for (const item of due) {
+ // (1) CLAIM: compare-and-set approved→posting. If null, another tick or a
+ // manual action already took it — skip.
+ const claimed = store.transition(item.id, 'approved', 'posting', {
+ attempts: (item.attempts || 0) + 1,
+ });
+ if (!claimed) continue;
+
+ // (2) POST — wrapped so one item's failure never kills the tick.
+ try {
+ const content = buildContent(claimed);
+ const res = await channels.postLive(claimed.channel, content, {});
+ const ok = !!(res && res.ok);
+ store.transition(claimed.id, 'posting', ok ? 'posted' : 'failed', {
+ detail: (res && res.detail) || null,
+ outboxId: (res && res.outboxId) || null,
+ lastError: ok ? null : ((res && res.error) || summarize(res && res.detail)),
+ postedAt: ok ? new Date().toISOString() : null,
+ });
+ if (ok) log('posted', claimed.id, '→ outbox', (res && res.outboxId) || '(none)');
+ else log('FAILED', claimed.id + ':', (res && res.error) || summarize(res && res.detail));
+ } catch (err) {
+ const msg = (err && err.message) || String(err);
+ store.transition(claimed.id, 'posting', 'failed', { lastError: msg, postedAt: null });
+ log('FAILED', claimed.id + ':', msg);
+ }
+ }
+}
+
+// tick() — the interval body: re-entrancy guard so a slow tick never overlaps.
+async function tick() {
+ if (_ticking) return;
+ _ticking = true;
+ try {
+ await _tickOnce();
+ } catch (e) {
+ log('tick error:', (e && e.message) || String(e));
+ } finally {
+ _ticking = false;
+ }
+}
+
+// ── lifecycle ────────────────────────────────────────────────────────────────
+function start() {
+ if (process.env.ENGINE_SCHEDULER === 'off') {
+ log('scheduler disabled (ENGINE_SCHEDULER=off) — not starting');
+ return;
+ }
+ if (_started) return;
+ _started = true;
+
+ // Crash sweep BEFORE the interval so interrupted items are resolved up front.
+ _sweep();
+
+ // Steady 60s cadence, plus one prompt tick 5s after boot. Both unref()'d so
+ // they never keep the process alive on their own (mirrors social/index.js).
+ _timer = setInterval(() => { tick().catch(() => {}); }, 60 * 1000);
+ if (_timer.unref) _timer.unref();
+
+ const kick = setTimeout(() => { tick().catch(() => {}); }, 5 * 1000);
+ if (kick.unref) kick.unref();
+
+ log('scheduler started (60s cadence)');
+}
+
+function getState() {
+ return { started: _started, lastTick: _lastTick, ticking: _ticking };
+}
+
+module.exports = {
+ start,
+ getState,
+ lastTick: () => _lastTick,
+ // exported for tests only:
+ _tickOnce,
+ _sweep,
+};
diff --git a/public/panels/engine.html b/public/panels/engine.html
new file mode 100644
index 0000000..fd5838d
--- /dev/null
+++ b/public/panels/engine.html
@@ -0,0 +1,123 @@
+<!-- Engine panel — daily AI-suggested social posts, grouped by product, with a
+ per-channel approve → schedule → post flow. Markup only; engine.js drives it
+ and registers window.MCC_PANELS['engine'] = { init(root) }. -->
+
+<!-- 1 ── Header strip: scheduler dot + last tick · today's count chips · Generate + cost badge -->
+<div class="card" id="eng-header">
+ <div class="row" style="justify-content:space-between;align-items:center;row-gap:12px">
+ <div class="row" style="align-items:center;gap:16px;row-gap:8px">
+ <span class="eng-sched" id="eng-sched" title="scheduler status">
+ <span class="dot" id="eng-sched-dot"></span><span id="eng-sched-txt">scheduler…</span>
+ </span>
+ <span class="muted" id="eng-tick" style="font-size:12px"></span>
+ <span class="eng-chips" id="eng-chips"></span>
+ </div>
+ <div class="row" style="align-items:center;gap:10px">
+ <span class="pill eng-cost" id="eng-cost" title="captions generated by local qwen3:14b — no API cost">$0 local</span>
+ <button class="btn gold" id="eng-generate">⚡ Generate today</button>
+ </div>
+ </div>
+ <div id="eng-banner" class="eng-banner" hidden></div>
+</div>
+
+<!-- 2 ── Today's Suggestions (grouped by product) -->
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Today's Suggestions</h2>
+ <span class="muted" id="eng-today-count" style="font-size:12.5px"></span>
+ </div>
+ <div id="eng-today" style="margin-top:14px">
+ <div class="loading">Loading today's suggestions…</div>
+ </div>
+</div>
+
+<!-- 3 ── Pipeline strip: last 7 days from /queue, horizontal scroll -->
+<div class="card">
+ <div class="row" style="justify-content:space-between;align-items:baseline">
+ <h2 style="margin:0">Pipeline · last 7 days</h2>
+ <span class="muted" id="eng-pipe-count" style="font-size:12.5px"></span>
+ </div>
+ <div id="eng-pipeline" class="eng-pipe" style="margin-top:12px">
+ <div class="muted">Loading history…</div>
+ </div>
+</div>
+
+<style>
+ /* header */
+ #eng-header .eng-sched{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--ink)}
+ #eng-header .eng-sched .dot::before{margin-right:6px}
+ .eng-chips{display:inline-flex;gap:6px;flex-wrap:wrap}
+ .eng-chips .pill{background:var(--cream);color:var(--accent)}
+ .eng-chips .pill.on{background:#e3efe0;color:#3a6b3a}
+ .eng-chips .pill.warn{background:#f3e9e9;color:#9a5a5a}
+ .eng-chips .pill.blue{background:#eef3f8;color:#3a5a8a}
+ .eng-cost{background:#eef3f8;color:#3a5a8a;cursor:default}
+ .eng-cost.template{background:#f6f0e4;color:#7a5a2a}
+ .eng-banner{margin-top:12px;background:#f3e9e9;border:1px solid #e2c9c9;color:#8a4a4a;border-radius:10px;padding:9px 13px;font-size:12.5px}
+ .eng-banner.ok{background:#e9f3e9;border-color:#c9e2c9;color:#3a6b3a}
+
+ /* product cards */
+ .eng-prod{display:flex;gap:16px;border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;background:var(--paper)}
+ .eng-prod .thumb{flex:none;width:104px;height:104px;border-radius:9px;overflow:hidden;background:var(--cream);border:1px solid var(--line)}
+ .eng-prod .thumb a{display:block;width:100%;height:100%}
+ .eng-prod .thumb img{width:100%;height:100%;object-fit:cover;display:block}
+ .eng-prod .thumb .ph{width:100%;height:100%;display:grid;place-items:center;color:var(--mut);font-size:26px}
+ .eng-prod .body{flex:1;min-width:0}
+ .eng-prod .ptitle{font:600 15px/1.25 "Cormorant Garamond",Georgia,serif;margin:0}
+ .eng-prod .ptitle a{color:var(--ink);text-decoration:none}
+ .eng-prod .ptitle a:hover{text-decoration:underline}
+ .eng-prod .pmeta{font-size:11.5px;color:var(--mut);margin:2px 0 3px}
+ .eng-prod .pwhen{font-size:10.5px;color:var(--mut)}
+
+ /* channel tabs */
+ .eng-tabs{display:flex;gap:5px;flex-wrap:wrap;margin:10px 0 8px}
+ .eng-tab{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:8px;
+ padding:4px 9px;font-size:12px;font-weight:600;color:var(--ink);cursor:pointer;line-height:1.3}
+ .eng-tab:hover{background:var(--cream)}
+ .eng-tab.active{border-color:var(--ink);box-shadow:0 0 0 1px var(--ink) inset}
+ .eng-tab .ic{font-size:13px}
+ .eng-tab .st{font-size:9px;text-transform:uppercase;letter-spacing:.4px}
+ /* status coloring */
+ .eng-tab.s-approved{background:#eef3f8;border-color:#c3d4e8;color:#2f4f7a}
+ .eng-tab.s-posted{background:#e9f3e9;border-color:#c9e2c9;color:#3a6b3a}
+ .eng-tab.s-failed{background:#f6ecec;border-color:#e2c9c9;color:#9a4a4a}
+ .eng-tab.s-skipped{color:var(--mut);text-decoration:line-through;opacity:.7}
+ .eng-tab.s-gated{opacity:.5}
+ .eng-tab.s-posting{animation:eng-pulse 1s ease-in-out infinite}
+ @keyframes eng-pulse{0%,100%{opacity:1}50%{opacity:.45}}
+
+ /* active channel detail */
+ .eng-detail{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff}
+ .eng-detail .cap{font-size:13px;line-height:1.5;white-space:pre-wrap;color:var(--ink)}
+ .eng-detail .cap.muted{color:var(--mut)}
+ .eng-detail textarea{margin-top:2px;min-height:88px}
+ .eng-detail .tags{margin:8px 0 0;display:flex;gap:4px;flex-wrap:wrap}
+ .eng-detail .slot{font-size:11.5px;color:var(--mut);margin:8px 0 6px}
+ .eng-detail .slot b{color:var(--ink)}
+ .eng-detail .gate{background:#f6f0e4;border:1px solid #e6d6b8;color:#7a5a2a;border-radius:8px;padding:7px 10px;font-size:12px;margin-bottom:8px}
+ .eng-actions{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:6px}
+ .eng-actions .btn{padding:6px 12px;font-size:12.5px}
+ .eng-actions .msg{font-size:11.5px;color:var(--mut)}
+ .eng-sched-inline{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px;padding:8px;background:var(--cream);border-radius:8px}
+ .eng-sched-inline input,.eng-sched-inline select{width:auto;font-size:12px;padding:6px 8px}
+ .eng-sched-inline label{margin:0;font-size:11px}
+
+ /* pipeline strip */
+ .eng-pipe{display:flex;gap:10px;overflow-x:auto;padding-bottom:6px}
+ .eng-pipe::-webkit-scrollbar{height:8px}
+ .eng-pipe::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px}
+ .eng-pc{flex:none;width:150px;border:1px solid var(--line);border-radius:10px;background:#fff;overflow:hidden}
+ .eng-pc .pt{width:100%;height:82px;object-fit:cover;display:block;background:var(--cream)}
+ .eng-pc .pt.ph{display:grid;place-items:center;color:var(--mut);font-size:22px}
+ .eng-pc .meta{padding:7px 9px}
+ .eng-pc .meta .ch{font-size:12px;font-weight:600}
+ .eng-pc .meta .st{font-size:10px;margin-top:2px}
+ .eng-pc .meta .st.s-posted{color:#3a6b3a}
+ .eng-pc .meta .st.s-failed{color:#9a4a4a}
+ .eng-pc .meta .st.s-approved{color:#2f4f7a}
+ .eng-pc .meta .st.s-skipped{color:var(--mut);text-decoration:line-through}
+ .eng-pc .meta .tm{font-size:10px;color:var(--mut);margin-top:1px}
+
+ .eng-empty{color:var(--mut);text-align:center;padding:26px;font-size:13px}
+ .eng-empty .btn{margin-top:12px}
+</style>
diff --git a/public/panels/engine.js b/public/panels/engine.js
new file mode 100644
index 0000000..099550c
--- /dev/null
+++ b/public/panels/engine.js
@@ -0,0 +1,450 @@
+// Engine panel — daily AI-suggested social posts grouped BY PRODUCT, with a
+// per-channel approve → schedule → post flow, a header status strip, and a
+// last-7-days pipeline strip. Registers into the shell's MCC_PANELS map (app.js).
+// Dependency-free vanilla JS; every fetch is wrapped and API errors render an
+// inline banner (never a blank panel). Auto-refreshes /status + /today (cheap,
+// non-generating) every 30s and pauses when the tab is hidden.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['engine'] = {
+ init(root) {
+ const ORIGIN = location.origin;
+ const $ = s => root.querySelector(s);
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+
+ const CHAN = {
+ facebook: { icon: '📘', label: 'Facebook' },
+ instagram: { icon: '📷', label: 'Instagram' },
+ tiktok: { icon: '🎵', label: 'TikTok' },
+ youtube: { icon: '▶️', label: 'YouTube' },
+ bluesky: { icon: '🦋', label: 'Bluesky' },
+ threads: { icon: '🧵', label: 'Threads' },
+ };
+ const chanIcon = c => (CHAN[c] && CHAN[c].icon) || '•';
+ const chanLabel = c => (CHAN[c] && CHAN[c].label) || c;
+
+ // Local-day + local-time formatting (admin's timezone).
+ const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
+ const fmtTime = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); };
+ const pad = n => String(n).padStart(2, '0');
+ function todayISO() { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
+ // datetime-local value (no seconds, no offset) for a given day + HH:MM.
+ function slotLocalValue(dayISO, hhmm) {
+ const [h, m] = String(hhmm || '09:00').split(':');
+ return `${dayISO}T${pad(+h || 9)}:${pad(+m || 0)}`;
+ }
+ // Convert a datetime-local string back to a full ISO w/ the browser offset.
+ function localToISO(v) { if (!v) return null; const d = new Date(v); return isNaN(d) ? null : d.toISOString(); }
+
+ // ── wrapped fetch helpers ─────────────────────────────────────────────────
+ async function jget(url) {
+ const r = await fetch(ORIGIN + url, { credentials: 'same-origin' });
+ if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
+ return r.json();
+ }
+ async function jpost(url, body) {
+ const r = await fetch(ORIGIN + url, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin', body: JSON.stringify(body || {}),
+ });
+ let j = {}; try { j = await r.json(); } catch {}
+ if (!r.ok && !j.error) j.error = `HTTP ${r.status}`;
+ return j;
+ }
+
+ function banner(msg, ok) {
+ const el = $('#eng-banner'); if (!el) return;
+ if (!msg) { el.hidden = true; el.textContent = ''; return; }
+ el.hidden = false; el.textContent = msg;
+ el.className = 'eng-banner' + (ok ? ' ok' : '');
+ }
+
+ // ── state ─────────────────────────────────────────────────────────────────
+ const state = {
+ status: null,
+ items: [], // today's items
+ active: {}, // productKey → active channel id
+ editing: {}, // itemId → true while its textarea is open
+ generating: false,
+ };
+ const productKey = it => (it.product && (it.product.dwSku || it.product.shopifyId || it.product.handle || it.product.title)) || it.id;
+
+ // ── 1 · header ────────────────────────────────────────────────────────────
+ function renderHeader() {
+ const s = state.status || {};
+ const on = !!s.scheduler;
+ const dot = $('#eng-sched-dot'); if (dot) dot.className = 'dot ' + (on ? 'ok' : 'bad');
+ const txt = $('#eng-sched-txt'); if (txt) txt.textContent = on ? 'Scheduler on' : 'Scheduler off';
+ const tick = $('#eng-tick');
+ if (tick) tick.textContent = s.lastTick ? `last tick ${fmtTime(s.lastTick)}` : (on ? 'no tick yet' : '');
+ const c = s.counts || {};
+ const chips = [
+ { k: 'suggested', label: 'Suggested', n: c.suggested || 0, cls: '' },
+ { k: 'approved', label: 'Scheduled', n: c.approved || 0, cls: 'blue' },
+ { k: 'posted', label: 'Posted', n: c.posted || 0, cls: 'on' },
+ { k: 'failed', label: 'Failed', n: c.failed || 0, cls: (c.failed ? 'warn' : '') },
+ ];
+ if (c.gated) chips.push({ k: 'gated', label: 'Gated', n: c.gated, cls: 'warn' });
+ const chipsEl = $('#eng-chips');
+ if (chipsEl) chipsEl.innerHTML = chips.map(ch =>
+ `<span class="pill ${ch.cls}" title="${esc(ch.label)} today">${esc(ch.label)} ${ch.n}</span>`).join('');
+
+ // Cost badge — template if any visible item was written by the template engine.
+ const cost = $('#eng-cost');
+ if (cost) {
+ const anyTemplate = state.items.some(it => it.source && it.source.llm === 'template');
+ if (anyTemplate) {
+ cost.className = 'pill eng-cost template';
+ cost.textContent = '$0 template';
+ cost.title = 'captions written by the built-in template engine — no API cost';
+ } else {
+ cost.className = 'pill eng-cost';
+ cost.textContent = '$0 local';
+ cost.title = 'captions generated by local qwen3:14b — no API cost';
+ }
+ }
+ }
+
+ // ── 2 · today's suggestions (grouped by product) ──────────────────────────
+ function renderToday() {
+ const box = $('#eng-today');
+ const cnt = $('#eng-today-count');
+ if (!box) return;
+
+ if (state.generating) {
+ box.innerHTML = `<div class="eng-empty">
+ <div class="loading" style="padding:8px">Generating today's suggestions ($0 local model)…</div>
+ <div class="muted" style="font-size:12px">The local qwen3:14b caption pass can take 1–3 minutes on a cold start. This won't cost anything.</div>
+ </div>`;
+ if (cnt) cnt.textContent = '';
+ return;
+ }
+
+ const items = state.items.slice();
+ if (!items.length) {
+ box.innerHTML = `<div class="eng-empty">
+ No suggestions yet for today.
+ <div><button class="btn gold" id="eng-empty-gen">⚡ Generate today's suggestions</button></div>
+ </div>`;
+ const b = $('#eng-empty-gen'); if (b) b.onclick = doGenerate;
+ if (cnt) cnt.textContent = '';
+ return;
+ }
+
+ // group by product, preserving first-seen order.
+ const groups = new Map();
+ for (const it of items) {
+ const k = productKey(it);
+ if (!groups.has(k)) groups.set(k, []);
+ groups.get(k).push(it);
+ }
+ if (cnt) cnt.textContent = `${groups.size} product${groups.size === 1 ? '' : 's'} · ${items.length} suggestion${items.length === 1 ? '' : 's'}`;
+
+ box.innerHTML = [...groups.entries()].map(([k, its]) => productCardHTML(k, its)).join('');
+
+ // wire each product card
+ for (const [k, its] of groups.entries()) wireProductCard(box, k, its);
+ }
+
+ function productCardHTML(key, its) {
+ const p = (its[0] && its[0].product) || {};
+ const img = p.imageUrl
+ ? `<a href="${esc(p.productUrl || '#')}" target="_blank" rel="noopener noreferrer"><img src="${esc(p.imageUrl)}" alt="${esc(p.title || '')}" loading="lazy"></a>`
+ : `<div class="ph">🖼️</div>`;
+ const created = its.map(i => i.createdAt).filter(Boolean).sort()[0];
+ const meta = [p.vendor, p.productType].filter(Boolean).map(esc).join(' · ');
+ const titleInner = p.productUrl
+ ? `<a href="${esc(p.productUrl)}" target="_blank" rel="noopener noreferrer">${esc(p.title || 'Untitled product')}</a>`
+ : esc(p.title || 'Untitled product');
+
+ // active channel default = first item's channel unless already chosen
+ if (!state.active[key]) state.active[key] = its[0].channel;
+ const active = state.active[key];
+
+ const tabs = its.map(it => {
+ const st = it.status || 'suggested';
+ const activeCls = it.channel === active ? ' active' : '';
+ const stLabel = st === 'approved' ? (it.scheduledAt ? fmtTime(it.scheduledAt) : 'sched')
+ : st === 'posted' ? '✓'
+ : st === 'failed' ? '✗'
+ : st === 'posting' ? '…'
+ : st === 'gated' ? '⛔'
+ : st === 'skipped' ? '—' : '';
+ const title = st === 'failed' && it.lastError ? ` title="${esc(it.lastError)}"` : (st === 'approved' && it.scheduledAt ? ` title="scheduled ${esc(fmtWhen(it.scheduledAt))}"` : '');
+ return `<button class="eng-tab s-${esc(st)}${activeCls}" data-chan="${esc(it.channel)}"${title}>
+ <span class="ic">${chanIcon(it.channel)}</span>${chanLabel(it.channel)}
+ ${stLabel ? `<span class="st">${esc(stLabel)}</span>` : ''}
+ </button>`;
+ }).join('');
+
+ return `<div class="eng-prod" data-key="${esc(key)}">
+ <div class="thumb">${img}</div>
+ <div class="body">
+ <div class="ptitle">${titleInner}</div>
+ <div class="pmeta">${meta || ' '}${p.dwSku ? ` · <span class="muted">${esc(p.dwSku)}</span>` : ''}</div>
+ <div class="pwhen" title="${esc(created || '')}">🕓 ${esc(fmtWhen(created))}</div>
+ <div class="eng-tabs">${tabs}</div>
+ <div class="eng-detail" data-detail></div>
+ </div>
+ </div>`;
+ }
+
+ function wireProductCard(box, key, its) {
+ const card = box.querySelector(`.eng-prod[data-key="${cssEsc(key)}"]`);
+ if (!card) return;
+ const byChan = Object.fromEntries(its.map(it => [it.channel, it]));
+ card.querySelectorAll('.eng-tab').forEach(tab => {
+ tab.onclick = () => {
+ state.active[key] = tab.dataset.chan;
+ card.querySelectorAll('.eng-tab').forEach(t => t.classList.toggle('active', t === tab));
+ renderDetail(card, byChan[tab.dataset.chan]);
+ };
+ });
+ renderDetail(card, byChan[state.active[key]]);
+ }
+
+ // escape a value for use in a [data-key="…"] attribute selector
+ function cssEsc(v) {
+ if (window.CSS && CSS.escape) return CSS.escape(String(v));
+ return String(v).replace(/["\\\]]/g, '\\$&');
+ }
+
+ function renderDetail(card, it) {
+ const box = card.querySelector('[data-detail]');
+ if (!box || !it) { if (box) box.innerHTML = ''; return; }
+ const st = it.status || 'suggested';
+ const editing = !!state.editing[it.id];
+
+ // gated → gate reason + Arm
+ if (st === 'gated') {
+ box.innerHTML = `
+ <div class="gate">⛔ Held: ${esc(it.gateReason || 'channel not available for live posting yet')}</div>
+ <div class="cap ${it.caption ? '' : 'muted'}">${esc(it.caption || '(caption suppressed until armed)')}</div>
+ <div class="eng-actions"><button class="btn ghost" data-act="arm">Arm →</button><span class="msg"></span></div>`;
+ wireActions(box, card, it);
+ return;
+ }
+
+ const tags = (it.hashtags || []).slice(0, 12).map(t => `<span class="pill">${esc(t.replace(/^#*/, '#'))}</span>`).join('');
+ const capBlock = editing
+ ? `<textarea data-cap>${esc(it.caption || '')}</textarea>`
+ : `<div class="cap ${it.caption ? '' : 'muted'}">${esc(it.caption || '(no caption)')}</div>`;
+
+ // scheduled/posted read-outs
+ let statusLine = '';
+ if (st === 'approved' && it.scheduledAt) statusLine = `<div class="slot">📅 Scheduled for <b>${esc(fmtWhen(it.scheduledAt))}</b></div>`;
+ else if (st === 'posted') statusLine = `<div class="slot">✅ Posted ${esc(fmtWhen(it.postedAt))}${it.outboxId ? ` · <span class="muted">outbox ${esc(it.outboxId)}</span>` : ''}</div>`;
+ else if (st === 'failed') statusLine = `<div class="slot" style="color:#9a4a4a">✗ Failed${it.lastError ? ` — ${esc(it.lastError)}` : ''}</div>`;
+ else if (st === 'skipped') statusLine = `<div class="slot muted">Skipped</div>`;
+ else statusLine = `<div class="slot">Suggested slot <b>${esc(it.suggestedSlot || '—')}</b></div>`;
+
+ // actions available while suggested (and edit while suggested|approved)
+ let actions = '';
+ if (st === 'suggested') {
+ actions = `
+ <button class="btn gold" data-act="approve">Approve ▸ time</button>
+ <button class="btn ghost" data-act="edit">${editing ? 'Save' : 'Edit'}</button>
+ <button class="btn ghost" data-act="skip">Skip</button>
+ <span class="msg"></span>`;
+ } else if (st === 'approved') {
+ actions = `<button class="btn ghost" data-act="edit">${editing ? 'Save' : 'Edit'}</button><span class="msg"></span>`;
+ }
+
+ box.innerHTML = `
+ ${capBlock}
+ <div class="tags">${tags}</div>
+ ${statusLine}
+ <div class="eng-actions">${actions}</div>
+ <div class="eng-sched-inline" data-picker hidden>
+ <label>When</label>
+ <input type="datetime-local" data-when value="${esc(slotLocalValue(it.date || todayISO(), it.suggestedSlot))}">
+ ${it.channel === 'youtube' ? `<label>Privacy</label>
+ <select data-privacy>
+ <option value="public">public</option>
+ <option value="unlisted">unlisted</option>
+ <option value="private">private</option>
+ </select>` : ''}
+ <button class="btn gold" data-act="approve-confirm">Confirm</button>
+ <button class="btn ghost" data-act="approve-cancel">Cancel</button>
+ </div>`;
+ wireActions(box, card, it);
+ }
+
+ function wireActions(box, card, it) {
+ const msg = box.querySelector('.msg');
+ const setMsg = t => { if (msg) msg.textContent = t; };
+ box.querySelectorAll('[data-act]').forEach(b => {
+ b.onclick = () => handleAction(b.dataset.act, it, box, card, setMsg);
+ });
+ }
+
+ async function handleAction(act, it, box, card, setMsg) {
+ if (act === 'edit') {
+ if (state.editing[it.id]) {
+ // save
+ const ta = box.querySelector('[data-cap]');
+ const caption = ta ? ta.value : it.caption;
+ setMsg('saving…');
+ const r = await jpost('/api/engine/edit', { id: it.id, caption });
+ if (r.error) { setMsg('✗ ' + r.error); return; }
+ state.editing[it.id] = false;
+ applyUpdated(r.item);
+ } else {
+ state.editing[it.id] = true;
+ renderDetail(card, it);
+ }
+ return;
+ }
+ if (act === 'skip') {
+ if (!confirm('Skip this suggestion? It won\'t be posted.')) return;
+ setMsg('skipping…');
+ const r = await jpost('/api/engine/skip', { id: it.id });
+ if (r.error) { setMsg('✗ ' + r.error); return; }
+ applyUpdated(r.item); refreshStatus();
+ return;
+ }
+ if (act === 'arm') {
+ setMsg('arming…');
+ const r = await jpost('/api/engine/arm', { id: it.id });
+ if (r.error) { setMsg('✗ ' + r.error); return; }
+ applyUpdated(r.item); refreshStatus();
+ return;
+ }
+ if (act === 'approve') {
+ // open the inline picker
+ const picker = box.querySelector('[data-picker]');
+ if (picker) picker.hidden = false;
+ return;
+ }
+ if (act === 'approve-cancel') {
+ const picker = box.querySelector('[data-picker]');
+ if (picker) picker.hidden = true;
+ return;
+ }
+ if (act === 'approve-confirm') {
+ const whenEl = box.querySelector('[data-when]');
+ const privEl = box.querySelector('[data-privacy]');
+ const scheduledAt = whenEl ? localToISO(whenEl.value) : null;
+ const body = { id: it.id };
+ if (scheduledAt) body.scheduledAt = scheduledAt;
+ if (privEl) body.privacy = privEl.value;
+ setMsg('approving…');
+ const r = await jpost('/api/engine/approve', body);
+ if (r.error) { setMsg('✗ ' + r.error); return; }
+ applyUpdated(r.item); refreshStatus();
+ return;
+ }
+ }
+
+ // Merge one updated item back into state.items and re-render just the affected
+ // product card (cheap; keeps the active-tab selection).
+ function applyUpdated(item) {
+ if (!item) return;
+ const i = state.items.findIndex(x => x.id === item.id);
+ if (i >= 0) state.items[i] = { ...state.items[i], ...item };
+ else state.items.push(item);
+ renderToday();
+ }
+
+ // ── 3 · pipeline strip (last 7 days) ──────────────────────────────────────
+ async function loadPipeline() {
+ const box = $('#eng-pipeline'); const cnt = $('#eng-pipe-count');
+ try {
+ const d = await jget('/api/engine/queue?days=7');
+ const items = (d.items || []);
+ if (cnt) cnt.textContent = `${items.length} item${items.length === 1 ? '' : 's'}`;
+ if (!box) return;
+ if (!items.length) { box.innerHTML = '<div class="muted">No activity in the last 7 days.</div>'; return; }
+ box.innerHTML = items.map(it => {
+ const p = it.product || {};
+ const thumb = (it.mediaUrl || p.imageUrl)
+ ? `<img class="pt" src="${esc(it.mediaUrl || p.imageUrl)}" alt="" loading="lazy">`
+ : `<div class="pt ph">🖼️</div>`;
+ const st = it.status || 'suggested';
+ const tm = it.postedAt ? fmtWhen(it.postedAt) : it.scheduledAt ? fmtWhen(it.scheduledAt) : (it.date || '');
+ const errTitle = st === 'failed' && it.lastError ? ` title="${esc(it.lastError)}"` : '';
+ return `<div class="eng-pc"${errTitle}>
+ ${thumb}
+ <div class="meta">
+ <div class="ch">${chanIcon(it.channel)} ${chanLabel(it.channel)}</div>
+ <div class="st s-${esc(st)}">${esc(st)}${st === 'failed' ? ' ✗' : st === 'posted' ? ' ✓' : ''}</div>
+ <div class="tm">${esc(tm)}</div>
+ </div>
+ </div>`;
+ }).join('');
+ } catch (e) {
+ if (cnt) cnt.textContent = '';
+ if (box) box.innerHTML = `<div class="muted">Couldn't load history: ${esc(e.message)}</div>`;
+ }
+ }
+
+ // ── loaders ───────────────────────────────────────────────────────────────
+ async function refreshStatus() {
+ try { state.status = await jget('/api/engine/status'); banner(''); }
+ catch (e) { banner('Engine status unavailable: ' + e.message); }
+ renderHeader();
+ }
+
+ // Cheap, non-generating today peek (used on load + on the 30s poll).
+ async function refreshToday() {
+ try {
+ const d = await jget('/api/engine/today?generate=0');
+ state.items = d.items || [];
+ banner('');
+ } catch (e) {
+ banner('Could not load today\'s suggestions: ' + e.message);
+ }
+ renderToday();
+ renderHeader(); // cost badge depends on items
+ }
+
+ // Force a fresh generation (may take 1–3 min via the local model).
+ async function doGenerate() {
+ if (state.generating) return;
+ state.generating = true;
+ renderToday();
+ banner('Generating today\'s suggestions with the local model — this can take 1–3 minutes…', true);
+ const genBtn = $('#eng-generate'); if (genBtn) { genBtn.disabled = true; genBtn.textContent = 'Generating…'; }
+ const r = await jpost('/api/engine/generate', {});
+ state.generating = false;
+ if (genBtn) { genBtn.disabled = false; genBtn.textContent = '⚡ Generate today'; }
+ if (r.error) { banner('Generation failed: ' + r.error); renderToday(); return; }
+ state.items = r.items || [];
+ if (r.note && !state.items.length) banner(r.note);
+ else banner('Suggestions ready.', true);
+ renderToday();
+ await refreshStatus();
+ await loadPipeline();
+ }
+
+ // ── auto-refresh (30s), paused when the tab is hidden ─────────────────────
+ let timer = null;
+ function tick() {
+ if (document.hidden || state.generating) return;
+ // don't stomp an open editor
+ if (Object.values(state.editing).some(Boolean)) { refreshStatus(); return; }
+ refreshStatus();
+ refreshToday();
+ loadPipeline();
+ }
+ function startTimer() { stopTimer(); timer = setInterval(tick, 30000); }
+ function stopTimer() { if (timer) { clearInterval(timer); timer = null; } }
+ const onVis = () => { if (!document.hidden) tick(); };
+ document.addEventListener('visibilitychange', onVis);
+ // stop polling once this panel is swapped out (its root leaves the DOM).
+ const mo = new MutationObserver(() => {
+ if (!document.body.contains(root)) { stopTimer(); document.removeEventListener('visibilitychange', onVis); mo.disconnect(); }
+ });
+ try { mo.observe(document.getElementById('panel') || document.body, { childList: true }); } catch {}
+
+ // ── wire header button + boot ─────────────────────────────────────────────
+ const gen = $('#eng-generate'); if (gen) gen.onclick = doGenerate;
+
+ (async function boot() {
+ await refreshStatus();
+ await refreshToday();
+ await loadPipeline();
+ startTimer();
+ })();
+ },
+};
← 0938d51 engine: core module (store/config/skeleton) + channels.postL
·
back to Marketing Command Center
·
engine: contrarian fixes — YT approve defaults private, edit 0210a80 →