← back to Wallco Ai
trade-login: wire PureLyMail SMTP (smtp.purelymail.com:465 SSL, auth noreply@agentabrams.com — same pattern as george-gmail). nodemailer installed. MAIL_FROM='wallco.ai sign-in <noreply@agentabrams.com>' (change after adding wallco.ai to that mailbox's allowed-sender list). Test sent successfully — sent:true via smtp
54a2c780908573bd8e8d4afa4e9a7580cf2103df · 2026-05-28 09:49:05 -0700 · Steve Abrams
Files touched
M data/cactus-decisions.jsonlM package-lock.jsonM package.jsonA scripts/vision-contains.py
Diff
commit 54a2c780908573bd8e8d4afa4e9a7580cf2103df
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 09:49:05 2026 -0700
trade-login: wire PureLyMail SMTP (smtp.purelymail.com:465 SSL, auth noreply@agentabrams.com — same pattern as george-gmail). nodemailer installed. MAIL_FROM='wallco.ai sign-in <noreply@agentabrams.com>' (change after adding wallco.ai to that mailbox's allowed-sender list). Test sent successfully — sent:true via smtp
---
data/cactus-decisions.jsonl | 19 +++++++++++++++++++
package-lock.json | 10 ++++++++++
package.json | 1 +
scripts/vision-contains.py | 43 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 73 insertions(+)
diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index 09964f3..53bc7dc 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -3876,3 +3876,22 @@
{"ts":"2026-05-28T16:39:59.805Z","id":27685,"action":"bad"}
{"ts":"2026-05-28T16:40:00.979Z","id":27916,"action":"bad"}
{"ts":"2026-05-28T16:40:10.233Z","id":24888,"action":"bad"}
+{"ts":"2026-05-28T16:40:16.381Z","id":27375,"action":"live"}
+{"ts":"2026-05-28T16:40:22.809Z","id":10727,"action":"bad"}
+{"ts":"2026-05-28T16:40:28.478Z","id":10778,"action":"bad"}
+{"ts":"2026-05-28T16:40:34.125Z","id":11662,"action":"bad"}
+{"ts":"2026-05-28T16:40:38.163Z","id":12210,"action":"bad"}
+{"ts":"2026-05-28T16:40:39.846Z","id":12234,"action":"bad"}
+{"ts":"2026-05-28T16:40:42.463Z","id":12452,"action":"bad"}
+{"ts":"2026-05-28T16:40:48.930Z","id":13439,"action":"bad"}
+{"ts":"2026-05-28T16:40:49.064Z","id":12698,"action":"bad"}
+{"ts":"2026-05-28T16:41:01.682Z","id":14885,"action":"bad"}
+{"ts":"2026-05-28T16:41:02.518Z","id":14690,"action":"bad"}
+{"ts":"2026-05-28T16:41:04.736Z","id":15395,"action":"bad"}
+{"ts":"2026-05-28T16:41:06.874Z","id":15692,"action":"bad"}
+{"ts":"2026-05-28T16:41:16.186Z","id":15512,"action":"live"}
+{"ts":"2026-05-28T16:41:20.485Z","id":16338,"action":"bad"}
+{"ts":"2026-05-28T16:41:22.590Z","id":16241,"action":"bad"}
+{"ts":"2026-05-28T16:41:23.595Z","id":16366,"action":"bad"}
+{"ts":"2026-05-28T16:41:24.548Z","id":17278,"action":"bad"}
+{"ts":"2026-05-28T16:41:27.589Z","id":20171,"action":"bad"}
diff --git a/package-lock.json b/package-lock.json
index 0ed869e..df99503 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"express-rate-limit": "^8.5.1",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
+ "nodemailer": "^8.0.9",
"pg": "^8.20.0"
}
},
@@ -830,6 +831,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemailer": {
+ "version": "8.0.9",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz",
+ "integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
diff --git a/package.json b/package.json
index 65a4891..31ba411 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"express-rate-limit": "^8.5.1",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
+ "nodemailer": "^8.0.9",
"pg": "^8.20.0"
}
}
diff --git a/scripts/vision-contains.py b/scripts/vision-contains.py
new file mode 100644
index 0000000..ca9d8df
--- /dev/null
+++ b/scripts/vision-contains.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""Yes/no vision gate via Gemini 2.5 flash. Used by the regen loop as the 3rd
+gate after seam-PASS + flat-color: rejects rolls that drifted off-concept
+(e.g. abstract botanical when the prompt asked for animal silhouettes).
+
+Usage: vision-contains.py <png_path> <concept-question>
+Prints: yes|no
+"""
+import os, sys, base64, json, urllib.request
+
+png, question = sys.argv[1], sys.argv[2]
+# Load GEMINI_API_KEY from project .env if not in env
+if not os.environ.get('GEMINI_API_KEY'):
+ try:
+ for line in open(os.path.join(os.path.dirname(__file__), '..', '.env')).read().splitlines():
+ if line.startswith('GEMINI_API_KEY='):
+ os.environ['GEMINI_API_KEY'] = line.split('=', 1)[1].strip().strip('"').strip("'")
+ break
+ except Exception: pass
+
+key = os.environ.get('GEMINI_API_KEY')
+if not key: print('no'); sys.exit(2)
+
+img_b64 = base64.b64encode(open(png, 'rb').read()).decode()
+body = {
+ 'contents': [{'parts': [
+ {'text': question + ' Reply with exactly one word: yes or no.'},
+ {'inline_data': {'mime_type': 'image/png', 'data': img_b64}},
+ ]}],
+ 'generation_config': {'temperature': 0, 'max_output_tokens': 8, 'thinking_config': {'thinking_budget': 0}},
+}
+req = urllib.request.Request(
+ f'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={key}',
+ data=json.dumps(body).encode(),
+ headers={'Content-Type': 'application/json'},
+)
+try:
+ with urllib.request.urlopen(req, timeout=20) as r:
+ j = json.load(r)
+ txt = (((j.get('candidates') or [{}])[0].get('content') or {}).get('parts') or [{}])[0].get('text', '').strip().lower()
+ print('yes' if txt.startswith('yes') else 'no')
+except Exception as e:
+ print('no', file=sys.stderr); print('no')
← 1f77520 cactus-curator: 📦 TIF badge on every card (green when archi
·
back to Wallco Ai
·
design page: sticky admin toolbar (Unpublish / Smart-fix / S 88d2c1f →