← back to Norma
snapshot before TK-11315 browser verification
cd1437ddae997a7072d853dd91ae542ff0652aa4 · 2026-09-11 08:12:19 -0700 · Steve Abrams
Files touched
M agents/instagram-agent/delete-novasuede-graph.jsA agents/instagram-agent/delete-room-setting-graph.jsA agents/instagram-agent/fleet-sassy-scan.jsA agents/instagram-agent/vision-classify.mjsA agents/instagram-agent/vision-sweep-local.mjsA cta/shots/chrome/pulse-0-after.pngA cta/shots/chrome/pulse-0-before.pngA cta/shots/chrome/pulse-1-after.pngA cta/shots/chrome/pulse-1-before.pngA cta/shots/chrome/pulse-10-after.pngA cta/shots/chrome/pulse-10-before.pngA cta/shots/chrome/pulse-11-after.pngA cta/shots/chrome/pulse-11-before.pngA cta/shots/chrome/pulse-2-after.pngA cta/shots/chrome/pulse-2-before.pngA cta/shots/chrome/pulse-3-after.pngA cta/shots/chrome/pulse-3-before.pngA cta/shots/chrome/pulse-4-after.pngA cta/shots/chrome/pulse-4-before.pngA cta/shots/chrome/pulse-5-after.pngA cta/shots/chrome/pulse-5-before.pngA cta/shots/chrome/pulse-6-after.pngA cta/shots/chrome/pulse-6-before.pngA cta/shots/chrome/pulse-7-after.pngA cta/shots/chrome/pulse-7-before.pngA cta/shots/chrome/pulse-8-after.pngA cta/shots/chrome/pulse-8-before.pngA cta/shots/chrome/pulse-9-after.pngA cta/shots/chrome/pulse-9-before.pngA cta/shots/chrome/pulse-page.pngA cta/shots/safari/pulse-0-after.pngA cta/shots/safari/pulse-0-before.pngA cta/shots/safari/pulse-1-after.pngA cta/shots/safari/pulse-1-before.pngA cta/shots/safari/pulse-10-after.pngA cta/shots/safari/pulse-10-before.pngA cta/shots/safari/pulse-11-after.pngA cta/shots/safari/pulse-11-before.pngA cta/shots/safari/pulse-2-after.pngA cta/shots/safari/pulse-2-before.pngA cta/shots/safari/pulse-3-after.pngA cta/shots/safari/pulse-3-before.pngA cta/shots/safari/pulse-4-after.pngA cta/shots/safari/pulse-4-before.pngA cta/shots/safari/pulse-5-after.pngA cta/shots/safari/pulse-5-before.pngA cta/shots/safari/pulse-6-after.pngA cta/shots/safari/pulse-6-before.pngA cta/shots/safari/pulse-7-after.pngA cta/shots/safari/pulse-7-before.pngA cta/shots/safari/pulse-8-after.pngA cta/shots/safari/pulse-8-before.pngA cta/shots/safari/pulse-9-after.pngA cta/shots/safari/pulse-9-before.pngA cta/shots/safari/pulse-page.png
Diff
commit cd1437ddae997a7072d853dd91ae542ff0652aa4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 11 08:12:19 2026 -0700
snapshot before TK-11315 browser verification
---
agents/instagram-agent/delete-novasuede-graph.js | 16 +++-
.../instagram-agent/delete-room-setting-graph.js | 85 ++++++++++++++++++++
agents/instagram-agent/fleet-sassy-scan.js | 79 +++++++++++++++++++
agents/instagram-agent/vision-classify.mjs | 86 +++++++++++++++++++++
agents/instagram-agent/vision-sweep-local.mjs | 72 +++++++++++++++++
cta/shots/chrome/pulse-0-after.png | Bin 0 -> 113401 bytes
cta/shots/chrome/pulse-0-before.png | Bin 0 -> 110566 bytes
cta/shots/chrome/pulse-1-after.png | Bin 0 -> 110925 bytes
cta/shots/chrome/pulse-1-before.png | Bin 0 -> 113860 bytes
cta/shots/chrome/pulse-10-after.png | Bin 0 -> 167119 bytes
cta/shots/chrome/pulse-10-before.png | Bin 0 -> 107849 bytes
cta/shots/chrome/pulse-11-after.png | Bin 0 -> 111261 bytes
cta/shots/chrome/pulse-11-before.png | Bin 0 -> 110724 bytes
cta/shots/chrome/pulse-2-after.png | Bin 0 -> 111479 bytes
cta/shots/chrome/pulse-2-before.png | Bin 0 -> 111165 bytes
cta/shots/chrome/pulse-3-after.png | Bin 0 -> 110678 bytes
cta/shots/chrome/pulse-3-before.png | Bin 0 -> 110883 bytes
cta/shots/chrome/pulse-4-after.png | Bin 0 -> 109548 bytes
cta/shots/chrome/pulse-4-before.png | Bin 0 -> 110658 bytes
cta/shots/chrome/pulse-5-after.png | Bin 0 -> 105947 bytes
cta/shots/chrome/pulse-5-before.png | Bin 0 -> 109045 bytes
cta/shots/chrome/pulse-6-after.png | Bin 0 -> 109251 bytes
cta/shots/chrome/pulse-6-before.png | Bin 0 -> 101402 bytes
cta/shots/chrome/pulse-7-after.png | Bin 0 -> 106040 bytes
cta/shots/chrome/pulse-7-before.png | Bin 0 -> 108836 bytes
cta/shots/chrome/pulse-8-after.png | Bin 0 -> 107608 bytes
cta/shots/chrome/pulse-8-before.png | Bin 0 -> 106613 bytes
cta/shots/chrome/pulse-9-after.png | Bin 0 -> 109652 bytes
cta/shots/chrome/pulse-9-before.png | Bin 0 -> 109123 bytes
cta/shots/chrome/pulse-page.png | Bin 0 -> 338273 bytes
cta/shots/safari/pulse-0-after.png | Bin 0 -> 151539 bytes
cta/shots/safari/pulse-0-before.png | Bin 0 -> 145829 bytes
cta/shots/safari/pulse-1-after.png | Bin 0 -> 148031 bytes
cta/shots/safari/pulse-1-before.png | Bin 0 -> 151595 bytes
cta/shots/safari/pulse-10-after.png | Bin 0 -> 207203 bytes
cta/shots/safari/pulse-10-before.png | Bin 0 -> 147641 bytes
cta/shots/safari/pulse-11-after.png | Bin 0 -> 151144 bytes
cta/shots/safari/pulse-11-before.png | Bin 0 -> 147322 bytes
cta/shots/safari/pulse-2-after.png | Bin 0 -> 150255 bytes
cta/shots/safari/pulse-2-before.png | Bin 0 -> 147232 bytes
cta/shots/safari/pulse-3-after.png | Bin 0 -> 147621 bytes
cta/shots/safari/pulse-3-before.png | Bin 0 -> 149436 bytes
cta/shots/safari/pulse-4-after.png | Bin 0 -> 136132 bytes
cta/shots/safari/pulse-4-before.png | Bin 0 -> 147246 bytes
cta/shots/safari/pulse-5-after.png | Bin 0 -> 145444 bytes
cta/shots/safari/pulse-5-before.png | Bin 0 -> 137611 bytes
cta/shots/safari/pulse-6-after.png | Bin 0 -> 150820 bytes
cta/shots/safari/pulse-6-before.png | Bin 0 -> 148909 bytes
cta/shots/safari/pulse-7-after.png | Bin 0 -> 137587 bytes
cta/shots/safari/pulse-7-before.png | Bin 0 -> 149467 bytes
cta/shots/safari/pulse-8-after.png | Bin 0 -> 147128 bytes
cta/shots/safari/pulse-8-before.png | Bin 0 -> 143989 bytes
cta/shots/safari/pulse-9-after.png | Bin 0 -> 149309 bytes
cta/shots/safari/pulse-9-before.png | Bin 0 -> 149389 bytes
cta/shots/safari/pulse-page.png | Bin 0 -> 421329 bytes
55 files changed, 336 insertions(+), 2 deletions(-)
diff --git a/agents/instagram-agent/delete-novasuede-graph.js b/agents/instagram-agent/delete-novasuede-graph.js
index 572b5e2..0cb6aaf 100644
--- a/agents/instagram-agent/delete-novasuede-graph.js
+++ b/agents/instagram-agent/delete-novasuede-graph.js
@@ -32,7 +32,12 @@ const https = require('https');
const { execFileSync } = require('child_process');
const DATA = path.join(__dirname, 'data');
-const LIVE = path.join(DATA, 'live-media.json');
+// --live <file> selects which per-account media cache to work from. Norma caches each account to
+// data/live-media-<ig_user_id>.json (the bare live-media.json is the default account), and the
+// targets for TK-11367 span 4 accounts — so the account must be selectable or the tool can only
+// ever see one of them (which is exactly why it reported 16 when 38 were live).
+const liveArg = (() => { const i = process.argv.indexOf('--live'); return i >= 0 ? String(process.argv[i + 1] || '') : ''; })();
+const LIVE = liveArg ? (path.isAbsolute(liveArg) ? liveArg : path.join(DATA, liveArg)) : path.join(DATA, 'live-media.json');
const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
// Same shared META PAGE token env as delete-gate2-graph.js (.env in this dir, dotenv-loaded).
const TOKEN = process.env.IG_ACCESS_TOKEN;
@@ -214,7 +219,14 @@ function ledger(n) {
// HARD GUARD: only ids from the built (filtered) candidate set can reach the delete loop.
const guard = new Set(candidates.map((c) => c.id));
- const targets = live.filter((c) => guard.has(c.id));
+ // --limit N caps the batch so a canary run can delete exactly one post and be verified before
+ // the rest follow. Blast-radius control only; it can never widen the target set.
+ const limitArg = (() => { const i = process.argv.indexOf('--limit'); return i >= 0 ? Number(process.argv[i + 1]) : 0; })();
+ let targets = live.filter((c) => guard.has(c.id));
+ if (limitArg > 0 && targets.length > limitArg) {
+ console.log(`--limit ${limitArg}: capping this run to the first ${limitArg} of ${targets.length} target(s).`);
+ targets = targets.slice(0, limitArg);
+ }
console.log(`\n--go: deleting ${targets.length} live Novasuede post(s), ${PACE_MS / 1000}s apart …\n`);
let ok = 0;
diff --git a/agents/instagram-agent/delete-room-setting-graph.js b/agents/instagram-agent/delete-room-setting-graph.js
new file mode 100644
index 0000000..32cf03c
--- /dev/null
+++ b/agents/instagram-agent/delete-room-setting-graph.js
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+/**
+ * delete-room-setting-graph.js — BROWSER-FREE Graph-API deletion of ROOM-SETTING IG posts.
+ *
+ * Steve directive 2026-09-10 (TK-11367): "any room setting or jewelry case, remove from posts".
+ * Modeled on delete-novasuede-graph.js (same token loading, pacing, live-recheck, verify-after-
+ * delete, tombstone conventions). NO BROWSER — openclaw is a ban vector AND is currently logged
+ * out; DELETE /{ig-media-id} with the owning Page's token works directly.
+ *
+ * TARGET SET is a fixed hitlist — data/room-setting-hitlist.jsonl. This runner acts ONLY on the
+ * media_ids in that file. It never discovers targets at runtime.
+ *
+ * node delete-room-setting-graph.js # DRY RUN (default): live-recheck, delete NOTHING
+ * node delete-room-setting-graph.js --go # delete each still-live target, paced, verified
+ *
+ * SAFETY: dry-run default. Every delete is VERIFIED (a follow-up GET must report gone) before it
+ * is tombstoned. A delete that fails or can't be verified is SKIPPED and reported, never aborting
+ * the run. Idempotent: ids already in data/deleted-posts.jsonl are skipped.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const DATA = path.join(__dirname, 'data');
+const SRC = path.join(DATA, 'room-setting-hitlist.jsonl');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const VER = 'v21.0';
+const args = process.argv.slice(2);
+const GO = args.includes('--go');
+const PACE_MS = (() => { const i = args.indexOf('--pace'); return (i >= 0 ? Number(args[i + 1]) : 8) * 1000; })();
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+const USER_TOKEN = (env.match(/^META_ACCESS_TOKEN=(.+)$/m) || [])[1]?.trim().replace(/"/g, '');
+if (!USER_TOKEN) { console.error('no META_ACCESS_TOKEN'); process.exit(1); }
+
+if (!fs.existsSync(SRC)) { console.error(`no hitlist at ${SRC}`); process.exit(1); }
+const done = new Set(fs.existsSync(TOMBS)
+ ? fs.readFileSync(TOMBS, 'utf8').trim().split('\n').filter(Boolean)
+ .map((l) => { try { return JSON.parse(l).media_id; } catch { return null; } }).filter(Boolean) : []);
+const rows = fs.readFileSync(SRC, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
+ .filter((r) => r.media_id && !done.has(r.media_id));
+const GUARD = new Set(rows.map((r) => r.media_id)); // nothing outside the hitlist is ever touched
+
+(async () => {
+ // page tokens for the owning accounts
+ const pages = [];
+ let u = `https://graph.facebook.com/${VER}/me/accounts?fields=name,access_token,instagram_business_account{username,id}&limit=100&access_token=${USER_TOKEN}`;
+ while (u) { const j = await (await fetch(u)).json(); if (j.error) { console.error('accounts:', j.error.message); process.exit(1); } pages.push(...j.data); u = j.paging?.next || null; }
+ const tokFor = (h) => pages.find((p) => p.instagram_business_account?.username?.toLowerCase() === h.toLowerCase())?.access_token;
+
+ console.log(`${GO ? 'GO' : 'DRY RUN'} — ${rows.length} room-setting target(s), pace ${PACE_MS / 1000}s\n`);
+ const live = [], gone = [], failed = [];
+ for (const r of rows) {
+ const tok = tokFor(r.handle);
+ if (!tok) { failed.push({ ...r, why: 'no page token' }); console.log(` ?? @${r.handle} ${r.media_id} — NO PAGE TOKEN, skipping`); continue; }
+ const j = await (await fetch(`https://graph.facebook.com/${VER}/${r.media_id}?fields=id,permalink,timestamp&access_token=${tok}`)).json();
+ if (j.error) { gone.push(r); console.log(` -- @${r.handle} ${r.media_id} already gone (${j.error.message.slice(0, 50)})`); continue; }
+ live.push({ ...r, tok });
+ console.log(` LIVE @${r.handle} ${r.media_id} ${j.permalink} ${r.image_file}`);
+ }
+ console.log(`\nlive now: ${live.length} · already gone: ${gone.length} · unresolvable: ${failed.length}`);
+ if (!GO) { console.log('\nDRY RUN — nothing deleted. Re-run with --go to execute.'); return; }
+
+ console.log(`\n--go: deleting ${live.length} post(s), ${PACE_MS / 1000}s apart …\n`);
+ const results = [];
+ for (const t of live) {
+ if (!GUARD.has(t.media_id)) { console.log(` !! ${t.media_id} not in guard set — REFUSED`); continue; }
+ const del = await (await fetch(`https://graph.facebook.com/${VER}/${t.media_id}?access_token=${t.tok}`, { method: 'DELETE' })).json();
+ await sleep(2000);
+ const chk = await (await fetch(`https://graph.facebook.com/${VER}/${t.media_id}?fields=id&access_token=${t.tok}`)).json();
+ const verified = !!chk.error;
+ if (verified) {
+ const tomb = { ts: new Date().toISOString(), handle: t.handle, media_id: t.media_id, permalink: t.permalink, image_file: t.image_file, method: 'graph-api', reason: t.reason, ticket: 'TK-11367', verified: true };
+ fs.appendFileSync(TOMBS, JSON.stringify(tomb) + '\n');
+ results.push(tomb);
+ console.log(` ✓ deleted+verified @${t.handle} ${t.media_id}`);
+ } else {
+ failed.push({ ...t, why: `delete unverified: ${JSON.stringify(del).slice(0, 90)}` });
+ console.log(` ✗ FAILED/unverified @${t.handle} ${t.media_id}`);
+ }
+ await sleep(PACE_MS);
+ }
+ console.log(`\nDONE — ${results.length} deleted+verified, ${failed.length} failed/skipped.`);
+ if (failed.length) failed.forEach((f) => console.log(` FAIL @${f.handle} ${f.media_id}: ${f.why}`));
+})();
diff --git a/agents/instagram-agent/fleet-sassy-scan.js b/agents/instagram-agent/fleet-sassy-scan.js
new file mode 100644
index 0000000..c6210e2
--- /dev/null
+++ b/agents/instagram-agent/fleet-sassy-scan.js
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/**
+ * fleet-sassy-scan.js — TK-10758 follow-through. READ-ONLY sweep of ALL IG accounts (not just the
+ * 4 with reshare ledgers) for sassy reposts of other people's content, so "across all accounts" is
+ * actually satisfied. Pulls each account's recent /media and flags captions matching the resharer's
+ * SASS signatures + credit markers. Excludes anything already tombstoned. Writes a candidate list.
+ *
+ * node fleet-sassy-scan.js # scan + report (deletes nothing)
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const ACC = require('./accounts.json').accounts;
+const TOKEN = process.env.IG_ACCESS_TOKEN;
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HOST = 'graph.facebook.com';
+const DATA = path.join(__dirname, 'data');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// distinctive resharer signatures (hashtag-reshare.js + fabric-friday-reshare.js SASS templates)
+const SASS_RE = /doing WAY too much|audacity of good taste|pull up (?:to Fabric Friday )?with THIS|casually drop a masterpiece|flex of the week|understood the assignment|Show-off\.? \(Please keep showing off/i;
+const CREDIT_RE = /📸\s*@|·\s*via\s*#|had to share/i;
+
+function get(p) {
+ return new Promise((resolve) => {
+ https.get(`https://${HOST}/${VER}/${p}`, (r) => { let s = ''; r.on('data', (d) => (s += d)); r.on('end', () => { let j = null; try { j = JSON.parse(s); } catch {} resolve({ status: r.statusCode, body: j }); }); }).on('error', () => resolve({ status: 0 }));
+ });
+}
+const tombstoned = fs.existsSync(TOMBS)
+ ? new Set(fs.readFileSync(TOMBS, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l).media_id; } catch { return null; } }).filter(Boolean))
+ : new Set();
+
+(async () => {
+ const handles = Object.keys(ACC);
+ const flagged = [];
+ let scanned = 0;
+ for (const h of handles) {
+ const a = ACC[h]; if (!a || !a.ig_user_id) continue;
+ let after = '';
+ for (let page = 0; page < 3; page++) { // ~150 most-recent per account
+ const r = await get(`${a.ig_user_id}/media?fields=id,caption,permalink,timestamp&limit=50${after ? `&after=${after}` : ''}&access_token=${TOKEN}`);
+ if (r.status !== 200 || !r.body || !Array.isArray(r.body.data)) break;
+ for (const m of r.body.data) {
+ scanned++;
+ const cap = m.caption || '';
+ if (tombstoned.has(m.id)) continue;
+ const sass = SASS_RE.test(cap), credit = CREDIT_RE.test(cap);
+ if (sass || credit) flagged.push({ handle: h, media_id: m.id, permalink: m.permalink, ts: m.timestamp, sass, credit, caption: cap.replace(/\n/g, ' ').slice(0, 100) });
+ }
+ const next = r.body.paging && r.body.paging.cursors && r.body.paging.cursors.after;
+ if (!next) break; after = next; await sleep(120);
+ }
+ await sleep(120);
+ }
+ fs.writeFileSync(path.join(DATA, 'fleet-sassy-scan.json'), JSON.stringify({ ts: new Date().toISOString(), scanned, flagged }, null, 2));
+ const byH = {}; flagged.forEach((x) => (byH[x.handle] = (byH[x.handle] || 0) + 1));
+ console.log('\n=== FLEET SASSY-REPOST SCAN (read-only, all accounts) ===');
+ console.log('accounts scanned:', handles.length, '| media examined:', scanned, '| NEW flagged (not already erased):', flagged.length);
+ console.log('by account:', JSON.stringify(byH, null, 2));
+ flagged.slice(0, 15).forEach((x) => console.log(` @${x.handle} ${x.permalink}\n [sass:${x.sass} credit:${x.credit}] "${x.caption}"`));
+ if (!flagged.length) { console.log('\n✓ No additional sassy reposts anywhere on the fleet — the 4-ledger erase covered everything.'); return; }
+
+ if (!process.argv.includes('--go')) { console.log('\nRun with --go to erase these flagged reposts (browser-free, verified).'); return; }
+ const del = (p) => new Promise((resolve) => { const r = https.request(`https://${HOST}/${VER}/${p}`, { method: 'DELETE' }, (res) => { let s = ''; res.on('data', (d) => (s += d)); res.on('end', () => { let j = null; try { j = JSON.parse(s); } catch {} resolve({ status: res.statusCode, body: j }); }); }); r.on('error', () => resolve({ status: 0 })); r.end(); });
+ let ok = 0, fail = 0;
+ for (const t of flagged) {
+ process.stdout.write(`DELETE @${t.handle} ${t.permalink} ... `);
+ await del(`${t.media_id}?access_token=${TOKEN}`);
+ await sleep(1500);
+ const chk = await get(`${t.media_id}?fields=id&access_token=${TOKEN}`);
+ const gone = chk.status !== 200 || (chk.body && chk.body.error);
+ if (gone) { fs.appendFileSync(TOMBS, JSON.stringify({ ts: new Date().toISOString(), handle: t.handle, media_id: t.media_id, permalink: t.permalink, method: 'graph-api', ticket: 'TK-10758', reason: 'sassy-reshare-fleet-scan', verified: true }) + '\n'); ok++; console.log('✓ deleted+verified'); }
+ else { fail++; console.log('✗ FAILED'); }
+ await sleep(5000);
+ }
+ console.log(`\nDONE: erased+verified ${ok}, failed ${fail}, of ${flagged.length}.`);
+})();
diff --git a/agents/instagram-agent/vision-classify.mjs b/agents/instagram-agent/vision-classify.mjs
new file mode 100644
index 0000000..3586335
--- /dev/null
+++ b/agents/instagram-agent/vision-classify.mjs
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * vision-classify.mjs — binary Gemini 2.0 Flash classifier for TK-11367.
+ * For each local image, asks: is this a styled ROOM SETTING / interior scene,
+ * and/or a JEWELRY DISPLAY CASE? Returns strict JSON per image.
+ *
+ * node vision-classify.mjs <img1.jpg> [img2.jpg ...] # ad-hoc
+ * node vision-classify.mjs --manifest <file.jsonl> # batch (see below)
+ *
+ * Manifest lines: {"id": "...", "path": "/abs/img.jpg"} -> appends a result
+ * line {"id","room","jewelry","raw"} to <manifest>.results.jsonl, idempotent
+ * (skips ids already in the results file).
+ */
+import fs from 'fs';
+import path from 'path';
+
+const ENV = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+const KEY = (ENV.match(/^GEMINI_API_KEY=(.+)$/m) || [])[1]?.trim().replace(/"/g, '');
+if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1); }
+const MODEL = process.env.GEMINI_MODEL || 'gemini-3.6-flash';
+const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+
+const PROMPT = `You are auditing a wallpaper/wallcovering brand's Instagram images.
+Classify THIS image on two independent yes/no questions:
+
+1. ROOM: Is this a styled ROOM SETTING / interior scene — i.e. the wallcovering
+ is shown installed on a wall as part of a decorated room or vignette, with
+ furniture, decor, lamps, plants, or architectural context (bedroom, living
+ room, lobby, office, dining, hallway, etc.)? A flat swatch, a rolled bolt,
+ a folded fabric drape, a close-up texture, a pattern tile, or a product-on-
+ white-background shot is NOT a room setting -> ROOM: no.
+
+2. JEWELRY: Does this image show a JEWELRY DISPLAY CASE, jewelry counter, or
+ jewelry-store glass display presenting the product? -> yes/no.
+
+Reply with ONLY a compact JSON object, no prose, no markdown fences:
+{"room": true|false, "jewelry": true|false, "scene": "<3-6 word description>"}`;
+
+async function classify(imgPath) {
+ const b64 = fs.readFileSync(imgPath).toString('base64');
+ const mime = imgPath.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
+ const body = {
+ contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: b64 } }] }],
+ generationConfig: { temperature: 0, maxOutputTokens: 120 },
+ };
+ for (let attempt = 0; attempt < 4; attempt++) {
+ try {
+ const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ if (res.status === 429 || res.status >= 500) { await new Promise(r => setTimeout(r, 1500 * (attempt + 1))); continue; }
+ const j = await res.json();
+ const txt = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+ const m = txt.match(/\{[\s\S]*\}/);
+ if (!m) return { room: null, jewelry: null, scene: 'PARSE_FAIL', raw: txt.slice(0, 120) };
+ const parsed = JSON.parse(m[0]);
+ return { room: !!parsed.room, jewelry: !!parsed.jewelry, scene: parsed.scene || '' };
+ } catch (e) {
+ await new Promise(r => setTimeout(r, 1500 * (attempt + 1)));
+ }
+ }
+ return { room: null, jewelry: null, scene: 'API_FAIL' };
+}
+
+const args = process.argv.slice(2);
+if (args[0] === '--manifest') {
+ const manifest = args[1];
+ const outFile = manifest.replace(/\.jsonl$/, '') + '.results.jsonl';
+ const done = new Set(fs.existsSync(outFile)
+ ? fs.readFileSync(outFile, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l).id; } catch { return null; } }).filter(Boolean)
+ : []);
+ const rows = fs.readFileSync(manifest, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(r => !done.has(r.id));
+ console.error(`manifest: ${rows.length} to classify (${done.size} already done) -> ${outFile}`);
+ let n = 0, hits = 0;
+ for (const r of rows) {
+ if (!fs.existsSync(r.path)) { fs.appendFileSync(outFile, JSON.stringify({ id: r.id, room: null, jewelry: null, scene: 'NO_FILE' }) + '\n'); continue; }
+ const v = await classify(r.path);
+ fs.appendFileSync(outFile, JSON.stringify({ id: r.id, ...v }) + '\n');
+ n++; if (v.room || v.jewelry) { hits++; console.error(` HIT[${v.room ? 'R' : ''}${v.jewelry ? 'J' : ''}] ${r.id} — ${v.scene}`); }
+ if (n % 100 === 0) console.error(` … ${n}/${rows.length} classified, ${hits} hits so far`);
+ }
+ console.error(`DONE — ${n} classified, ${hits} room/jewelry hits.`);
+} else {
+ for (const p of args) {
+ const v = await classify(p);
+ console.log(path.basename(p), '->', JSON.stringify(v));
+ }
+}
diff --git a/agents/instagram-agent/vision-sweep-local.mjs b/agents/instagram-agent/vision-sweep-local.mjs
new file mode 100644
index 0000000..9820f18
--- /dev/null
+++ b/agents/instagram-agent/vision-sweep-local.mjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+/**
+ * vision-sweep-local.mjs — TK-11367 room-setting/jewelry sweep via LOCAL qwen2.5vl
+ * (Gemini billing depleted). Shards across ollama hosts with a small worker pool.
+ * Idempotent + resumable: appends {id,room,jewelry,scene} to <manifest>.results.jsonl,
+ * skips ids already present.
+ *
+ * node vision-sweep-local.mjs <manifest.jsonl>
+ */
+import fs from 'fs';
+
+const MANIFEST = process.argv[2];
+if (!MANIFEST) { console.error('usage: vision-sweep-local.mjs <manifest.jsonl>'); process.exit(1); }
+const OUT = MANIFEST.replace(/\.jsonl$/, '') + '.results.jsonl';
+const HOSTS = (process.env.OLLAMA_HOSTS || '127.0.0.1,192.168.1.133').split(',');
+const PER_HOST = Number(process.env.PER_HOST || 2);
+const MODEL = process.env.VL_MODEL || 'qwen2.5vl:7b';
+
+const PROMPT = `Classify this wallpaper-brand Instagram image on two independent yes/no questions.
+ROOM = the wallcovering is shown INSTALLED in a decorated room or styled vignette, with furniture, decor, lamps, plants, rugs, or architectural context (bedroom, living room, lobby, office, dining, hallway, retail, hotel). A flat swatch, rolled bolt, folded fabric drape, close-up texture, pattern tile, or product-on-white-background is NOT a room -> room:false.
+JEWELRY = the image shows a JEWELRY DISPLAY CASE / jewelry counter / glass jewelry display presenting the product.
+Reply with ONLY a compact JSON object, no prose: {"room":true|false,"jewelry":true|false,"scene":"3-6 word description"}`;
+
+const CALL_TIMEOUT = Number(process.env.CALL_TIMEOUT_MS || 90000);
+async function classify(host, imgPath) {
+ const b64 = fs.readFileSync(imgPath).toString('base64');
+ for (let a = 0; a < 3; a++) {
+ const ac = new AbortController();
+ const to = setTimeout(() => ac.abort(), CALL_TIMEOUT);
+ try {
+ const res = await fetch(`http://${host}:11434/api/generate`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
+ body: JSON.stringify({ model: MODEL, prompt: PROMPT, images: [b64], stream: false, format: 'json', options: { temperature: 0, num_predict: 80 } }),
+ });
+ clearTimeout(to);
+ const j = await res.json();
+ const txt = j.response || '';
+ const m = txt.match(/\{[\s\S]*\}/);
+ if (!m) { if (a < 2) { await new Promise(r => setTimeout(r, 1500)); continue; } return { room: null, jewelry: null, scene: 'PARSE_FAIL' }; }
+ const p = JSON.parse(m[0]);
+ return { room: !!p.room, jewelry: !!p.jewelry, scene: (p.scene || '').slice(0, 60) };
+ } catch (e) { clearTimeout(to); await new Promise(r => setTimeout(r, 2000 * (a + 1))); }
+ }
+ return { room: null, jewelry: null, scene: 'API_FAIL' };
+}
+
+const done = new Set(fs.existsSync(OUT)
+ ? fs.readFileSync(OUT, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l).id; } catch { return null; } }).filter(Boolean)
+ : []);
+const rows = fs.readFileSync(MANIFEST, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(r => !done.has(r.id));
+console.error(`[sweep] ${rows.length} to classify (${done.size} already done) | hosts=${HOSTS.join(',')} x${PER_HOST} | -> ${OUT}`);
+
+let idx = 0, n = 0, hits = 0;
+const t0 = Date.now();
+async function worker(host, slot) {
+ while (true) {
+ const my = idx++;
+ if (my >= rows.length) return;
+ const r = rows[my];
+ if (!fs.existsSync(r.path)) { fs.appendFileSync(OUT, JSON.stringify({ id: r.id, room: null, jewelry: null, scene: 'NO_FILE' }) + '\n'); continue; }
+ const v = await classify(host, r.path);
+ if (v.room === null) { continue; } // transient failure -> DON'T persist, let a resume retry it
+ fs.appendFileSync(OUT, JSON.stringify({ id: r.id, ...v }) + '\n');
+ n++;
+ if (v.room || v.jewelry) { hits++; console.error(` HIT[${v.room ? 'R' : ''}${v.jewelry ? 'J' : ''}] ${r.id} — ${v.scene}`); }
+ if (n % 100 === 0) { const rate = n / ((Date.now() - t0) / 1000); console.error(` … ${n}/${rows.length} (${rate.toFixed(2)}/s, ${hits} hits, ~${Math.round((rows.length - n) / rate / 60)}min left)`); }
+ }
+}
+const pool = [];
+for (const h of HOSTS) for (let s = 0; s < PER_HOST; s++) pool.push(worker(h, s));
+await Promise.all(pool);
+console.error(`[sweep] DONE — ${n} newly classified, ${hits} room/jewelry hits this run. Total results: ${done.size + n}.`);
diff --git a/cta/shots/chrome/pulse-0-after.png b/cta/shots/chrome/pulse-0-after.png
new file mode 100644
index 0000000..c8a609a
Binary files /dev/null and b/cta/shots/chrome/pulse-0-after.png differ
diff --git a/cta/shots/chrome/pulse-0-before.png b/cta/shots/chrome/pulse-0-before.png
new file mode 100644
index 0000000..30771ea
Binary files /dev/null and b/cta/shots/chrome/pulse-0-before.png differ
diff --git a/cta/shots/chrome/pulse-1-after.png b/cta/shots/chrome/pulse-1-after.png
new file mode 100644
index 0000000..645e547
Binary files /dev/null and b/cta/shots/chrome/pulse-1-after.png differ
diff --git a/cta/shots/chrome/pulse-1-before.png b/cta/shots/chrome/pulse-1-before.png
new file mode 100644
index 0000000..fd2ec9d
Binary files /dev/null and b/cta/shots/chrome/pulse-1-before.png differ
diff --git a/cta/shots/chrome/pulse-10-after.png b/cta/shots/chrome/pulse-10-after.png
new file mode 100644
index 0000000..e226e52
Binary files /dev/null and b/cta/shots/chrome/pulse-10-after.png differ
diff --git a/cta/shots/chrome/pulse-10-before.png b/cta/shots/chrome/pulse-10-before.png
new file mode 100644
index 0000000..284eb6a
Binary files /dev/null and b/cta/shots/chrome/pulse-10-before.png differ
diff --git a/cta/shots/chrome/pulse-11-after.png b/cta/shots/chrome/pulse-11-after.png
new file mode 100644
index 0000000..465790c
Binary files /dev/null and b/cta/shots/chrome/pulse-11-after.png differ
diff --git a/cta/shots/chrome/pulse-11-before.png b/cta/shots/chrome/pulse-11-before.png
new file mode 100644
index 0000000..909d97e
Binary files /dev/null and b/cta/shots/chrome/pulse-11-before.png differ
diff --git a/cta/shots/chrome/pulse-2-after.png b/cta/shots/chrome/pulse-2-after.png
new file mode 100644
index 0000000..0c90cd9
Binary files /dev/null and b/cta/shots/chrome/pulse-2-after.png differ
diff --git a/cta/shots/chrome/pulse-2-before.png b/cta/shots/chrome/pulse-2-before.png
new file mode 100644
index 0000000..47f982b
Binary files /dev/null and b/cta/shots/chrome/pulse-2-before.png differ
diff --git a/cta/shots/chrome/pulse-3-after.png b/cta/shots/chrome/pulse-3-after.png
new file mode 100644
index 0000000..7f81058
Binary files /dev/null and b/cta/shots/chrome/pulse-3-after.png differ
diff --git a/cta/shots/chrome/pulse-3-before.png b/cta/shots/chrome/pulse-3-before.png
new file mode 100644
index 0000000..77df9f3
Binary files /dev/null and b/cta/shots/chrome/pulse-3-before.png differ
diff --git a/cta/shots/chrome/pulse-4-after.png b/cta/shots/chrome/pulse-4-after.png
new file mode 100644
index 0000000..c509ae0
Binary files /dev/null and b/cta/shots/chrome/pulse-4-after.png differ
diff --git a/cta/shots/chrome/pulse-4-before.png b/cta/shots/chrome/pulse-4-before.png
new file mode 100644
index 0000000..8db5000
Binary files /dev/null and b/cta/shots/chrome/pulse-4-before.png differ
diff --git a/cta/shots/chrome/pulse-5-after.png b/cta/shots/chrome/pulse-5-after.png
new file mode 100644
index 0000000..d40b674
Binary files /dev/null and b/cta/shots/chrome/pulse-5-after.png differ
diff --git a/cta/shots/chrome/pulse-5-before.png b/cta/shots/chrome/pulse-5-before.png
new file mode 100644
index 0000000..9612550
Binary files /dev/null and b/cta/shots/chrome/pulse-5-before.png differ
diff --git a/cta/shots/chrome/pulse-6-after.png b/cta/shots/chrome/pulse-6-after.png
new file mode 100644
index 0000000..9514335
Binary files /dev/null and b/cta/shots/chrome/pulse-6-after.png differ
diff --git a/cta/shots/chrome/pulse-6-before.png b/cta/shots/chrome/pulse-6-before.png
new file mode 100644
index 0000000..2558ebc
Binary files /dev/null and b/cta/shots/chrome/pulse-6-before.png differ
diff --git a/cta/shots/chrome/pulse-7-after.png b/cta/shots/chrome/pulse-7-after.png
new file mode 100644
index 0000000..3499b2d
Binary files /dev/null and b/cta/shots/chrome/pulse-7-after.png differ
diff --git a/cta/shots/chrome/pulse-7-before.png b/cta/shots/chrome/pulse-7-before.png
new file mode 100644
index 0000000..5203461
Binary files /dev/null and b/cta/shots/chrome/pulse-7-before.png differ
diff --git a/cta/shots/chrome/pulse-8-after.png b/cta/shots/chrome/pulse-8-after.png
new file mode 100644
index 0000000..41416fc
Binary files /dev/null and b/cta/shots/chrome/pulse-8-after.png differ
diff --git a/cta/shots/chrome/pulse-8-before.png b/cta/shots/chrome/pulse-8-before.png
new file mode 100644
index 0000000..7129001
Binary files /dev/null and b/cta/shots/chrome/pulse-8-before.png differ
diff --git a/cta/shots/chrome/pulse-9-after.png b/cta/shots/chrome/pulse-9-after.png
new file mode 100644
index 0000000..74c2cc7
Binary files /dev/null and b/cta/shots/chrome/pulse-9-after.png differ
diff --git a/cta/shots/chrome/pulse-9-before.png b/cta/shots/chrome/pulse-9-before.png
new file mode 100644
index 0000000..b51114f
Binary files /dev/null and b/cta/shots/chrome/pulse-9-before.png differ
diff --git a/cta/shots/chrome/pulse-page.png b/cta/shots/chrome/pulse-page.png
new file mode 100644
index 0000000..7f2500a
Binary files /dev/null and b/cta/shots/chrome/pulse-page.png differ
diff --git a/cta/shots/safari/pulse-0-after.png b/cta/shots/safari/pulse-0-after.png
new file mode 100644
index 0000000..594084e
Binary files /dev/null and b/cta/shots/safari/pulse-0-after.png differ
diff --git a/cta/shots/safari/pulse-0-before.png b/cta/shots/safari/pulse-0-before.png
new file mode 100644
index 0000000..3767efd
Binary files /dev/null and b/cta/shots/safari/pulse-0-before.png differ
diff --git a/cta/shots/safari/pulse-1-after.png b/cta/shots/safari/pulse-1-after.png
new file mode 100644
index 0000000..bd955c9
Binary files /dev/null and b/cta/shots/safari/pulse-1-after.png differ
diff --git a/cta/shots/safari/pulse-1-before.png b/cta/shots/safari/pulse-1-before.png
new file mode 100644
index 0000000..3107647
Binary files /dev/null and b/cta/shots/safari/pulse-1-before.png differ
diff --git a/cta/shots/safari/pulse-10-after.png b/cta/shots/safari/pulse-10-after.png
new file mode 100644
index 0000000..e4d4f6a
Binary files /dev/null and b/cta/shots/safari/pulse-10-after.png differ
diff --git a/cta/shots/safari/pulse-10-before.png b/cta/shots/safari/pulse-10-before.png
new file mode 100644
index 0000000..cc35156
Binary files /dev/null and b/cta/shots/safari/pulse-10-before.png differ
diff --git a/cta/shots/safari/pulse-11-after.png b/cta/shots/safari/pulse-11-after.png
new file mode 100644
index 0000000..32dc4fd
Binary files /dev/null and b/cta/shots/safari/pulse-11-after.png differ
diff --git a/cta/shots/safari/pulse-11-before.png b/cta/shots/safari/pulse-11-before.png
new file mode 100644
index 0000000..36b33df
Binary files /dev/null and b/cta/shots/safari/pulse-11-before.png differ
diff --git a/cta/shots/safari/pulse-2-after.png b/cta/shots/safari/pulse-2-after.png
new file mode 100644
index 0000000..3726c19
Binary files /dev/null and b/cta/shots/safari/pulse-2-after.png differ
diff --git a/cta/shots/safari/pulse-2-before.png b/cta/shots/safari/pulse-2-before.png
new file mode 100644
index 0000000..4a517e9
Binary files /dev/null and b/cta/shots/safari/pulse-2-before.png differ
diff --git a/cta/shots/safari/pulse-3-after.png b/cta/shots/safari/pulse-3-after.png
new file mode 100644
index 0000000..4064528
Binary files /dev/null and b/cta/shots/safari/pulse-3-after.png differ
diff --git a/cta/shots/safari/pulse-3-before.png b/cta/shots/safari/pulse-3-before.png
new file mode 100644
index 0000000..452df60
Binary files /dev/null and b/cta/shots/safari/pulse-3-before.png differ
diff --git a/cta/shots/safari/pulse-4-after.png b/cta/shots/safari/pulse-4-after.png
new file mode 100644
index 0000000..32f669f
Binary files /dev/null and b/cta/shots/safari/pulse-4-after.png differ
diff --git a/cta/shots/safari/pulse-4-before.png b/cta/shots/safari/pulse-4-before.png
new file mode 100644
index 0000000..72265cc
Binary files /dev/null and b/cta/shots/safari/pulse-4-before.png differ
diff --git a/cta/shots/safari/pulse-5-after.png b/cta/shots/safari/pulse-5-after.png
new file mode 100644
index 0000000..74d2677
Binary files /dev/null and b/cta/shots/safari/pulse-5-after.png differ
diff --git a/cta/shots/safari/pulse-5-before.png b/cta/shots/safari/pulse-5-before.png
new file mode 100644
index 0000000..06fa579
Binary files /dev/null and b/cta/shots/safari/pulse-5-before.png differ
diff --git a/cta/shots/safari/pulse-6-after.png b/cta/shots/safari/pulse-6-after.png
new file mode 100644
index 0000000..cb6314a
Binary files /dev/null and b/cta/shots/safari/pulse-6-after.png differ
diff --git a/cta/shots/safari/pulse-6-before.png b/cta/shots/safari/pulse-6-before.png
new file mode 100644
index 0000000..f85277f
Binary files /dev/null and b/cta/shots/safari/pulse-6-before.png differ
diff --git a/cta/shots/safari/pulse-7-after.png b/cta/shots/safari/pulse-7-after.png
new file mode 100644
index 0000000..423da33
Binary files /dev/null and b/cta/shots/safari/pulse-7-after.png differ
diff --git a/cta/shots/safari/pulse-7-before.png b/cta/shots/safari/pulse-7-before.png
new file mode 100644
index 0000000..d29125b
Binary files /dev/null and b/cta/shots/safari/pulse-7-before.png differ
diff --git a/cta/shots/safari/pulse-8-after.png b/cta/shots/safari/pulse-8-after.png
new file mode 100644
index 0000000..5d0ac42
Binary files /dev/null and b/cta/shots/safari/pulse-8-after.png differ
diff --git a/cta/shots/safari/pulse-8-before.png b/cta/shots/safari/pulse-8-before.png
new file mode 100644
index 0000000..e95a81c
Binary files /dev/null and b/cta/shots/safari/pulse-8-before.png differ
diff --git a/cta/shots/safari/pulse-9-after.png b/cta/shots/safari/pulse-9-after.png
new file mode 100644
index 0000000..48c8542
Binary files /dev/null and b/cta/shots/safari/pulse-9-after.png differ
diff --git a/cta/shots/safari/pulse-9-before.png b/cta/shots/safari/pulse-9-before.png
new file mode 100644
index 0000000..85303de
Binary files /dev/null and b/cta/shots/safari/pulse-9-before.png differ
diff --git a/cta/shots/safari/pulse-page.png b/cta/shots/safari/pulse-page.png
new file mode 100644
index 0000000..1f3d375
Binary files /dev/null and b/cta/shots/safari/pulse-page.png differ
← 8bb3668 auto-data-snapshot: 2026-09-10T19:39:42 (1 data files) — age
·
back to Norma
·
auto-data-snapshot: 2026-09-11T08:16:47 (3 data files) — age 2f0cf94 →