← back to Videos Agentabrams Worker
initial scaffold (gitify-all 2026-05-06)
e82f4cbb76bdad7221906d296f2bc0c838da5926 · 2026-05-06 10:25:51 -0700 · Steve Abrams
Files touched
A .gitignoreA cursor.txtA flow-map.jsonA log.jsonlA poll.py
Diff
commit e82f4cbb76bdad7221906d296f2bc0c838da5926
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:25:51 2026 -0700
initial scaffold (gitify-all 2026-05-06)
---
.gitignore | 12 +++
cursor.txt | 1 +
flow-map.json | 26 +++++++
log.jsonl | 10 +++
poll.py | 237 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 286 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e6a9c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/cursor.txt b/cursor.txt
new file mode 100644
index 0000000..46c50e8
--- /dev/null
+++ b/cursor.txt
@@ -0,0 +1 @@
+2026-05-06T17:10:00.000Z
diff --git a/flow-map.json b/flow-map.json
new file mode 100644
index 0000000..01d2171
--- /dev/null
+++ b/flow-map.json
@@ -0,0 +1,26 @@
+{
+ "Ad-Signals-Rolodex": {
+ "flow_file": "Ad-Signals-Rolodex.json",
+ "url": "/Users/stevestudio2/Projects/ad-signals-rolodex/rolodex.html",
+ "app_name": "Ad-Signals-Rolodex",
+ "selectors_hint": "th (sortable column headers); .btn-pitch (per-row pitch button); #themeBtn (light/dark toggle); .row.tier-1 (Tier-A rows); .row.tier-2 (Tier-B); body (whole page); table tbody tr (any row)"
+ },
+ "Animals-Pitch": {
+ "flow_file": "Animals-Pitch.json",
+ "url": "/Users/stevestudio2/Projects/animals/exports/animals-sales-pitch-latest.html",
+ "app_name": "Animals-Pitch",
+ "selectors_hint": "th (sort columns); .btn-pitch (pitch button); #themeBtn (theme toggle); tr.tier-1, tr.tier1, tr.tier-A (Tier-A row variants); table tbody tr:first-child (top row); .filter-tier (tier filter)"
+ },
+ "Doctor-Pitch": {
+ "flow_file": "Doctor-Pitch.json",
+ "url": "/Users/stevestudio2/Projects/professional-directory/exports/doctor-sales-pitch-latest.html",
+ "app_name": "Doctor-Pitch",
+ "selectors_hint": "th; .btn-pitch; #themeBtn; tr.tier-1; table tbody tr:first-child"
+ },
+ "Ventura-Corridor": {
+ "flow_file": "Ventura-Corridor.json",
+ "url": "http://127.0.0.1:9780/",
+ "app_name": "Ventura-Corridor",
+ "selectors_hint": "#gs-input (search box); .gs-cell (any business tile); .b-3d (3D billboard); #stats (top counts); .theme-toggle-host (theme); #panel (detail panel); .b-tier (tier badge); .t.A, .t.B (tier-A and tier-B class)"
+ }
+}
diff --git a/log.jsonl b/log.jsonl
new file mode 100644
index 0000000..d85cc7a
--- /dev/null
+++ b/log.jsonl
@@ -0,0 +1,10 @@
+{"event": "tick", "new_count": 1, "cursor": "2026-05-06T17:10:00.000Z", "ts": "2026-05-06T17:11:05.906425+00:00"}
+{"event": "process_start", "request_ts": "2026-05-06T17:10:43.483Z", "video_ids": ["Animals-Pitch-2026-05-06.mp4"], "message": "add a hover step on Tier-A row before clicking the pitch button \u2014 make narration punchier", "ts": "2026-05-06T17:11:05.906722+00:00"}
+{"event": "ollama_error", "error": "timed out", "ts": "2026-05-06T17:14:05.911236+00:00"}
+{"event": "no_change", "video_id": "Animals-Pitch-2026-05-06.mp4", "ts": "2026-05-06T17:14:05.912156+00:00"}
+{"event": "tick", "new_count": 1, "cursor": "2026-05-06T17:10:00.000Z", "ts": "2026-05-06T17:14:24.832079+00:00"}
+{"event": "process_start", "request_ts": "2026-05-06T17:10:43.483Z", "video_ids": ["Animals-Pitch-2026-05-06.mp4"], "message": "add a hover step on Tier-A row before clicking the pitch button \u2014 make narration punchier", "ts": "2026-05-06T17:14:24.832254+00:00"}
+{"event": "ollama_error", "error": "timed out", "ts": "2026-05-06T17:20:24.837376+00:00"}
+{"event": "no_change", "video_id": "Animals-Pitch-2026-05-06.mp4", "ts": "2026-05-06T17:20:24.846187+00:00"}
+{"event": "tick", "new_count": 1, "cursor": "2026-05-06T17:10:00.000Z", "ts": "2026-05-06T17:21:27.115638+00:00"}
+{"event": "process_start", "request_ts": "2026-05-06T17:10:43.483Z", "video_ids": ["Animals-Pitch-2026-05-06.mp4"], "message": "add a hover step on Tier-A row before clicking the pitch button \u2014 make narration punchier", "ts": "2026-05-06T17:21:27.116457+00:00"}
diff --git a/poll.py b/poll.py
new file mode 100755
index 0000000..2d1a0a6
--- /dev/null
+++ b/poll.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""
+videos.agentabrams.com chat → auto-regenerate videos.
+
+Polls /api/requests, processes new entries, edits flow JSON via local Ollama
+(qwen3:14b on Mac1), re-runs ship-video which re-publishes to Kamatera.
+
+Cursor (last-processed timestamp) lives at cursor.txt to avoid re-processing.
+First run starts cursor at "now" so legacy/test entries are skipped.
+"""
+import os, sys, json, time, base64, subprocess, urllib.request, urllib.error, re
+from pathlib import Path
+from datetime import datetime, timezone
+
+ROOT = Path.home() / 'Projects' / 'videos-agentabrams-worker'
+ROOT.mkdir(parents=True, exist_ok=True)
+CURSOR = ROOT / 'cursor.txt'
+LOG = ROOT / 'log.jsonl'
+MAP_FILE = ROOT / 'flow-map.json'
+FLOWS_DIR = Path.home() / '.claude' / 'skills' / 'app-demo-video' / 'flows'
+SHIP_VIDEO = Path.home() / 'bin' / 'ship-video'
+OLLAMA_URL = os.environ.get('OLLAMA_URL', 'http://192.168.1.133:11434')
+OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'qwen3:14b')
+
+API_BASE = 'https://videos.agentabrams.com'
+API_AUTH = ('admin', 'DWSecure2024!')
+
+
+def log_event(entry):
+ entry['ts'] = datetime.now(timezone.utc).isoformat()
+ with LOG.open('a') as f:
+ f.write(json.dumps(entry) + '\n')
+
+
+def http_get(url):
+ req = urllib.request.Request(url)
+ token = base64.b64encode(f'{API_AUTH[0]}:{API_AUTH[1]}'.encode()).decode()
+ req.add_header('Authorization', f'Basic {token}')
+ with urllib.request.urlopen(req, timeout=30) as r:
+ return json.loads(r.read())
+
+
+def call_ollama(system, user, timeout=360):
+ # /no_think disables qwen3 reasoning tokens (massive speedup, otherwise hangs on long prompts)
+ body = json.dumps({
+ 'model': OLLAMA_MODEL,
+ 'system': system,
+ 'prompt': '/no_think\n\n' + user,
+ 'stream': False,
+ 'format': 'json',
+ 'options': {'temperature': 0.4, 'num_ctx': 8192, 'num_predict': 2048},
+ }).encode()
+ req = urllib.request.Request(
+ f'{OLLAMA_URL}/api/generate',
+ data=body,
+ headers={'Content-Type': 'application/json'},
+ )
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return json.loads(r.read())['response']
+
+
+def vid_id_to_slug(vid_id):
+ """'Animals-Pitch-2026-05-06.mp4' → 'Animals-Pitch'"""
+ base = vid_id[:-4] if vid_id.endswith('.mp4') else vid_id
+ return re.sub(r'-\d{4}-\d{2}-\d{2}$', '', base)
+
+
+def get_cursor():
+ if CURSOR.exists():
+ return CURSOR.read_text().strip()
+ now = datetime.now(timezone.utc).isoformat()
+ CURSOR.write_text(now)
+ return now
+
+
+def set_cursor(ts):
+ CURSOR.write_text(ts)
+
+
+VALID_ACTIONS = {'wait', 'scroll', 'click', 'hover'}
+
+
+def validate_flow(flow):
+ if not isinstance(flow, dict): return False, 'not a dict'
+ if 'steps' not in flow or not isinstance(flow['steps'], list): return False, 'no steps[]'
+ if not flow['steps']: return False, 'empty steps'
+ if len(flow['steps']) > 16: return False, f'too many steps ({len(flow["steps"])})'
+ for i, s in enumerate(flow['steps']):
+ if not isinstance(s, dict): return False, f'step {i} not dict'
+ if s.get('action') not in VALID_ACTIONS:
+ return False, f'step {i} invalid action: {s.get("action")}'
+ if s['action'] in ('click', 'hover') and not s.get('selector'):
+ return False, f'step {i} ({s["action"]}) missing selector'
+ return True, 'ok'
+
+
+def edit_flow_via_llm(current_flow, user_msg, entry):
+ system = (
+ "You edit JSON flow files for a web-app demo video recorder.\n\n"
+ "Schema: { \"steps\": [ ... ] }\n\n"
+ "Action shapes:\n"
+ " {\"action\":\"wait\",\"ms\":<int 500-3000>,\"narration\":\"<5-10 words>\"}\n"
+ " {\"action\":\"scroll\",\"y\":<int>,\"duration\":<ms>,\"narration\":\"<5-10 words>\"}\n"
+ " {\"action\":\"click\",\"selector\":\"<css>\",\"narration\":\"<5-10 words>\"}\n"
+ " {\"action\":\"hover\",\"selector\":\"<css>\",\"narration\":\"<5-10 words>\"}\n\n"
+ "Rules:\n"
+ "- Use ONLY the four action types above. There is no \"type\" action.\n"
+ "- Selectors must be valid CSS, ideally drawn from the provided selector hints.\n"
+ "- Narration: short, punchy, 5-10 words, sales-pitch tone.\n"
+ "- Total steps must be ≥6 and ≤14.\n"
+ "- Output VALID JSON object only. NO commentary, NO markdown fences."
+ )
+ user = (
+ f"Current flow JSON:\n{json.dumps(current_flow, indent=2)}\n\n"
+ f"App: {entry['app_name']}\n"
+ f"URL: {entry['url']}\n"
+ f"Available selectors / hints: {entry.get('selectors_hint', '(none)')}\n\n"
+ f"User request: \"{user_msg}\"\n\n"
+ "Output the COMPLETE updated flow JSON. Be creative — change narration tone, "
+ "add hovers, reorder, swap selectors — whatever the user is asking for. If the "
+ "request is ambiguous, make your best guess. Return only the JSON."
+ )
+ try:
+ resp = call_ollama(system, user)
+ except Exception as e:
+ log_event({'event': 'ollama_error', 'error': str(e)})
+ return None
+
+ new_flow = None
+ try:
+ new_flow = json.loads(resp)
+ except json.JSONDecodeError:
+ m = re.search(r'\{[\s\S]*\}', resp)
+ if m:
+ try:
+ new_flow = json.loads(m.group(0))
+ except json.JSONDecodeError:
+ pass
+ if new_flow is None:
+ log_event({'event': 'llm_invalid_json', 'response_head': resp[:400]})
+ return None
+
+ ok, why = validate_flow(new_flow)
+ if not ok:
+ log_event({'event': 'llm_invalid_schema', 'reason': why, 'flow': new_flow})
+ return None
+
+ if new_flow == current_flow:
+ return None
+ return new_flow
+
+
+def process_request(req, flow_map):
+ msg = req.get('message', '')
+ ids = req.get('video_ids', []) or []
+ log_event({'event': 'process_start', 'request_ts': req['ts'], 'video_ids': ids, 'message': msg})
+
+ for vid_id in ids:
+ slug = vid_id_to_slug(vid_id)
+ if slug not in flow_map:
+ log_event({'event': 'unknown_slug', 'video_id': vid_id, 'slug': slug})
+ continue
+ entry = flow_map[slug]
+ flow_path = FLOWS_DIR / entry['flow_file']
+ if not flow_path.exists():
+ log_event({'event': 'flow_file_missing', 'video_id': vid_id, 'path': str(flow_path)})
+ continue
+
+ current = json.loads(flow_path.read_text())
+ new_flow = edit_flow_via_llm(current, msg, entry)
+ if not new_flow:
+ log_event({'event': 'no_change', 'video_id': vid_id})
+ continue
+
+ # Backup + write
+ backup = flow_path.with_suffix(f'.{int(time.time())}.bak.json')
+ backup.write_text(flow_path.read_text())
+ flow_path.write_text(json.dumps(new_flow, indent=2) + '\n')
+ log_event({
+ 'event': 'flow_updated',
+ 'video_id': vid_id,
+ 'flow_file': str(flow_path),
+ 'backup': str(backup),
+ 'old_steps': len(current.get('steps', [])),
+ 'new_steps': len(new_flow.get('steps', [])),
+ })
+
+ # Re-record
+ env = os.environ.copy()
+ try:
+ result = subprocess.run(
+ [str(SHIP_VIDEO), entry['app_name'], entry['url']],
+ capture_output=True, text=True, env=env, timeout=300,
+ )
+ log_event({
+ 'event': 'ship_video_done',
+ 'video_id': vid_id,
+ 'app_name': entry['app_name'],
+ 'returncode': result.returncode,
+ 'stdout_tail': (result.stdout or '')[-500:],
+ 'stderr_tail': (result.stderr or '')[-500:],
+ })
+ except subprocess.TimeoutExpired:
+ log_event({'event': 'ship_video_timeout', 'video_id': vid_id})
+
+
+def main():
+ if not MAP_FILE.exists():
+ print(f'flow-map.json missing at {MAP_FILE}', file=sys.stderr)
+ sys.exit(2)
+ flow_map = json.loads(MAP_FILE.read_text())
+
+ cursor = get_cursor()
+ try:
+ data = http_get(f'{API_BASE}/api/requests')
+ except Exception as e:
+ log_event({'event': 'fetch_failed', 'error': str(e)})
+ return
+
+ requests = data.get('requests', [])
+ new = [r for r in requests if r.get('ts', '') > cursor]
+ if not new:
+ return
+
+ new.sort(key=lambda r: r['ts']) # oldest first
+ log_event({'event': 'tick', 'new_count': len(new), 'cursor': cursor})
+
+ for req in new:
+ try:
+ process_request(req, flow_map)
+ except Exception as e:
+ log_event({'event': 'process_exception', 'request_ts': req['ts'], 'error': str(e)})
+ set_cursor(req['ts'])
+
+
+if __name__ == '__main__':
+ main()
(oldest)
·
back to Videos Agentabrams Worker
·
ignore runtime files (cursor, logs) 170bcf7 →