[object Object]

← back to Norma

snapshot before TK-11315 browser verification

cd1437ddae997a7072d853dd91ae542ff0652aa4 · 2026-09-11 08:12:19 -0700 · Steve Abrams

Files touched

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 →