← back to Norma
IG post path: durable jewelry-counter exclusion gate (TK-12012)
8f1455a4f101a007dc7f7775f7ff9f41c31bb99d · 2026-09-22 11:09:00 -0700 · Steve Abrams
Steve directive 2026-09-22: stop posting any Instagram with jewelry counters
(scope = jewelry ONLY; room-setting carousels stay). Wire the vision jewelry
check into the Norma post path so no jewelry-counter image can be published.
- jewelry-gate.js: classify each candidate image at SELECTION time (ollama
qwen2.5vl free / Gemini fallback), URL/file-keyed cache so vision is called
once per image. Blocks jewelry:true AND undecidable (fail-closed); never
drops room:true.
- post-to.js: gate carousel + single feed/story image URLs before publish;
carousel degrades to single when a slide is dropped, skips when all blocked.
Posting stays DISARMED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRkDtx31oCvHHaMfJf9CzT
Files touched
A agents/instagram-agent/jewelry-gate.jsM agents/instagram-agent/post-to.js
Diff
commit 8f1455a4f101a007dc7f7775f7ff9f41c31bb99d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 22 11:09:00 2026 -0700
IG post path: durable jewelry-counter exclusion gate (TK-12012)
Steve directive 2026-09-22: stop posting any Instagram with jewelry counters
(scope = jewelry ONLY; room-setting carousels stay). Wire the vision jewelry
check into the Norma post path so no jewelry-counter image can be published.
- jewelry-gate.js: classify each candidate image at SELECTION time (ollama
qwen2.5vl free / Gemini fallback), URL/file-keyed cache so vision is called
once per image. Blocks jewelry:true AND undecidable (fail-closed); never
drops room:true.
- post-to.js: gate carousel + single feed/story image URLs before publish;
carousel degrades to single when a slide is dropped, skips when all blocked.
Posting stays DISARMED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRkDtx31oCvHHaMfJf9CzT
---
agents/instagram-agent/jewelry-gate.js | 189 +++++++++++++++++++++++++++++++++
agents/instagram-agent/post-to.js | 30 +++++-
2 files changed, 218 insertions(+), 1 deletion(-)
diff --git a/agents/instagram-agent/jewelry-gate.js b/agents/instagram-agent/jewelry-gate.js
new file mode 100644
index 0000000..0c2ce28
--- /dev/null
+++ b/agents/instagram-agent/jewelry-gate.js
@@ -0,0 +1,189 @@
+/**
+ * jewelry-gate.js — DURABLE jewelry-counter exclusion gate for the IG post path.
+ *
+ * Steve, 2026-09-22: "stop creating any instagrams with jewelry counters."
+ * Scope = JEWELRY ONLY. Room-setting carousels stay — this gate NEVER drops a
+ * room:true image, only jewelry:true (and anything it can't decide).
+ *
+ * Any image about to be published is classified at SELECTION time. An image the
+ * classifier flags jewelry:true — OR that it CANNOT decide (backend down / parse
+ * fail / missing file) — is treated as NOT postable and excluded. Fail-CLOSED:
+ * an un-measured image is never "safe to post" (CLAUDE.md TK-11431 amendment 1).
+ *
+ * Results are cached (data/jewelry-cache.json) keyed by the image's own identity
+ * (URL, or localpath+mtime+size) so vision is called at most once per image, not
+ * on every cadence run. Only DECIDED verdicts are cached — a transient null is
+ * never persisted, so a later run re-measures it.
+ *
+ * Detector: the same binary vision question as vision-classify.mjs /
+ * vision-sweep-local.mjs. Backend order (all local/free-first):
+ * 1. ollama qwen2.5vl on OLLAMA_HOSTS (default 127.0.0.1,192.168.1.133) — $0
+ * 2. Gemini (GEMINI_API_KEY) as a fallback
+ * If every backend fails -> verdict null -> image is BLOCKED (fail-closed).
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const crypto = require('crypto');
+
+const HERE = __dirname;
+const CACHE_FILE = path.join(HERE, 'data', 'jewelry-cache.json');
+const OLLAMA_HOSTS = (process.env.OLLAMA_HOSTS || '127.0.0.1,192.168.1.133').split(',').map((s) => s.trim()).filter(Boolean);
+const VL_MODEL = process.env.VL_MODEL || 'qwen2.5vl:7b';
+const CALL_TIMEOUT = Number(process.env.JEWELRY_CALL_TIMEOUT_MS || 60000);
+
+// The jewelry question, phrased identically to the existing detectors so the
+// verdict is consistent across the sweep and the live gate.
+const PROMPT = `Classify this wallpaper/wallcovering 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. 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, or jewelry-store glass display presenting the product (rings, necklaces on busts, earrings on stands in a glass/lit case).
+Reply with ONLY a compact JSON object, no prose: {"room":true|false,"jewelry":true|false,"scene":"3-6 word description"}`;
+
+function loadCache() {
+ try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
+}
+function saveCache(c) {
+ try {
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
+ } catch { /* cache is best-effort; a write failure must never post an unclassified image */ }
+}
+
+/** ollama qwen2.5vl — reads the file in Node (no shell arg-length limit). */
+async function classifyOllama(host, buf) {
+ const b64 = buf.toString('base64');
+ 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: VL_MODEL, prompt: PROMPT, images: [b64], stream: false, format: 'json', options: { temperature: 0, num_predict: 80 } }),
+ });
+ clearTimeout(to);
+ if (!res.ok) return null;
+ const j = await res.json();
+ const m = String(j.response || '').match(/\{[\s\S]*\}/);
+ if (!m) return null;
+ const p = JSON.parse(m[0]);
+ return { room: !!p.room, jewelry: !!p.jewelry, scene: String(p.scene || '').slice(0, 60), backend: `ollama:${host}` };
+ } catch { clearTimeout(to); return null; }
+}
+
+/** Gemini fallback (GEMINI_API_KEY from secrets-manager/.env). */
+let GEMINI_KEY = null;
+function geminiKey() {
+ if (GEMINI_KEY !== null) return GEMINI_KEY;
+ try {
+ const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+ GEMINI_KEY = (env.match(/^GEMINI_API_KEY=(.+)$/m) || [])[1]?.trim().replace(/"/g, '') || '';
+ } catch { GEMINI_KEY = ''; }
+ return GEMINI_KEY;
+}
+async function classifyGemini(buf, mime) {
+ const key = geminiKey();
+ if (!key) return null;
+ const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
+ const ep = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
+ const ac = new AbortController();
+ const to = setTimeout(() => ac.abort(), CALL_TIMEOUT);
+ try {
+ const res = await fetch(ep, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: buf.toString('base64') } }] }],
+ generationConfig: { temperature: 0, maxOutputTokens: 200 },
+ }),
+ });
+ clearTimeout(to);
+ if (!res.ok) return null;
+ const j = await res.json();
+ const m = String(j?.candidates?.[0]?.content?.parts?.[0]?.text || '').match(/\{[\s\S]*\}/);
+ if (!m) return null;
+ const p = JSON.parse(m[0]);
+ return { room: !!p.room, jewelry: !!p.jewelry, scene: String(p.scene || '').slice(0, 60), backend: `gemini:${model}` };
+ } catch { clearTimeout(to); return null; }
+}
+
+/** Classify a Buffer through the backend chain. Returns a verdict or null (undecidable). */
+async function classifyBuffer(buf, mime) {
+ for (const host of OLLAMA_HOSTS) {
+ const v = await classifyOllama(host, buf);
+ if (v) return v;
+ }
+ const g = await classifyGemini(buf, mime || 'image/jpeg');
+ if (g) return g;
+ return null;
+}
+
+const mimeFor = (p) => (/\.png($|\?)/i.test(p) ? 'image/png' : 'image/jpeg');
+
+/**
+ * Classify a LOCAL image file. Cache key = abspath|mtime|size (re-measures if the
+ * file changes). Returns { jewelry, room, scene, decided, backend }.
+ */
+async function classifyFile(filePath) {
+ let st;
+ try { st = fs.statSync(filePath); } catch {
+ return { jewelry: null, room: null, scene: 'NO_FILE', decided: false };
+ }
+ const key = `file:${path.resolve(filePath)}|${st.mtimeMs}|${st.size}`;
+ const cache = loadCache();
+ if (cache[key] && cache[key].decided) return cache[key];
+ const v = await classifyBuffer(fs.readFileSync(filePath), mimeFor(filePath));
+ const out = v
+ ? { jewelry: v.jewelry, room: v.room, scene: v.scene, decided: true, backend: v.backend, at: new Date().toISOString() }
+ : { jewelry: null, room: null, scene: 'UNDECIDED', decided: false };
+ if (out.decided) { cache[key] = out; saveCache(cache); }
+ return out;
+}
+
+/** Download a URL to a temp file, classify, delete the temp. Cache key = URL. */
+async function classifyUrl(url) {
+ const key = `url:${url}`;
+ const cache = loadCache();
+ if (cache[key] && cache[key].decided) return cache[key];
+ let buf, mime = 'image/jpeg';
+ try {
+ const u = url.startsWith('//') ? 'https:' + url : url;
+ const r = await fetch(u, { headers: { 'User-Agent': 'dw-jewelry-gate' } });
+ if (!r.ok) return { jewelry: null, room: null, scene: `FETCH_${r.status}`, decided: false };
+ buf = Buffer.from(await r.arrayBuffer());
+ mime = r.headers.get('content-type') || mimeFor(url);
+ } catch { return { jewelry: null, room: null, scene: 'FETCH_FAIL', decided: false }; }
+ const v = await classifyBuffer(buf, mime);
+ const out = v
+ ? { jewelry: v.jewelry, room: v.room, scene: v.scene, decided: true, backend: v.backend, at: new Date().toISOString() }
+ : { jewelry: null, room: null, scene: 'UNDECIDED', decided: false };
+ if (out.decided) { cache[key] = out; saveCache(cache); }
+ return out;
+}
+
+/**
+ * Gate a list of image URLs. Returns { postable[], blocked[] } where blocked
+ * carries the reason. An image is postable ONLY if decided AND jewelry===false.
+ * jewelry===true -> blocked('jewelry'); undecidable -> blocked('undecided').
+ */
+async function gateUrls(urls) {
+ const postable = [], blocked = [];
+ for (const url of urls) {
+ const v = await classifyUrl(url);
+ if (v.decided && v.jewelry === false) postable.push(url);
+ else blocked.push({ url, reason: v.jewelry === true ? 'jewelry' : 'undecided', scene: v.scene });
+ }
+ return { postable, blocked };
+}
+
+module.exports = { classifyFile, classifyUrl, classifyBuffer, gateUrls, PROMPT };
+
+// CLI: `node jewelry-gate.js <file-or-url> [...]` — prints one verdict per line.
+if (require.main === module) {
+ (async () => {
+ const args = process.argv.slice(2);
+ if (!args.length) { console.error('usage: node jewelry-gate.js <file-or-url> [...]'); process.exit(1); }
+ for (const a of args) {
+ const v = /^https?:\/\//.test(a) || a.startsWith('//') ? await classifyUrl(a) : await classifyFile(a);
+ const verdict = !v.decided ? 'BLOCK(undecided)' : v.jewelry ? 'BLOCK(jewelry)' : 'PASS';
+ console.log(`${verdict} ${a} -> ${JSON.stringify({ jewelry: v.jewelry, room: v.room, scene: v.scene, backend: v.backend })}`);
+ }
+ })();
+}
diff --git a/agents/instagram-agent/post-to.js b/agents/instagram-agent/post-to.js
index ebe5f3e..2585493 100644
--- a/agents/instagram-agent/post-to.js
+++ b/agents/instagram-agent/post-to.js
@@ -24,6 +24,25 @@
const accounts = require('./accounts');
const content = require('./content');
+const jewelryGate = require('./jewelry-gate');
+
+// JEWELRY-COUNTER EXCLUSION (Steve, 2026-09-22): no image showing a jewelry
+// display case may be published. Classify every candidate image URL at post
+// time and drop jewelry:true (and any the classifier can't decide — fail-closed).
+// Room-setting images are NEVER dropped. Returns the filtered URL list; throws
+// if nothing postable survives. Set JEWELRY_GATE=0 only for an explicit,
+// deliberate bypass (never in the cadence).
+async function jewelryFilter(urls, handle) {
+ if (process.env.JEWELRY_GATE === '0') return urls;
+ const { postable, blocked } = await jewelryGate.gateUrls(urls);
+ if (blocked.length) {
+ for (const b of blocked) console.log(` ⊘ @${handle} — excluded ${b.reason} image (${b.scene || ''}): ${b.url}`);
+ }
+ if (!postable.length) {
+ throw new Error(`all ${urls.length} candidate image(s) excluded by jewelry gate (${blocked.map((b) => b.reason).join(',')}) — nothing to post`);
+ }
+ return postable;
+}
function parseArgs(argv) {
const a = { _: [] };
@@ -114,13 +133,22 @@ async function publishOne(acct, opts) {
// Carousel: --images "url1,url2,..." (2–10 images)
if (opts.images) {
- const urls = String(opts.images).split(',').map((s) => s.trim()).filter(Boolean);
+ let urls = String(opts.images).split(',').map((s) => s.trim()).filter(Boolean);
+ urls = await jewelryFilter(urls, acct.handle); // drop any jewelry-counter slide
+ // A carousel needs ≥2 images; if the gate leaves exactly one, post it as a
+ // single image rather than failing the whole post.
+ if (urls.length === 1) return publishOne(acct, { ...opts, images: undefined, image: urls[0] });
return publishCarousel(acct, urls, opts.caption || '', dry);
}
const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
const kind = opts.reel ? 'REELS' : opts.story ? 'STORIES' : 'IMAGE';
+ // Gate single still-image posts (feed image, or a photo story). Video kinds
+ // (--reel, an .mp4 story) are gated in the reel pipeline (publish-social.mjs).
+ if (opts.image) { [opts.image] = await jewelryFilter([opts.image], acct.handle); }
+ else if (opts.story && !/\.mp4($|\?)/i.test(opts.story)) { [opts.story] = await jewelryFilter([opts.story], acct.handle); }
+
// Build the media-container params for the requested kind
const container = { access_token: token };
if (opts.reel) { container.media_type = 'REELS'; container.video_url = opts.reel; }
← 987ab62 instagram-agent: add loopback co-listener so 127.0.0.1:9810
·
back to Norma
·
auto-data-snapshot: 2026-09-22T11:11:00 (1 data files) — age 087f2d7 →