← back to Discontinued Agent
discontinued-agent: George→FileMaker vendor-discontinued pipeline (skill + 10s poller daemon)
637b1f35a82ee4f1a7c3e728be4466ee0bd55c9b · 2026-07-08 08:33:48 -0700 · Steve Abrams
Watches info@ Gmail for vendor replies to DW's 'Priority Request - Samples
over 10 days old' follow-ups. On explicit discontinuation, stages/commits
Date Discontinued + closes open sample orders (Date WP Sample Sent) in
FileMaker WALLPAPER/REPORT ON SAMPLES ORDERED. Dry-run by default; requires
DISCO_AUTOCOMMIT=1 to write. Ambiguous/sensitive replies (relationship-ended,
brand-attribution, no-sample, backorder) queue to pending-approval. Reuses
george-gmail + filemaker-mcp creds (no hardcoded secrets).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A .env.exampleA .gitignoreA README.mdA com.steve.discontinued-agent.plistA lib/filemaker.jsA lib/george.jsA lib/parse.jsA package.jsonA poller.js
Diff
commit 637b1f35a82ee4f1a7c3e728be4466ee0bd55c9b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 8 08:33:48 2026 -0700
discontinued-agent: George→FileMaker vendor-discontinued pipeline (skill + 10s poller daemon)
Watches info@ Gmail for vendor replies to DW's 'Priority Request - Samples
over 10 days old' follow-ups. On explicit discontinuation, stages/commits
Date Discontinued + closes open sample orders (Date WP Sample Sent) in
FileMaker WALLPAPER/REPORT ON SAMPLES ORDERED. Dry-run by default; requires
DISCO_AUTOCOMMIT=1 to write. Ambiguous/sensitive replies (relationship-ended,
brand-attribution, no-sample, backorder) queue to pending-approval. Reuses
george-gmail + filemaker-mcp creds (no hardcoded secrets).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.env.example | 31 ++++
.gitignore | 15 ++
README.md | 115 +++++++++++++
com.steve.discontinued-agent.plist | 41 +++++
lib/filemaker.js | 100 +++++++++++
lib/george.js | 79 +++++++++
lib/parse.js | 182 ++++++++++++++++++++
package.json | 18 ++
poller.js | 339 +++++++++++++++++++++++++++++++++++++
9 files changed, 920 insertions(+)
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1a41f68
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,31 @@
+# discontinued-agent config — copy to .env and adjust.
+# George + FileMaker are local / self-hosted = $0 per call.
+
+# ---- George (Gmail HTTP agent) ----
+# Health endpoint needs no auth; other routes need HTTP Basic (admin:<pw>).
+GEORGE_URL=http://127.0.0.1:9850
+# If your George instance requires basic auth on /api/messages etc., set this to
+# the base64 of "admin:<pw>" (same value george-mcp reads from the dw-agents keychain).
+# Leave blank to try unauthenticated first, then fall back to the keychain lookup.
+GEORGE_BASIC_AUTH=
+# Gmail account key. "info" = info@designerwallcoverings.com (the follow-up inbox).
+GEORGE_ACCOUNT=info
+# Gmail search that selects the follow-up threads to scan.
+GMAIL_SEARCH=subject:"Priority Request - Samples over 10 days old" newer_than:60d
+
+# ---- FileMaker (Cloud Data API, via the filemaker-mcp fm-client) ----
+# Path to the filemaker-mcp project whose src/fm-client.js + .env we reuse.
+# We DO NOT hardcode FM creds — they are read from this project's own .env
+# (FM_CLOUD_HOST / FM_CLARIS_EMAIL / FM_CLARIS_PASSWORD / FM_COGNITO_*).
+FM_MCP_DIR=/Users/macstudio3/Projects/filemaker-mcp
+FM_DATABASE=WALLPAPER
+FM_LAYOUT=REPORT ON SAMPLES ORDERED
+
+# ---- Behaviour ----
+# Loop interval in seconds (daemon mode). Default 10.
+POLL_INTERVAL_SEC=10
+# HARD SAFETY GATE. Unless this is exactly "1", the daemon only stages + queues;
+# it NEVER commits a FileMaker write. Dry-run diffs are always logged first.
+DISCO_AUTOCOMMIT=0
+# Where ambiguous/sensitive cases get parked for Steve to review.
+QUEUE_DIR=/Users/macstudio3/.claude/yolo-queue/pending-approval
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..06906f8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+
+# runtime state — audit ledger + processed-thread state stay local
+data/*.jsonl
+data/*.json
+data/*.log
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..add2c12
--- /dev/null
+++ b/README.md
@@ -0,0 +1,115 @@
+# discontinued-agent
+
+Watches the **info@designerwallcoverings.com** Gmail inbox for vendor replies to
+DW's *"Priority Request - Samples over 10 days old - Designer Wallcoverings"*
+sample-follow-up emails. When a vendor **explicitly** says a product is
+discontinued, it stamps the wallpaper record discontinued in FileMaker and closes
+the open sample order — so the >10-day follow-up system stops re-asking.
+
+Ships as **both** a standing poller daemon (this project) and a Claude skill
+(`~/.claude/skills/discontinued-agent/SKILL.md`).
+
+**Cost:** George (Gmail HTTP agent) + FileMaker Cloud are local / self-hosted =
+**$0 per call**.
+
+---
+
+## How it works
+
+1. **Read** — polls George (`http://127.0.0.1:9850`, account `info`) for threads
+ whose subject contains *"Priority Request - Samples over 10 days old"*.
+2. **Group** — groups messages by Gmail `threadId` (oldest → newest). Each thread
+ has DW **outbound** message(s) (from `info@…`) listing SKUs under a
+ *"Manufacturer Number"* block, plus **vendor reply** message(s).
+3. **Extract** — pulls the mfr#(s) from the DW outbound's *Manufacturer Number*
+ block (each item line is `<Pattern Name> <MFR#>`, e.g. `Shooting Star CM104-2180`).
+ The address/phone footer is explicitly excluded.
+4. **Classify** the newest vendor reply:
+ - **COMMIT** — reply literally contains *discontinued* / *no longer available* /
+ *can not order … discontinued*, **and** at least one clean mfr# was extracted.
+ - **QUEUE** — ambiguous/sensitive: *no sample available*, *backordered / no
+ restock*, relationship-ended, brand-attribution, legal, or "discontinued but
+ no mfr# mapped". A memo is written to
+ `~/.claude/yolo-queue/pending-approval/` and **no FileMaker write** happens.
+ - **SKIP** — nothing actionable.
+5. **Write** (COMMIT only) against FileMaker `WALLPAPER` /
+ `REPORT ON SAMPLES ORDERED`, for every row matching the mfr# via `Mfr Pattern`
+ (FileMaker word-match — `CM104-2180` matches stored `Shooting Star CM104-2180`):
+ - **WRITE 1 — discontinue:** set `Date Discontinued` = today (MM/DD/YYYY) on
+ every matching row whose `Date Discontinued` is currently blank (skip dated rows).
+ - **WRITE 2 — close open order:** set `Date WP Sample Sent` = today on rows that
+ are OPEN sample orders (non-empty `account`/`Clients::Company` **and** a
+ `Date Sample Request printed for vendor` **and** a blank `Date WP Sample Sent`).
+ This blank field is what the >10-day follow-up query keys on; stamping it
+ removes the item from future blasts.
+6. **Audit** — every action (committed, staged, queued) is appended to
+ `data/ledger.jsonl`; a processed-state file `data/processed-threads.json`
+ (keyed by `threadId` + a hash of the vendor message) prevents double-processing.
+
+## SAFETY GATE
+
+By default `DISCO_AUTOCOMMIT=0` → the daemon **only stages** (dry-run diffs) and
+**queues**; it never commits a FileMaker write. Set `DISCO_AUTOCOMMIT=1` in `.env`
+to actually commit. Dry-run diffs are always computed + logged first.
+
+---
+
+## Setup
+
+```sh
+cd ~/Projects/discontinued-agent
+cp .env.example .env # then adjust
+```
+
+Key `.env` values:
+
+| var | meaning |
+|-----|---------|
+| `GEORGE_URL` | `http://127.0.0.1:9850` |
+| `GEORGE_BASIC_AUTH` | base64 of `admin:<pw>` for George's `/api/*`. On this box the pw is empty → `YWRtaW46` (`admin:`). |
+| `GEORGE_ACCOUNT` | `info` |
+| `FM_MCP_DIR` | `~/Projects/filemaker-mcp` — its `src/fm-client.js` + `.env` are reused for FM auth (**no creds hardcoded here**). |
+| `FM_DATABASE` / `FM_LAYOUT` | `WALLPAPER` / `REPORT ON SAMPLES ORDERED` |
+| `POLL_INTERVAL_SEC` | daemon loop interval (default `10`) |
+| `DISCO_AUTOCOMMIT` | `1` to commit; anything else = stage + queue only |
+
+## Run on-demand (single scan, always safe to preview)
+
+```sh
+node --env-file=.env poller.js --once # one scan, then exit
+# or: npm run once
+```
+
+## Run as the daemon (loops every 10s)
+
+The poller loops internally; launchd just keeps it alive.
+
+```sh
+# start (bootstrap the LaunchAgent):
+launchctl bootstrap gui/$(id -u) ~/Projects/discontinued-agent/com.steve.discontinued-agent.plist
+
+# stop:
+launchctl bootout gui/$(id -u)/com.steve.discontinued-agent
+
+# status / restart:
+launchctl print gui/$(id -u)/com.steve.discontinued-agent | head
+launchctl kickstart -k gui/$(id -u)/com.steve.discontinued-agent
+
+# logs:
+tail -f data/agent.out.log
+```
+
+> Note: the plist lives in this repo. To have launchd load it you must have the
+> file at a stable path — it references this project directory directly, so it
+> can be bootstrapped straight from the repo (no copy into `~/Library/LaunchAgents`
+> required, though you may symlink it there if you prefer).
+
+## Files
+
+- `poller.js` — the daemon (single-scan `--once`, or infinite loop).
+- `lib/george.js` — George Gmail HTTP client (read-only here).
+- `lib/filemaker.js` — FM Cloud Data API wrapper (imports filemaker-mcp's client + creds).
+- `lib/parse.js` — thread parse, mfr# extraction, gating classification.
+- `data/ledger.jsonl` — append-only audit log (gitignored).
+- `data/processed-threads.json` — dedupe state (gitignored).
+- `com.steve.discontinued-agent.plist` — KeepAlive LaunchAgent.
diff --git a/com.steve.discontinued-agent.plist b/com.steve.discontinued-agent.plist
new file mode 100644
index 0000000..50d754c
--- /dev/null
+++ b/com.steve.discontinued-agent.plist
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.discontinued-agent</string>
+
+ <!-- Runs the poller, which internally loops every POLL_INTERVAL_SEC (default 10s). -->
+ <key>ProgramArguments</key>
+ <array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>--env-file=/Users/macstudio3/Projects/discontinued-agent/.env</string>
+ <string>/Users/macstudio3/Projects/discontinued-agent/poller.js</string>
+ </array>
+
+ <key>WorkingDirectory</key>
+ <string>/Users/macstudio3/Projects/discontinued-agent</string>
+
+ <!-- KeepAlive: respawn if it ever exits. -->
+ <key>KeepAlive</key>
+ <true/>
+ <key>RunAtLoad</key>
+ <true/>
+
+ <!-- Throttle respawn so a crash-loop doesn't hammer George / FileMaker. -->
+ <key>ThrottleInterval</key>
+ <integer>30</integer>
+
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Projects/discontinued-agent/data/agent.out.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Projects/discontinued-agent/data/agent.err.log</string>
+
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+ </dict>
+</dict>
+</plist>
diff --git a/lib/filemaker.js b/lib/filemaker.js
new file mode 100644
index 0000000..62e63fd
--- /dev/null
+++ b/lib/filemaker.js
@@ -0,0 +1,100 @@
+// FileMaker Cloud Data API client for the discontinued-agent.
+//
+// We DO NOT reimplement Claris/Cognito SRP or hardcode any credentials.
+// Instead we dynamically import the already-working fm-client from the
+// filemaker-mcp project (FM_MCP_DIR) and load ITS .env for the host + creds.
+// That project holds the FM Cloud credentials (FM_CLOUD_HOST / FM_CLARIS_* /
+// FM_COGNITO_*) and the amazon-cognito-identity-js dependency.
+//
+// Cost: FileMaker Cloud Data API is self-hosted / already-paid = $0 per call.
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+const FM_MCP_DIR = process.env.FM_MCP_DIR || '/Users/macstudio3/Projects/filemaker-mcp';
+export const FM_DATABASE = process.env.FM_DATABASE || 'WALLPAPER';
+export const FM_LAYOUT = process.env.FM_LAYOUT || 'REPORT ON SAMPLES ORDERED';
+
+// Field names on the REPORT ON SAMPLES ORDERED layout (verified via field metadata).
+export const FIELDS = {
+ mfrPattern: 'Mfr Pattern',
+ dateDiscontinued: 'Date Discontinued',
+ dateSampleSent: 'Date WP Sample Sent',
+ account: 'account',
+ company: 'Clients::Company',
+ requestPrinted: 'Date Sample Request printed for vendor',
+};
+
+// Load the filemaker-mcp .env into process.env WITHOUT clobbering anything we
+// already set (our own env wins). Minimal parser: KEY=VALUE, ignores comments.
+function loadFmEnv() {
+ const envPath = join(FM_MCP_DIR, '.env');
+ let raw;
+ try {
+ raw = readFileSync(envPath, 'utf8');
+ } catch {
+ throw new Error(`filemaker-mcp .env not found at ${envPath} — cannot resolve FM credentials.`);
+ }
+ for (const line of raw.split('\n')) {
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+ if (!m) continue;
+ const k = m[1];
+ let v = m[2];
+ // strip surrounding single/double quotes if present
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
+ v = v.slice(1, -1);
+ }
+ if (process.env[k] === undefined) process.env[k] = v;
+ }
+}
+
+let _client;
+async function client() {
+ if (_client) return _client;
+ loadFmEnv();
+ const clientPath = pathToFileURL(join(FM_MCP_DIR, 'src', 'fm-client.js')).href;
+ _client = await import(clientPath);
+ return _client;
+}
+
+// Today's date in FileMaker MM/DD/YYYY format.
+export function fmToday(d = new Date()) {
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ const yyyy = d.getFullYear();
+ return `${mm}/${dd}/${yyyy}`;
+}
+
+// Prove auth + a Data API session.
+export async function ping() {
+ const c = await client();
+ return c.ping();
+}
+
+// Find all rows whose `Mfr Pattern` word-matches the given mfr#.
+// FileMaker _find is a word/substring match on that field.
+export async function findByMfrPattern(mfr, { limit = 200 } = {}) {
+ const c = await client();
+ const query = { [FIELDS.mfrPattern]: mfr };
+ try {
+ const { records } = await c.findRecords(FM_DATABASE, FM_LAYOUT, query, { limit });
+ return records || [];
+ } catch (e) {
+ // FileMaker returns [401] "No records match the request" — treat as empty.
+ if (String(e.fmCode) === '401') return [];
+ throw e;
+ }
+}
+
+// Update a single record's fields. dryRun:true returns the before->after diff
+// WITHOUT committing; dryRun:false commits.
+export async function updateRecord(recordId, fieldData, { dryRun = true } = {}) {
+ const c = await client();
+ return c.updateRecord(FM_DATABASE, FM_LAYOUT, recordId, fieldData, { dryRun });
+}
+
+// Convenience: pull fieldData + recordId from a Data API record object.
+export function recRow(rec) {
+ return { recordId: rec.recordId, fields: rec.fieldData || {} };
+}
diff --git a/lib/george.js b/lib/george.js
new file mode 100644
index 0000000..75008f4
--- /dev/null
+++ b/lib/george.js
@@ -0,0 +1,79 @@
+// George (DW Gmail HTTP agent) client — read-only usage here.
+// George runs locally on http://127.0.0.1:9850. Health needs no auth; the
+// data routes may require HTTP Basic (admin:<pw>). We reuse the same keychain
+// item george-mcp uses (service "dw-agents", account "admin") when
+// GEORGE_BASIC_AUTH is not supplied in the env.
+//
+// Cost: George is local / self-hosted = $0 per call.
+
+import { execSync } from 'node:child_process';
+
+const BASE_URL = (process.env.GEORGE_URL || 'http://127.0.0.1:9850').replace(/\/$/, '');
+const ACCOUNT = process.env.GEORGE_ACCOUNT || 'info';
+
+function keychainAuth() {
+ try {
+ const pw = execSync('security find-generic-password -s dw-agents -a admin -w', {
+ encoding: 'utf-8',
+ }).trim();
+ return Buffer.from(`admin:${pw}`).toString('base64');
+ } catch {
+ return null;
+ }
+}
+
+let _auth; // memoized: undefined = not resolved, null = none
+function basicAuth() {
+ if (_auth !== undefined) return _auth;
+ _auth = process.env.GEORGE_BASIC_AUTH || keychainAuth() || null;
+ return _auth;
+}
+
+async function george(path, { query } = {}) {
+ const url = new URL(BASE_URL + path);
+ if (query) {
+ for (const [k, v] of Object.entries(query)) {
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
+ }
+ }
+ const headers = {};
+ const auth = basicAuth();
+ if (auth) headers.Authorization = `Basic ${auth}`;
+ const res = await fetch(url, { headers });
+ const text = await res.text();
+ let data;
+ try {
+ data = JSON.parse(text);
+ } catch {
+ data = { raw: text };
+ }
+ if (!res.ok) {
+ const err = new Error(`George GET ${path} -> ${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
+ err.status = res.status;
+ throw err;
+ }
+ return data;
+}
+
+export async function health() {
+ // Public, unauthenticated liveness route. (/api/health requires Basic auth.)
+ const res = await fetch(`${BASE_URL}/health`);
+ if (!res.ok) throw new Error(`George health ${res.status}`);
+ return res.json().catch(() => ({ status: 'ok' }));
+}
+
+// List messages matching a Gmail search query. Returns [{id,threadId,subject,from,to,date,snippet}].
+export async function listMessages(query, { maxResults = 50 } = {}) {
+ const data = await george('/api/messages', {
+ query: { q: query, maxResults, account: ACCOUNT },
+ });
+ // George returns { messages: [...] } or a bare array depending on version.
+ return data.messages || data.results || (Array.isArray(data) ? data : []);
+}
+
+// Fetch a full message (headers + body + attachment metadata) by id.
+export async function getMessage(id) {
+ return george(`/api/messages/${encodeURIComponent(id)}`, { query: { account: ACCOUNT } });
+}
+
+export { ACCOUNT, BASE_URL };
diff --git a/lib/parse.js b/lib/parse.js
new file mode 100644
index 0000000..514efbc
--- /dev/null
+++ b/lib/parse.js
@@ -0,0 +1,182 @@
+// Thread parsing + gating logic for the discontinued-agent.
+//
+// A follow-up thread contains:
+// - DW OUTBOUND message(s) (from info@designerwallcoverings.com) that list the
+// requested SKUs under a "Manufacturer Number" section.
+// - VENDOR REPLY message(s) that may state a product is discontinued.
+//
+// We extract the candidate mfr#(s) from the DW outbound, read the vendor reply
+// text, and classify each thread into: COMMIT (high-confidence explicit
+// discontinuation) / QUEUE (ambiguous/sensitive) / SKIP (nothing actionable).
+
+import crypto from 'node:crypto';
+
+const DW_FROM_RE = /info@designerwallcoverings\.com/i;
+
+// Strip HTML to readable-ish plain text.
+export function htmlToText(html = '') {
+ return String(html)
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+ .replace(/<br\s*\/?>/gi, '\n')
+ .replace(/<\/(p|div|tr|li|h[1-6]|table)>/gi, '\n')
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/&/gi, '&')
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/'|’|‘/gi, "'")
+ .replace(/"|“|”/gi, '"')
+ .replace(/[ \t]+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+export function isDwOutbound(msg) {
+ return DW_FROM_RE.test(msg.from || '');
+}
+
+// Mfr# token pattern — vendor style numbers like CM104-2180, TCO08, 5008040,
+// W3612-11, T75461. Alphanumerics with optional dashes/slashes, length >=4,
+// containing at least one digit. Deliberately permissive; the FileMaker match
+// re-validates by finding the row.
+const MFR_TOKEN_RE = /\b([A-Z0-9]{1,}(?:[-/. ][A-Z0-9]{1,}){0,3})\b/g;
+
+// The DW outbound footer is a fixed block — its tokens must never be treated
+// as mfr#s. (Address line, city/state/zip, toll-free number.)
+const FOOTER_BLOCKLIST = [
+ /^15442$/, // street number
+ /^CA\s*9\d{4}$/i, // CA + zip
+ /^9\d{4}$/, // bare CA zip
+ /^1?[-.\s]?888[-.\s]?373[-.\s]?4564$/, // DW toll-free
+ /^373[-.\s]?4564$/,
+ /^102$/, // suite #
+];
+
+function looksLikeMfr(tok) {
+ const t = tok.trim();
+ if (t.length < 4 || t.length > 24) return false;
+ if (!/[0-9]/.test(t)) return false; // must contain a digit
+ if (!/[A-Z0-9]/i.test(t)) return false;
+ // reject pure prose numbers / dates / phone-ish / money / time-of-day
+ if (/^\d{1,2}[/.]\d{1,2}([/.]\d{2,4})?$/.test(t)) return false; // dates
+ if (/^\$?\d+[.,]\d{2}$/.test(t)) return false; // money
+ if (/\b(?:AM|PM)\b/i.test(t)) return false; // time fragments like "22 AM"
+ if (/^(10|days|over|2026|2025|2024|2023)$/i.test(t)) return false;
+ if (FOOTER_BLOCKLIST.some((re) => re.test(t))) return false;
+ return true;
+}
+
+// Grab the LAST mfr-looking token on a line — the DW outbound lists each item
+// as "<Pattern Name> <MFR#>" (e.g. "Shooting Star CM104-2180").
+function lastMfrTokenOnLine(line) {
+ let last = null;
+ let mm;
+ const re = new RegExp(MFR_TOKEN_RE.source, 'g');
+ while ((mm = re.exec(line)) !== null) {
+ const tok = mm[1].trim();
+ if (looksLikeMfr(tok)) last = tok;
+ }
+ return last;
+}
+
+// Extract mfr#(s) from a DW outbound message.
+//
+// The outbound lists them in a block delimited like:
+// Manufacturer Number
+// _________________
+// Shooting Star CM104-2180
+// Shooting Star CM104-2170
+// _________________
+// Ship to the address below. <- footer (address/phone) starts here
+//
+// So we scope extraction to the lines BETWEEN the "Manufacturer Number" heading
+// and the NEXT underscore/asterisk divider, taking the trailing mfr# per line.
+// This deliberately excludes the address/phone footer that follows.
+export function extractMfrNumbers(outboundText) {
+ const text = outboundText || '';
+ const found = new Set();
+
+ const lines = text.split(/\n/);
+ const headIdx = lines.findIndex((l) => /manufacturer\s*(?:number|#|no\.?)/i.test(l));
+
+ if (headIdx >= 0) {
+ for (let i = headIdx + 1; i < lines.length; i++) {
+ const raw = lines[i];
+ const line = raw.replace(/^[>\s]+/, '').trim();
+ if (!line) continue;
+ // Divider line (all underscores / dashes) — skip until we hit content.
+ if (/^[_\-–—\s]{3,}$/.test(line)) {
+ // A divider AFTER we've already collected items ends the block.
+ if (found.size > 0) break;
+ continue;
+ }
+ // A "Manufacturer Number" label line on its own — skip.
+ if (/^manufacturer\s*(?:number|#|no\.?)\s*:?\s*$/i.test(line)) continue;
+ // Explicit "Manufacturer Number: XXX" form.
+ const labeled = line.match(/manufacturer\s*(?:number|#|no\.?)\s*[:\-]?\s*(.+)$/i);
+ const target = labeled ? labeled[1] : line;
+ const tok = lastMfrTokenOnLine(target);
+ if (tok) {
+ found.add(tok.toUpperCase());
+ } else if (found.size > 0) {
+ // Once we've started collecting, a non-mfr content line (e.g. "Ship to
+ // the address below.") marks the end of the item block.
+ break;
+ }
+ }
+ }
+
+ // NO whole-body fallback: if the structured "Manufacturer Number" block
+ // yielded nothing, we return empty so the thread is QUEUED as "no mfr#"
+ // rather than risking a false match from prose/footer tokens.
+ return [...found];
+}
+
+// --- Gating classification ---
+// HIGH-CONFIDENCE explicit discontinuation phrases.
+const DISCO_PHRASES = [
+ /\bdiscontinued\b/i,
+ /\bno longer available\b/i,
+ /\bno longer offer(?:ed|ing)?\b/i,
+ /\bno longer carr(?:y|ied|ies)\b/i,
+ /\bhas been discontinued\b/i,
+ /\bcan\s?not (?:be )?order(?:ed)?[^.]{0,40}discontinued/i,
+ /\bdiscontinued[^.]{0,40}can\s?not (?:be )?order/i,
+];
+
+// QUEUE-worthy (ambiguous / sensitive) phrases — do NOT auto-discontinue.
+const QUEUE_PHRASES = [
+ { re: /\bno sample(?:s)? available\b/i, why: 'no sample available (not a discontinuation)' },
+ { re: /\bout of stock\b/i, why: 'out of stock (not a discontinuation)' },
+ { re: /\bback\s?order(?:ed)?\b/i, why: 'backordered / no restock date' },
+ { re: /\bno restock\b/i, why: 'no restock date' },
+ { re: /\bended (?:our|the) (?:retailer|vendor|reseller|distributor) relationship\b/i, why: 'vendor ended retailer/vendor relationship (relationship-ended)' },
+ { re: /\bwithout brand attribution\b/i, why: 'brand-attribution complaint' },
+ { re: /\bbrand attribution\b/i, why: 'brand-attribution concern' },
+ { re: /\bcease and desist\b/i, why: 'legal / cease-and-desist' },
+ { re: /\bnot (?:an )?authorized (?:dealer|retailer)\b/i, why: 'authorization dispute' },
+ { re: /\bremove (?:our|the) (?:products|styles|patterns|brand)\b/i, why: 'vendor asked to remove listings' },
+];
+
+// Classify a vendor reply's text.
+export function classifyReply(replyText) {
+ const text = replyText || '';
+
+ // Sensitive/ambiguous flags always take precedence (fail-safe to QUEUE).
+ const queueHits = QUEUE_PHRASES.filter((q) => q.re.test(text)).map((q) => q.why);
+ const discoHit = DISCO_PHRASES.find((re) => re.test(text));
+
+ if (queueHits.length > 0) {
+ return { verdict: 'QUEUE', reasons: queueHits, discontinuedPhrase: discoHit ? discoHit.source : null };
+ }
+ if (discoHit) {
+ return { verdict: 'COMMIT', reasons: ['explicit discontinuation'], discontinuedPhrase: discoHit.source };
+ }
+ return { verdict: 'SKIP', reasons: ['no discontinuation or sensitive signal'], discontinuedPhrase: null };
+}
+
+// Stable hash of the vendor message content (for the processed-state key).
+export function hashMsg(str) {
+ return crypto.createHash('sha256').update(String(str)).digest('hex').slice(0, 16);
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..857f18c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "discontinued-agent",
+ "version": "1.0.0",
+ "description": "Watches info@ Gmail for vendor 'discontinued' replies to DW sample-follow-up emails; stamps the wallpaper record discontinued and closes the open sample order in FileMaker.",
+ "type": "module",
+ "private": true,
+ "bin": {
+ "discontinued-agent": "poller.js"
+ },
+ "scripts": {
+ "once": "node poller.js --once",
+ "start": "node poller.js",
+ "dry": "node poller.js --once"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/poller.js b/poller.js
new file mode 100644
index 0000000..0927032
--- /dev/null
+++ b/poller.js
@@ -0,0 +1,339 @@
+#!/usr/bin/env node
+// discontinued-agent — poller daemon.
+//
+// Watches info@designerwallcoverings.com for vendor replies to DW's
+// "Priority Request - Samples over 10 days old" sample-follow-up emails.
+// When a vendor EXPLICITLY says a product is discontinued, it stamps the
+// wallpaper record discontinued in FileMaker and closes the open sample order
+// (so the >10-day follow-up system stops re-asking).
+//
+// Modes:
+// node poller.js -> daemon: loops every POLL_INTERVAL_SEC seconds
+// node poller.js --once -> run a single scan and exit
+//
+// SAFETY: unless DISCO_AUTOCOMMIT=1, this NEVER commits a FileMaker write. It
+// stages the dry-run diff, logs it, and (for QUEUE cases) parks a memo.
+//
+// Cost: George (Gmail) + FileMaker Cloud are local/self-hosted = $0 per call.
+
+import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+import * as George from './lib/george.js';
+import * as FM from './lib/filemaker.js';
+import {
+ htmlToText,
+ isDwOutbound,
+ extractMfrNumbers,
+ classifyReply,
+ hashMsg,
+} from './lib/parse.js';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const DATA_DIR = join(__dir, 'data');
+const PROCESSED_FILE = join(DATA_DIR, 'processed-threads.json');
+const LEDGER_FILE = join(DATA_DIR, 'ledger.jsonl');
+
+const AUTOCOMMIT = process.env.DISCO_AUTOCOMMIT === '1';
+const QUEUE_DIR = process.env.QUEUE_DIR || join(process.env.HOME || '', '.claude/yolo-queue/pending-approval');
+const GMAIL_SEARCH =
+ process.env.GMAIL_SEARCH ||
+ 'subject:"Priority Request - Samples over 10 days old" newer_than:60d';
+const POLL_INTERVAL_SEC = Number(process.env.POLL_INTERVAL_SEC || 10);
+const MAX_RESULTS = Number(process.env.MAX_RESULTS || 100);
+
+function log(...a) {
+ console.log(new Date().toISOString(), '[disco]', ...a);
+}
+
+// ---- state ----
+function loadProcessed() {
+ try {
+ return JSON.parse(readFileSync(PROCESSED_FILE, 'utf8'));
+ } catch {
+ return {};
+ }
+}
+function saveProcessed(state) {
+ mkdirSync(DATA_DIR, { recursive: true });
+ writeFileSync(PROCESSED_FILE, JSON.stringify(state, null, 2));
+}
+function appendLedger(entry) {
+ mkdirSync(DATA_DIR, { recursive: true });
+ appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
+
+function queueMemo(name, body) {
+ mkdirSync(QUEUE_DIR, { recursive: true });
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const path = join(QUEUE_DIR, `discontinued-agent-${name}-${stamp}.md`);
+ writeFileSync(path, body);
+ return path;
+}
+
+// ---- thread processing ----
+
+// Build the {threadId -> [messages]} grouping from a flat list, ordered oldest->newest.
+function groupByThread(messages) {
+ const g = new Map();
+ for (const m of messages) {
+ if (!g.has(m.threadId)) g.set(m.threadId, []);
+ g.get(m.threadId).push(m);
+ }
+ for (const [, arr] of g) {
+ arr.sort((a, b) => Number(a.internalDate || 0) - Number(b.internalDate || 0));
+ }
+ return g;
+}
+
+// Given full messages for a thread, split into DW outbound + vendor replies.
+function splitThread(fullMsgs) {
+ const outbound = [];
+ const vendor = [];
+ for (const m of fullMsgs) {
+ if (isDwOutbound(m)) outbound.push(m);
+ else vendor.push(m);
+ }
+ return { outbound, vendor };
+}
+
+// Decide the disposition of one thread. Returns a plan object (no side effects).
+function planThread(threadId, fullMsgs) {
+ const { outbound, vendor } = splitThread(fullMsgs);
+ const outboundText = outbound.map((m) => htmlToText(m.body || '')).join('\n\n');
+ const mfrNumbers = extractMfrNumbers(outboundText);
+
+ // Newest vendor reply drives the verdict.
+ const latestVendor = vendor[vendor.length - 1] || null;
+ const vendorText = latestVendor ? htmlToText(latestVendor.body || '') : '';
+ const vendorFrom = latestVendor ? latestVendor.from : null;
+ const cls = classifyReply(vendorText);
+
+ return {
+ threadId,
+ vendorFrom,
+ vendorMsgId: latestVendor ? latestVendor.id : null,
+ vendorHash: hashMsg(vendorText),
+ mfrNumbers,
+ verdict: cls.verdict,
+ reasons: cls.reasons,
+ outboundCount: outbound.length,
+ vendorCount: vendor.length,
+ vendorText,
+ };
+}
+
+// Execute the FileMaker writes for a COMMIT plan (dry-run unless AUTOCOMMIT).
+async function applyCommit(plan) {
+ const today = FM.fmToday();
+ const F = FM.FIELDS;
+ const perMfr = [];
+
+ for (const mfr of plan.mfrNumbers) {
+ let rows;
+ try {
+ rows = await FM.findByMfrPattern(mfr);
+ } catch (e) {
+ perMfr.push({ mfr, error: `find failed: ${e.message}` });
+ continue;
+ }
+ const discoWrites = [];
+ const closeWrites = [];
+
+ for (const rec of rows) {
+ const { recordId, fields } = FM.recRow(rec);
+ const blankDisco = !String(fields[F.dateDiscontinued] || '').trim();
+ const isOpenOrder =
+ String(fields[F.account] || fields[F.company] || '').trim() &&
+ String(fields[F.requestPrinted] || '').trim() &&
+ !String(fields[F.dateSampleSent] || '').trim();
+
+ // WRITE 1 — discontinue (only if currently blank)
+ if (blankDisco) {
+ const r = await FM.updateRecord(recordId, { [F.dateDiscontinued]: today }, { dryRun: !AUTOCOMMIT });
+ discoWrites.push({ recordId, field: F.dateDiscontinued, ...r });
+ }
+ // WRITE 2 — close open order (blank Date WP Sample Sent on an open request)
+ if (isOpenOrder) {
+ const r = await FM.updateRecord(recordId, { [F.dateSampleSent]: today }, { dryRun: !AUTOCOMMIT });
+ closeWrites.push({ recordId, field: F.dateSampleSent, ...r });
+ }
+ }
+ perMfr.push({ mfr, rowsMatched: rows.length, discoWrites, closeWrites });
+ }
+ return { today, perMfr };
+}
+
+async function processThread(threadId, headers, state) {
+ // Fetch full bodies for each message in the thread.
+ const full = [];
+ for (const h of headers) {
+ try {
+ full.push(await George.getMessage(h.id));
+ } catch (e) {
+ log('getMessage failed', h.id, e.message);
+ }
+ }
+ if (full.length === 0) return;
+
+ const plan = planThread(threadId, full);
+
+ // processed-state key: threadId + hash of the vendor message.
+ const key = `${threadId}:${plan.vendorHash}`;
+ if (state[key]) return; // already handled this exact vendor message
+
+ if (plan.verdict === 'SKIP') {
+ // Mark processed so we don't re-read it every cycle, but log nothing heavy.
+ state[key] = { at: new Date().toISOString(), verdict: 'SKIP' };
+ return;
+ }
+
+ if (plan.verdict === 'QUEUE') {
+ const memoBody = [
+ `# discontinued-agent — QUEUED for Steve's review`,
+ ``,
+ `- **When:** ${new Date().toISOString()}`,
+ `- **Thread:** ${threadId}`,
+ `- **Vendor:** ${plan.vendorFrom || 'unknown'}`,
+ `- **Why queued (not auto-discontinued):** ${plan.reasons.join('; ')}`,
+ `- **Candidate mfr#(s) in thread:** ${plan.mfrNumbers.join(', ') || '(none extracted)'}`,
+ ``,
+ `## Vendor reply (excerpt)`,
+ '```',
+ plan.vendorText.slice(0, 1500),
+ '```',
+ ``,
+ `No FileMaker writes were made. Review and, if a discontinuation is warranted,`,
+ `stamp \`Date Discontinued\` + close the open order manually or re-run with a`,
+ `confirmed mfr# via the skill.`,
+ ].join('\n');
+ const memoPath = queueMemo(`queue-${threadId}`, memoBody);
+ appendLedger({
+ threadId,
+ vendor: plan.vendorFrom,
+ verdict: 'QUEUE',
+ reasons: plan.reasons,
+ mfrNumbers: plan.mfrNumbers,
+ memoPath,
+ fieldsWritten: [],
+ });
+ state[key] = { at: new Date().toISOString(), verdict: 'QUEUE', memoPath };
+ log('QUEUE', threadId, plan.vendorFrom, '->', plan.reasons.join('; '));
+ return;
+ }
+
+ // COMMIT path — but only if we have at least one clean mfr# to act on.
+ if (plan.mfrNumbers.length === 0) {
+ const memoPath = queueMemo(
+ `nomfr-${threadId}`,
+ `# discontinued-agent — discontinuation stated but no mfr# mapped\n\n` +
+ `- Thread: ${threadId}\n- Vendor: ${plan.vendorFrom}\n` +
+ `- The vendor reply says discontinued but no manufacturer number could be\n` +
+ ` cleanly extracted from the DW outbound. Queued for Steve.\n\n` +
+ '## Vendor reply\n```\n' + plan.vendorText.slice(0, 1500) + '\n```\n'
+ );
+ appendLedger({ threadId, vendor: plan.vendorFrom, verdict: 'QUEUE_NO_MFR', reasons: plan.reasons, mfrNumbers: [], memoPath, fieldsWritten: [] });
+ state[key] = { at: new Date().toISOString(), verdict: 'QUEUE_NO_MFR', memoPath };
+ log('QUEUE(no mfr#)', threadId, plan.vendorFrom);
+ return;
+ }
+
+ const result = await applyCommit(plan);
+ const fieldsWritten = [];
+ const recordIds = [];
+ for (const pm of result.perMfr) {
+ for (const w of [...(pm.discoWrites || []), ...(pm.closeWrites || [])]) {
+ recordIds.push(w.recordId);
+ fieldsWritten.push({ recordId: w.recordId, field: w.field, committed: !!w.committed, changes: w.changes });
+ }
+ }
+
+ appendLedger({
+ threadId,
+ vendor: plan.vendorFrom,
+ verdict: AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
+ reasons: plan.reasons,
+ mfrNumbers: plan.mfrNumbers,
+ today: result.today,
+ recordIds,
+ fieldsWritten,
+ autocommit: AUTOCOMMIT,
+ });
+
+ // Only mark processed when we actually committed. In dry-run mode we leave it
+ // un-processed so a later run with DISCO_AUTOCOMMIT=1 can complete the write.
+ if (AUTOCOMMIT) {
+ state[key] = { at: new Date().toISOString(), verdict: 'COMMIT', recordIds };
+ }
+ log(
+ AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
+ threadId,
+ plan.vendorFrom,
+ 'mfr#', plan.mfrNumbers.join(','),
+ 'rows', recordIds.length
+ );
+}
+
+async function scanOnce() {
+ const state = loadProcessed();
+ let headers;
+ try {
+ headers = await George.listMessages(GMAIL_SEARCH, { maxResults: MAX_RESULTS });
+ } catch (e) {
+ log('George list failed:', e.message);
+ return;
+ }
+ const threads = groupByThread(headers);
+ log(`scanned ${headers.length} msgs across ${threads.size} threads`);
+
+ for (const [threadId, msgs] of threads) {
+ try {
+ await processThread(threadId, msgs, state);
+ } catch (e) {
+ log('thread error', threadId, e.message);
+ }
+ }
+ saveProcessed(state);
+}
+
+async function main() {
+ const once = process.argv.includes('--once');
+ log(`starting. autocommit=${AUTOCOMMIT ? 'ON' : 'OFF (dry-run/stage only)'} search="${GMAIL_SEARCH}"`);
+
+ // Health preflight (non-fatal).
+ try {
+ const h = await George.health();
+ log('George health:', h.status || 'ok');
+ } catch (e) {
+ log('WARN George health check failed:', e.message);
+ }
+ try {
+ const p = await FM.ping();
+ log('FileMaker ping:', p.ok ? `ok (${p.database} @ ${p.host})` : 'unexpected');
+ } catch (e) {
+ log('WARN FileMaker ping failed:', e.message);
+ }
+
+ if (once) {
+ await scanOnce();
+ log('single scan complete.');
+ return;
+ }
+
+ // Daemon loop — every POLL_INTERVAL_SEC seconds, never overlapping.
+ log(`daemon loop every ${POLL_INTERVAL_SEC}s`);
+ for (;;) {
+ try {
+ await scanOnce();
+ } catch (e) {
+ log('scan error:', e.message);
+ }
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_SEC * 1000));
+ }
+}
+
+main().catch((e) => {
+ log('FATAL', e.stack || e.message);
+ process.exit(1);
+});
(oldest)
·
back to Discontinued Agent
·
Add disco/not-going-to-make-again triggers + info@ run-compl 85a3914 →