diff --git a/docker-compose.yaml b/docker-compose.yaml index e27db25..31ffc31 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -323,8 +323,8 @@ services: - N8N_PROTOCOL=http - N8N_EDITOR_BASE_URL=http://localhost:18808 - WEBHOOK_URL=http://localhost:18808/ - - TZ=UTC - - GENERIC_TIMEZONE=UTC + - TZ=America/Los_Angeles + - GENERIC_TIMEZONE=America/Los_Angeles - N8N_SECURE_COOKIE=false volumes: - n8n-agent-data:/home/node/.n8n diff --git a/swarm-common/n8n-workflows/9sFwRyUDz51csAp7.json b/swarm-common/n8n-workflows/9sFwRyUDz51csAp7.json index 60e59dc..0c9e106 100644 --- a/swarm-common/n8n-workflows/9sFwRyUDz51csAp7.json +++ b/swarm-common/n8n-workflows/9sFwRyUDz51csAp7.json @@ -1 +1,957 @@ -{"updatedAt":"2026-05-13T21:29:35.440Z","createdAt":"2026-03-18T05:20:48.223Z","id":"9sFwRyUDz51csAp7","name":"IMAP Inbox Triage + Obsidian Notes","description":null,"active":true,"isArchived":false,"nodes":[{"parameters":{"rule":{"interval":[{"field":"minutes","minutesInterval":15}]}},"id":"n1","name":"Schedule Trigger","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1,"position":[240,304]},{"parameters":{"jsCode":"// DEFINITE NOISE - never worth seeing\nconst NOISE_SENDERS = [\n 'discord', 'plex', 'spotify', 'youtube',\n 'lodge at redmond ridge', 'flex +',\n 'seattle jeep',\n 'no-reply', 'noreply', 'do-not-reply', 'donotreply',\n 'newsletter', 'marketing',\n];\nconst NOISE_SUBJECTS = [\n 'bulletin board', 'daily digest', 'weekly digest',\n 'most watchlisted', 'newsletter',\n 'mentioned you in',\n 'looking to see what your car',\n 'take your favorite music',\n 'introducing the take',\n];\n\n// DEFINITE SIGNAL - always pass through, skip LLM\nconst SIGNAL_PATTERNS = [\n 'login attempt', 'unauthorized', 'unusual sign',\n 'invoice', 'payment due', 'receipt',\n 'urgent', 'action required',\n 'password reset', 'verify your',\n 'github', 'gitea',\n];\n\nconst items = $input.all();\nif (items.length === 0) return [];\n\n// Ignore schedule/no-email pass-through items from polling mode\nconst emailish = items.filter(item => {\n const j = item.json || {};\n return !!(j.from || j.subject || j.text || j.textPlain || j.textHtml || j.html || j.headers || j.messageId);\n});\nif (emailish.length === 0) return [];\n\n\nconst definiteSignal = [];\nconst needsJudgement = [];\n\nfor (const item of items) {\n const from = (item.json.from || '').toLowerCase();\n const subject = (item.json.subject || '').toLowerCase();\n const combined = from + ' ' + subject;\n\n // Definite signal - fast path, no LLM needed\n if (SIGNAL_PATTERNS.some(p => combined.includes(p))) {\n definiteSignal.push({ ...item.json, _stage1: 'definite_signal', _account: item.json._account || 'unknown' });\n continue;\n }\n\n // Definite noise - drop\n const isNoise = \n NOISE_SENDERS.some(n => combined.includes(n)) ||\n NOISE_SUBJECTS.some(n => new RegExp(n, 'i').test(combined));\n if (isNoise) continue;\n\n // Everything else - send to LLM for judgement\n needsJudgement.push({ ...item.json, _stage1: 'needs_judgement', _account: item.json._account || 'unknown' });\n}\n\n// Return all items for next node; tag them so we can route\nconst all = [...definiteSignal, ...needsJudgement];\nif (all.length === 0) return [{ json: { _empty: true } }];\nreturn all.map(j => ({ json: j }));"},"id":"n2","name":"Stage 1 - Static Filter","type":"n8n-nodes-base.code","typeVersion":2,"position":[464,304]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"loose"},"conditions":[{"id":"c1","leftValue":"={{ $json._empty }}","rightValue":true,"operator":{"type":"boolean","operation":"notEquals"}}],"combinator":"and"},"options":{}},"id":"n3","name":"Any Left?","type":"n8n-nodes-base.if","typeVersion":2,"position":[688,304]},{"parameters":{"conditions":{"string":[{"value1":"={{ $json._stage1 }}","value2":"needs_judgement"}]}},"id":"n4","name":"Needs LLM Judgement?","type":"n8n-nodes-base.if","typeVersion":1,"position":[912,208]},{"parameters":{"method":"POST","url":"http://172.19.0.1:18806/v1/chat/completions","sendHeaders":true,"headerParameters":{"parameters":[{"name":"Content-Type","value":"application/json"}]},"sendBody":true,"contentType":"raw","rawContentType":"application/json","body":"={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"temperature\": 0, \"max_tokens\": 60, \"messages\": [{\"role\": \"system\", \"content\": \"You are an email triage assistant for a software developer. Emails may be in any language \\u2014 translate mentally before judging. Reply with JSON only: {\\\"signal\\\": true|false, \\\"priority\\\": 1|2|3, \\\"reason\\\": \\\"one short phrase\\\"}. Priority: 1=act now, 2=read today, 3=FYI. Signal=false means drop silently. Always mark security alerts (login attempts, account access, suspicious activity) as signal priority 1, regardless of language.\"}, {\"role\": \"user\", \"content\": \"From: {{ $json.from }}\\nSubject: {{ $json.subject }}\"}]}","options":{"response":{"response":{"responseFormat":"json"}},"timeout":15000}},"id":"n5","name":"Judge with Local LLM","type":"n8n-nodes-base.httpRequest","typeVersion":4,"position":[1120,128]},{"parameters":{"jsCode":"const item = $input.first();\nconst inputItem = $('Needs LLM Judgement?').first();\n\ntry {\n let content = '';\n const j = item.json || {};\n\n if (j.choices && j.choices[0] && j.choices[0].message) {\n content = j.choices[0].message.content || '';\n } else if (j._readableState && j._readableState.buffer && j._readableState.buffer[0] && j._readableState.buffer[0].data) {\n const bytes = j._readableState.buffer[0].data;\n const raw = Buffer.from(bytes).toString('utf8');\n const parsed = JSON.parse(raw);\n content = parsed.choices[0].message.content || '';\n }\n\n content = content.trim();\n if (!content) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_empty', _priority: 3, _reason: 'no llm response' } }];\n }\n\n // Strip markdown code fences\n const cleaned = content.replace(/^[^\\{]*/, '').replace(/[^\\}]*$/, '').trim();\n const result = JSON.parse(cleaned);\n\n if (!result.signal) return [];\n\n return [{ json: { ...inputItem.json, _stage2: 'llm_signal', _priority: result.priority || 3, _reason: result.reason || '' } }];\n} catch(e) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_parse_error', _priority: 3, _reason: 'parse error: ' + e.message } }];\n}"},"id":"n6","name":"Parse LLM Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[1344,128]},{"parameters":{"jsCode":"const results = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n results.push({\n json: {\n from: String(j.from || ''),\n subject: String(j.subject || ''),\n date: String(j.date || ''),\n textPlain: String(j.textPlain || j.text || '').substring(0, 500),\n messageId: String(j.messageId || ''),\n _account: String(j._account || 'unknown'),\n _stage1: 'definite_signal',\n _stage2: 'definite_signal',\n _priority: 1,\n _reason: 'pattern match'\n }\n });\n}\nreturn results;"},"id":"n7","name":"Tag Definite Signal","type":"n8n-nodes-base.code","typeVersion":2,"position":[1120,304]},{"parameters":{"aggregate":"aggregateAllItemData","destinationFieldName":"messages","options":{}},"id":"n8","name":"Merge All Signal","type":"n8n-nodes-base.aggregate","typeVersion":1,"position":[1568,208]},{"parameters":{"jsCode":"const messages = ($input.first().json.messages || [])\n .sort((a, b) => (a._priority || 3) - (b._priority || 3));\n\nif (messages.length === 0) return [];\n\nconst PRIORITY_EMOJI = { 1: '🔴', 2: '🟡', 3: '🔵' };\n\nconst lines = messages.map((m, i) => {\n const from = (m.from || '(unknown)').replace(/<[^>]+>/g, '').trim().substring(0, 50);\n const subject = (m.subject || '(no subject)').trim().substring(0, 75);\n const emoji = PRIORITY_EMOJI[m._priority] || '🔵';\n const reason = m._reason && m._reason !== 'pattern match' ? ` — _${m._reason}_` : '';\n const acct = m._account && m._account !== 'unknown' ? ` [${m._account}]` : '';\n return `${emoji} ${subject}\\n ${from}${acct}${reason}`;\n});\n\nconst text = `📬 *${messages.length} new email${messages.length > 1 ? 's' : ''}*\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text } }];"},"id":"n9","name":"Format & Send","type":"n8n-nodes-base.code","typeVersion":2,"position":[1792,128]},{"parameters":{"chatId":"8367012007","text":"={{ $json.text }}","additionalFields":{"parse_mode":"Markdown"}},"id":"n10","name":"Send to Telegram","type":"n8n-nodes-base.telegram","typeVersion":1,"position":[2000,128],"webhookId":"795a0fc5-c932-4265-bd0d-095dd410f8a8","credentials":{"telegramApi":{"id":"aox4dyIWVSRdcH5z","name":"Telegram Bot (OpenClaw)"}}},{"parameters":{},"id":"n11","name":"Silent Stop","type":"n8n-nodes-base.noOp","typeVersion":1,"position":[688,464]},{"parameters":{"jsCode":"const wrapper = $input.first().json;\nconst messages = wrapper.messages || [];\nconst results = [];\n\nfor (const item of messages) {\n const now = new Date();\n const date = now.toISOString().split('T')[0];\n const subject = (item.subject || 'No Subject').replace(/[\\/\\\\?%*:|\"<>]/g, '-').substring(0, 80);\n const from = (item.from || 'unknown').replace(/<[^>]+>/g, '').trim();\n const snippet = (item.textPlain || '').substring(0, 500);\n const priority = item._priority || 3;\n const reason = item._reason || '';\n const PRIORITY_LABEL = {1: 'high', 2: 'medium', 3: 'low'};\n const PRIORITY_TAG = {1: 'priority-high', 2: 'priority-medium', 3: 'priority-low'};\n const frontmatter = '---\\ntitle: \"' + subject + '\"\\narea: notes\\ntags: [email, imap, ' + PRIORITY_TAG[priority] + ']\\ncreated: ' + date + '\\nupdated: ' + date + '\\nstatus: active\\nfrom: \"' + from + '\"\\npriority: ' + PRIORITY_LABEL[priority] + '\\nsignal_reason: \"' + reason + '\"\\n---';\n const content = frontmatter + '\\n\\n# ' + subject + '\\n\\n**From:** ' + from + '\\n**Date:** ' + date + '\\n**Priority:** ' + PRIORITY_LABEL[priority] + (reason ? ' — ' + reason : '') + '\\n\\n## Snippet\\n\\n' + snippet + '\\n\\n## Notes\\n\\n_Add notes here_\\n';\n results.push({ json: { path: 'Notes/' + date + ' ' + subject + '.md', content, subject, from, priority, date } });\n}\nreturn results;"},"id":"n12","name":"Format Email Notes","type":"n8n-nodes-base.code","typeVersion":2,"position":[1792,304]},{"parameters":{"method":"PUT","url":"=http://172.19.0.1:27123/vault/{{ encodeURIComponent($json.path).replace(/%2F/g, \"/\") }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","sendBody":true,"contentType":"raw","rawContentType":"text/markdown","body":"={{ $json.content }}","options":{"response":{"response":{"neverError":true}}}},"id":"n13","name":"Write Email to Vault","type":"n8n-nodes-base.httpRequest","typeVersion":4,"position":[2000,304],"credentials":{"httpHeaderAuth":{"id":"465Swz2b71O2KRAK","name":"Obsidian Local REST API"}},"onError":"continueRegularOutput"},{"parameters":{"options":{}},"id":"n1a","name":"Read Unseen Emails","type":"n8n-nodes-base.emailReadImap","typeVersion":2,"position":[352,656],"credentials":{"imap":{"id":"5qGEXTjFtPUZL8BB","name":"wills_portal IMAP"}}}],"connections":{"Stage 1 - Static Filter":{"main":[[{"node":"Any Left?","type":"main","index":0}]]},"Any Left?":{"main":[[{"node":"Needs LLM Judgement?","type":"main","index":0}],[{"node":"Silent Stop","type":"main","index":0}]]},"Needs LLM Judgement?":{"main":[[{"node":"Judge with Local LLM","type":"main","index":0}],[{"node":"Tag Definite Signal","type":"main","index":0}]]},"Judge with Local LLM":{"main":[[{"node":"Parse LLM Result","type":"main","index":0}]]},"Parse LLM Result":{"main":[[{"node":"Merge All Signal","type":"main","index":0}]]},"Tag Definite Signal":{"main":[[{"node":"Merge All Signal","type":"main","index":0}]]},"Merge All Signal":{"main":[[{"node":"Format & Send","type":"main","index":0},{"node":"Format Email Notes","type":"main","index":0}]]},"Format & Send":{"main":[[{"node":"Send to Telegram","type":"main","index":0}]]},"Format Email Notes":{"main":[[{"node":"Write Email to Vault","type":"main","index":0}]]},"Schedule Trigger":{"main":[[{"node":"Read Unseen Emails","type":"main","index":0}]]},"Read Unseen Emails":{"main":[[{"node":"Stage 1 - Static Filter","type":"main","index":0}]]}},"settings":{"executionOrder":"v1","callerPolicy":"workflowsFromSameOwner","availableInMCP":false,"saveDataSuccessExecution":"all","saveDataErrorExecution":"all","saveManualExecutions":true},"staticData":{"node:Schedule Trigger":{"recurrenceRules":[]},"node:Read Unseen Emails":{},"node:Read wills_portal":{"lastMessageUid":8770},"node:Read squareffect":{},"node:Schedule wills_portal":{"recurrenceRules":[]},"node:Schedule squareffect":{"recurrenceRules":[]},"node:Email Trigger":{}},"meta":null,"versionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","activeVersionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","versionCounter":3814,"triggerCount":2,"shared":[{"updatedAt":"2026-03-18T05:20:48.224Z","createdAt":"2026-03-18T05:20:48.224Z","role":"workflow:owner","workflowId":"9sFwRyUDz51csAp7","projectId":"WGdp8QunI1tHpjXa","project":{"updatedAt":"2026-03-11T21:08:10.005Z","createdAt":"2026-03-11T21:05:11.541Z","id":"WGdp8QunI1tHpjXa","name":"will will ","type":"personal","icon":null,"description":null,"creatorId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"}}],"tags":[{"updatedAt":"2026-03-19T04:40:29.921Z","createdAt":"2026-03-19T04:40:29.921Z","id":"R9u3nhZlt6Vanvus","name":"telegram"},{"updatedAt":"2026-03-19T04:40:29.892Z","createdAt":"2026-03-19T04:40:29.892Z","id":"VfqIkUpiu2YMBSHw","name":"obsidian-sync"},{"updatedAt":"2026-03-19T04:40:29.877Z","createdAt":"2026-03-19T04:40:29.877Z","id":"qu6qwIegC1LgLKoA","name":"email-triage"},{"updatedAt":"2026-03-19T04:40:29.909Z","createdAt":"2026-03-19T04:40:29.909Z","id":"r3vsVtTwe9UfLrGi","name":"imap"},{"updatedAt":"2026-03-19T04:40:29.926Z","createdAt":"2026-03-19T04:40:29.926Z","id":"zKN5N7wCrUuKB7rV","name":"llm"}],"activeVersion":{"updatedAt":"2026-05-13T21:29:35.443Z","createdAt":"2026-05-13T21:29:35.443Z","versionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","workflowId":"9sFwRyUDz51csAp7","nodes":[{"parameters":{"rule":{"interval":[{"field":"minutes","minutesInterval":15}]}},"id":"n1","name":"Schedule Trigger","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1,"position":[240,304]},{"parameters":{"jsCode":"// DEFINITE NOISE - never worth seeing\nconst NOISE_SENDERS = [\n 'discord', 'plex', 'spotify', 'youtube',\n 'lodge at redmond ridge', 'flex +',\n 'seattle jeep',\n 'no-reply', 'noreply', 'do-not-reply', 'donotreply',\n 'newsletter', 'marketing',\n];\nconst NOISE_SUBJECTS = [\n 'bulletin board', 'daily digest', 'weekly digest',\n 'most watchlisted', 'newsletter',\n 'mentioned you in',\n 'looking to see what your car',\n 'take your favorite music',\n 'introducing the take',\n];\n\n// DEFINITE SIGNAL - always pass through, skip LLM\nconst SIGNAL_PATTERNS = [\n 'login attempt', 'unauthorized', 'unusual sign',\n 'invoice', 'payment due', 'receipt',\n 'urgent', 'action required',\n 'password reset', 'verify your',\n 'github', 'gitea',\n];\n\nconst items = $input.all();\nif (items.length === 0) return [];\n\n// Ignore schedule/no-email pass-through items from polling mode\nconst emailish = items.filter(item => {\n const j = item.json || {};\n return !!(j.from || j.subject || j.text || j.textPlain || j.textHtml || j.html || j.headers || j.messageId);\n});\nif (emailish.length === 0) return [];\n\n\nconst definiteSignal = [];\nconst needsJudgement = [];\n\nfor (const item of items) {\n const from = (item.json.from || '').toLowerCase();\n const subject = (item.json.subject || '').toLowerCase();\n const combined = from + ' ' + subject;\n\n // Definite signal - fast path, no LLM needed\n if (SIGNAL_PATTERNS.some(p => combined.includes(p))) {\n definiteSignal.push({ ...item.json, _stage1: 'definite_signal', _account: item.json._account || 'unknown' });\n continue;\n }\n\n // Definite noise - drop\n const isNoise = \n NOISE_SENDERS.some(n => combined.includes(n)) ||\n NOISE_SUBJECTS.some(n => new RegExp(n, 'i').test(combined));\n if (isNoise) continue;\n\n // Everything else - send to LLM for judgement\n needsJudgement.push({ ...item.json, _stage1: 'needs_judgement', _account: item.json._account || 'unknown' });\n}\n\n// Return all items for next node; tag them so we can route\nconst all = [...definiteSignal, ...needsJudgement];\nif (all.length === 0) return [{ json: { _empty: true } }];\nreturn all.map(j => ({ json: j }));"},"id":"n2","name":"Stage 1 - Static Filter","type":"n8n-nodes-base.code","typeVersion":2,"position":[464,304]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"loose"},"conditions":[{"id":"c1","leftValue":"={{ $json._empty }}","rightValue":true,"operator":{"type":"boolean","operation":"notEquals"}}],"combinator":"and"},"options":{}},"id":"n3","name":"Any Left?","type":"n8n-nodes-base.if","typeVersion":2,"position":[688,304]},{"parameters":{"conditions":{"string":[{"value1":"={{ $json._stage1 }}","value2":"needs_judgement"}]}},"id":"n4","name":"Needs LLM Judgement?","type":"n8n-nodes-base.if","typeVersion":1,"position":[912,208]},{"parameters":{"method":"POST","url":"http://172.19.0.1:18806/v1/chat/completions","sendHeaders":true,"headerParameters":{"parameters":[{"name":"Content-Type","value":"application/json"}]},"sendBody":true,"contentType":"raw","rawContentType":"application/json","body":"={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"temperature\": 0, \"max_tokens\": 60, \"messages\": [{\"role\": \"system\", \"content\": \"You are an email triage assistant for a software developer. Emails may be in any language \\u2014 translate mentally before judging. Reply with JSON only: {\\\"signal\\\": true|false, \\\"priority\\\": 1|2|3, \\\"reason\\\": \\\"one short phrase\\\"}. Priority: 1=act now, 2=read today, 3=FYI. Signal=false means drop silently. Always mark security alerts (login attempts, account access, suspicious activity) as signal priority 1, regardless of language.\"}, {\"role\": \"user\", \"content\": \"From: {{ $json.from }}\\nSubject: {{ $json.subject }}\"}]}","options":{"response":{"response":{"responseFormat":"json"}},"timeout":15000}},"id":"n5","name":"Judge with Local LLM","type":"n8n-nodes-base.httpRequest","typeVersion":4,"position":[1120,128]},{"parameters":{"jsCode":"const item = $input.first();\nconst inputItem = $('Needs LLM Judgement?').first();\n\ntry {\n let content = '';\n const j = item.json || {};\n\n if (j.choices && j.choices[0] && j.choices[0].message) {\n content = j.choices[0].message.content || '';\n } else if (j._readableState && j._readableState.buffer && j._readableState.buffer[0] && j._readableState.buffer[0].data) {\n const bytes = j._readableState.buffer[0].data;\n const raw = Buffer.from(bytes).toString('utf8');\n const parsed = JSON.parse(raw);\n content = parsed.choices[0].message.content || '';\n }\n\n content = content.trim();\n if (!content) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_empty', _priority: 3, _reason: 'no llm response' } }];\n }\n\n // Strip markdown code fences\n const cleaned = content.replace(/^[^\\{]*/, '').replace(/[^\\}]*$/, '').trim();\n const result = JSON.parse(cleaned);\n\n if (!result.signal) return [];\n\n return [{ json: { ...inputItem.json, _stage2: 'llm_signal', _priority: result.priority || 3, _reason: result.reason || '' } }];\n} catch(e) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_parse_error', _priority: 3, _reason: 'parse error: ' + e.message } }];\n}"},"id":"n6","name":"Parse LLM Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[1344,128]},{"parameters":{"jsCode":"const results = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n results.push({\n json: {\n from: String(j.from || ''),\n subject: String(j.subject || ''),\n date: String(j.date || ''),\n textPlain: String(j.textPlain || j.text || '').substring(0, 500),\n messageId: String(j.messageId || ''),\n _account: String(j._account || 'unknown'),\n _stage1: 'definite_signal',\n _stage2: 'definite_signal',\n _priority: 1,\n _reason: 'pattern match'\n }\n });\n}\nreturn results;"},"id":"n7","name":"Tag Definite Signal","type":"n8n-nodes-base.code","typeVersion":2,"position":[1120,304]},{"parameters":{"aggregate":"aggregateAllItemData","destinationFieldName":"messages","options":{}},"id":"n8","name":"Merge All Signal","type":"n8n-nodes-base.aggregate","typeVersion":1,"position":[1568,208]},{"parameters":{"jsCode":"const messages = ($input.first().json.messages || [])\n .sort((a, b) => (a._priority || 3) - (b._priority || 3));\n\nif (messages.length === 0) return [];\n\nconst PRIORITY_EMOJI = { 1: '🔴', 2: '🟡', 3: '🔵' };\n\nconst lines = messages.map((m, i) => {\n const from = (m.from || '(unknown)').replace(/<[^>]+>/g, '').trim().substring(0, 50);\n const subject = (m.subject || '(no subject)').trim().substring(0, 75);\n const emoji = PRIORITY_EMOJI[m._priority] || '🔵';\n const reason = m._reason && m._reason !== 'pattern match' ? ` — _${m._reason}_` : '';\n const acct = m._account && m._account !== 'unknown' ? ` [${m._account}]` : '';\n return `${emoji} ${subject}\\n ${from}${acct}${reason}`;\n});\n\nconst text = `📬 *${messages.length} new email${messages.length > 1 ? 's' : ''}*\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text } }];"},"id":"n9","name":"Format & Send","type":"n8n-nodes-base.code","typeVersion":2,"position":[1792,128]},{"parameters":{"chatId":"8367012007","text":"={{ $json.text }}","additionalFields":{"parse_mode":"Markdown"}},"id":"n10","name":"Send to Telegram","type":"n8n-nodes-base.telegram","typeVersion":1,"position":[2000,128],"webhookId":"795a0fc5-c932-4265-bd0d-095dd410f8a8","credentials":{"telegramApi":{"id":"aox4dyIWVSRdcH5z","name":"Telegram Bot (OpenClaw)"}}},{"parameters":{},"id":"n11","name":"Silent Stop","type":"n8n-nodes-base.noOp","typeVersion":1,"position":[688,464]},{"parameters":{"jsCode":"const wrapper = $input.first().json;\nconst messages = wrapper.messages || [];\nconst results = [];\n\nfor (const item of messages) {\n const now = new Date();\n const date = now.toISOString().split('T')[0];\n const subject = (item.subject || 'No Subject').replace(/[\\/\\\\?%*:|\"<>]/g, '-').substring(0, 80);\n const from = (item.from || 'unknown').replace(/<[^>]+>/g, '').trim();\n const snippet = (item.textPlain || '').substring(0, 500);\n const priority = item._priority || 3;\n const reason = item._reason || '';\n const PRIORITY_LABEL = {1: 'high', 2: 'medium', 3: 'low'};\n const PRIORITY_TAG = {1: 'priority-high', 2: 'priority-medium', 3: 'priority-low'};\n const frontmatter = '---\\ntitle: \"' + subject + '\"\\narea: notes\\ntags: [email, imap, ' + PRIORITY_TAG[priority] + ']\\ncreated: ' + date + '\\nupdated: ' + date + '\\nstatus: active\\nfrom: \"' + from + '\"\\npriority: ' + PRIORITY_LABEL[priority] + '\\nsignal_reason: \"' + reason + '\"\\n---';\n const content = frontmatter + '\\n\\n# ' + subject + '\\n\\n**From:** ' + from + '\\n**Date:** ' + date + '\\n**Priority:** ' + PRIORITY_LABEL[priority] + (reason ? ' — ' + reason : '') + '\\n\\n## Snippet\\n\\n' + snippet + '\\n\\n## Notes\\n\\n_Add notes here_\\n';\n results.push({ json: { path: 'Notes/' + date + ' ' + subject + '.md', content, subject, from, priority, date } });\n}\nreturn results;"},"id":"n12","name":"Format Email Notes","type":"n8n-nodes-base.code","typeVersion":2,"position":[1792,304]},{"parameters":{"method":"PUT","url":"=http://172.19.0.1:27123/vault/{{ encodeURIComponent($json.path).replace(/%2F/g, \"/\") }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","sendBody":true,"contentType":"raw","rawContentType":"text/markdown","body":"={{ $json.content }}","options":{"response":{"response":{"neverError":true}}}},"id":"n13","name":"Write Email to Vault","type":"n8n-nodes-base.httpRequest","typeVersion":4,"position":[2000,304],"credentials":{"httpHeaderAuth":{"id":"465Swz2b71O2KRAK","name":"Obsidian Local REST API"}},"onError":"continueRegularOutput"},{"parameters":{"options":{}},"id":"n1a","name":"Read Unseen Emails","type":"n8n-nodes-base.emailReadImap","typeVersion":2,"position":[352,656],"credentials":{"imap":{"id":"5qGEXTjFtPUZL8BB","name":"wills_portal IMAP"}}}],"connections":{"Stage 1 - Static Filter":{"main":[[{"node":"Any Left?","type":"main","index":0}]]},"Any Left?":{"main":[[{"node":"Needs LLM Judgement?","type":"main","index":0}],[{"node":"Silent Stop","type":"main","index":0}]]},"Needs LLM Judgement?":{"main":[[{"node":"Judge with Local LLM","type":"main","index":0}],[{"node":"Tag Definite Signal","type":"main","index":0}]]},"Judge with Local LLM":{"main":[[{"node":"Parse LLM Result","type":"main","index":0}]]},"Parse LLM Result":{"main":[[{"node":"Merge All Signal","type":"main","index":0}]]},"Tag Definite Signal":{"main":[[{"node":"Merge All Signal","type":"main","index":0}]]},"Merge All Signal":{"main":[[{"node":"Format & Send","type":"main","index":0},{"node":"Format Email Notes","type":"main","index":0}]]},"Format & Send":{"main":[[{"node":"Send to Telegram","type":"main","index":0}]]},"Format Email Notes":{"main":[[{"node":"Write Email to Vault","type":"main","index":0}]]},"Schedule Trigger":{"main":[[{"node":"Read Unseen Emails","type":"main","index":0}]]},"Read Unseen Emails":{"main":[[{"node":"Stage 1 - Static Filter","type":"main","index":0}]]}},"authors":"will will","name":null,"description":null,"autosaved":false,"workflowPublishHistory":[{"createdAt":"2026-05-13T21:29:37.830Z","id":1426,"workflowId":"9sFwRyUDz51csAp7","versionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","event":"activated","userId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"},{"createdAt":"2026-05-13T21:29:47.863Z","id":1428,"workflowId":"9sFwRyUDz51csAp7","versionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","event":"activated","userId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"},{"createdAt":"2026-05-13T21:29:35.811Z","id":1425,"workflowId":"9sFwRyUDz51csAp7","versionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","event":"deactivated","userId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"},{"createdAt":"2026-05-13T21:29:46.033Z","id":1427,"workflowId":"9sFwRyUDz51csAp7","versionId":"4c4e4dab-f40b-48aa-9bd9-8db805bbf1e4","event":"deactivated","userId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"}]}} \ No newline at end of file +{ + "updatedAt": "2026-05-14T00:02:05.677Z", + "createdAt": "2026-03-18T05:20:48.223Z", + "id": "9sFwRyUDz51csAp7", + "name": "IMAP Inbox Triage + Obsidian Notes", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + }, + "id": "n1", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 240, + 304 + ] + }, + { + "parameters": { + "jsCode": "// DEFINITE NOISE - never worth seeing\nconst NOISE_SENDERS = [\n 'discord', 'plex', 'spotify', 'youtube',\n 'lodge at redmond ridge', 'flex +',\n 'seattle jeep',\n 'no-reply', 'noreply', 'do-not-reply', 'donotreply',\n 'newsletter', 'marketing',\n];\nconst NOISE_SUBJECTS = [\n 'bulletin board', 'daily digest', 'weekly digest',\n 'most watchlisted', 'newsletter',\n 'mentioned you in',\n 'looking to see what your car',\n 'take your favorite music',\n 'introducing the take',\n];\n\n// DEFINITE SIGNAL - always pass through, skip LLM\nconst SIGNAL_PATTERNS = [\n 'login attempt', 'unauthorized', 'unusual sign',\n 'invoice', 'payment due', 'receipt',\n 'urgent', 'action required',\n 'password reset', 'verify your',\n 'github', 'gitea',\n];\n\nconst items = $input.all();\nif (items.length === 0) return [];\n\n// Ignore schedule/no-email pass-through items from polling mode\nconst emailish = items.filter(item => {\n const j = item.json || {};\n return !!(j.from || j.subject || j.text || j.textPlain || j.textHtml || j.html || j.headers || j.messageId);\n});\nif (emailish.length === 0) return [];\n\n\nconst definiteSignal = [];\nconst needsJudgement = [];\n\nfor (const item of items) {\n const from = (item.json.from || '').toLowerCase();\n const subject = (item.json.subject || '').toLowerCase();\n const combined = from + ' ' + subject;\n\n // Definite signal - fast path, no LLM needed\n if (SIGNAL_PATTERNS.some(p => combined.includes(p))) {\n definiteSignal.push({ ...item.json, _stage1: 'definite_signal', _account: item.json._account || 'unknown' });\n continue;\n }\n\n // Definite noise - drop\n const isNoise = \n NOISE_SENDERS.some(n => combined.includes(n)) ||\n NOISE_SUBJECTS.some(n => new RegExp(n, 'i').test(combined));\n if (isNoise) continue;\n\n // Everything else - send to LLM for judgement\n needsJudgement.push({ ...item.json, _stage1: 'needs_judgement', _account: item.json._account || 'unknown' });\n}\n\n// Return all items for next node; tag them so we can route\nconst all = [...definiteSignal, ...needsJudgement];\nif (all.length === 0) return [{ json: { _empty: true } }];\nreturn all.map(j => ({ json: j }));" + }, + "id": "n2", + "name": "Stage 1 - Static Filter", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 464, + 304 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json._empty }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "notEquals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "n3", + "name": "Any Left?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 688, + 304 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json._stage1 }}", + "value2": "needs_judgement" + } + ] + } + }, + "id": "n4", + "name": "Needs LLM Judgement?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 912, + 208 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18806/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "application/json", + "body": "={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"temperature\": 0, \"max_tokens\": 256, \"messages\": [{\"role\": \"system\", \"content\": \"You are an email triage assistant for a software developer. Emails may be in any language \\u2014 translate mentally before judging. Reply with JSON only: {\\\"signal\\\": true|false, \\\"priority\\\": 1|2|3, \\\"reason\\\": \\\"one short phrase\\\"}. Priority: 1=act now, 2=read today, 3=FYI. Signal=false means drop silently. Always mark security alerts (login attempts, account access, suspicious activity) as signal priority 1, regardless of language.\"}, {\"role\": \"user\", \"content\": \"From: {{ $json.from }}\\nSubject: {{ $json.subject }}\"}]}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 15000 + } + }, + "id": "n5", + "name": "Judge with Local LLM", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1120, + 128 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst inputItem = $('Needs LLM Judgement?').first();\n\ntry {\n let content = '';\n const j = item.json || {};\n\n if (j.choices && j.choices[0] && j.choices[0].message) {\n content = j.choices[0].message.content || '';\n } else if (j._readableState && j._readableState.buffer && j._readableState.buffer[0] && j._readableState.buffer[0].data) {\n const bytes = j._readableState.buffer[0].data;\n const raw = Buffer.from(bytes).toString('utf8');\n const parsed = JSON.parse(raw);\n content = parsed.choices[0].message.content || '';\n }\n\n content = content.trim();\n if (!content) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_empty', _priority: 3, _reason: 'no llm response' } }];\n }\n\n // Strip markdown code fences\n const cleaned = content.replace(/^[^\\{]*/, '').replace(/[^\\}]*$/, '').trim();\n const result = JSON.parse(cleaned);\n\n if (!result.signal) return [];\n\n return [{ json: { ...inputItem.json, _stage2: 'llm_signal', _priority: result.priority || 3, _reason: result.reason || '' } }];\n} catch(e) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_parse_error', _priority: 3, _reason: 'parse error: ' + e.message } }];\n}" + }, + "id": "n6", + "name": "Parse LLM Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1344, + 128 + ] + }, + { + "parameters": { + "jsCode": "const results = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n results.push({\n json: {\n from: String(j.from || ''),\n subject: String(j.subject || ''),\n date: String(j.date || ''),\n textPlain: String(j.textPlain || j.text || '').substring(0, 500),\n messageId: String(j.messageId || ''),\n _account: String(j._account || 'unknown'),\n _stage1: 'definite_signal',\n _stage2: 'definite_signal',\n _priority: 1,\n _reason: 'pattern match'\n }\n });\n}\nreturn results;" + }, + "id": "n7", + "name": "Tag Definite Signal", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 304 + ] + }, + { + "parameters": { + "aggregate": "aggregateAllItemData", + "destinationFieldName": "messages", + "options": {} + }, + "id": "n8", + "name": "Merge All Signal", + "type": "n8n-nodes-base.aggregate", + "typeVersion": 1, + "position": [ + 1568, + 208 + ] + }, + { + "parameters": { + "jsCode": "const messages = ($input.first().json.messages || [])\n .sort((a, b) => (a._priority || 3) - (b._priority || 3));\n\nif (messages.length === 0) return [];\n\nconst PRIORITY_EMOJI = { 1: '🔴', 2: '🟡', 3: '🔵' };\n\nconst lines = messages.map((m, i) => {\n const from = (m.from || '(unknown)').replace(/<[^>]+>/g, '').trim().substring(0, 50);\n const subject = (m.subject || '(no subject)').trim().substring(0, 75);\n const emoji = PRIORITY_EMOJI[m._priority] || '🔵';\n const reason = m._reason && m._reason !== 'pattern match' ? ` — _${m._reason}_` : '';\n const acct = m._account && m._account !== 'unknown' ? ` [${m._account}]` : '';\n return `${emoji} ${subject}\\n ${from}${acct}${reason}`;\n});\n\nconst text = `📬 *${messages.length} new email${messages.length > 1 ? 's' : ''}*\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text } }];" + }, + "id": "n9", + "name": "Format & Send", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1792, + 128 + ] + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{ $json.text }}", + "additionalFields": { + "parse_mode": "Markdown" + } + }, + "id": "n10", + "name": "Send to Telegram", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [ + 2000, + 128 + ], + "webhookId": "795a0fc5-c932-4265-bd0d-095dd410f8a8", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + } + }, + { + "parameters": {}, + "id": "n11", + "name": "Silent Stop", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 688, + 464 + ] + }, + { + "parameters": { + "jsCode": "const wrapper = $input.first().json;\nconst messages = wrapper.messages || [];\nconst results = [];\n\nfor (const item of messages) {\n const now = new Date();\n const date = now.toISOString().split('T')[0];\n const subject = (item.subject || 'No Subject').replace(/[\\/\\\\?%*:|\"<>]/g, '-').substring(0, 80);\n const from = (item.from || 'unknown').replace(/<[^>]+>/g, '').trim();\n const snippet = (item.textPlain || '').substring(0, 500);\n const priority = item._priority || 3;\n const reason = item._reason || '';\n const PRIORITY_LABEL = {1: 'high', 2: 'medium', 3: 'low'};\n const PRIORITY_TAG = {1: 'priority-high', 2: 'priority-medium', 3: 'priority-low'};\n const frontmatter = '---\\ntitle: \"' + subject + '\"\\narea: notes\\ntags: [email, imap, ' + PRIORITY_TAG[priority] + ']\\ncreated: ' + date + '\\nupdated: ' + date + '\\nstatus: active\\nfrom: \"' + from + '\"\\npriority: ' + PRIORITY_LABEL[priority] + '\\nsignal_reason: \"' + reason + '\"\\n---';\n const content = frontmatter + '\\n\\n# ' + subject + '\\n\\n**From:** ' + from + '\\n**Date:** ' + date + '\\n**Priority:** ' + PRIORITY_LABEL[priority] + (reason ? ' — ' + reason : '') + '\\n\\n## Snippet\\n\\n' + snippet + '\\n\\n## Notes\\n\\n_Add notes here_\\n';\n results.push({ json: { path: 'Notes/' + date + ' ' + subject + '.md', content, subject, from, priority, date } });\n}\nreturn results;" + }, + "id": "n12", + "name": "Format Email Notes", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1792, + 304 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "=http://172.19.0.1:27123/vault/{{ encodeURIComponent($json.path).replace(/%2F/g, \"/\") }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{ $json.content }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "id": "n13", + "name": "Write Email to Vault", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2000, + 304 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + }, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "options": {} + }, + "id": "n1a", + "name": "Read Unseen Emails", + "type": "n8n-nodes-base.emailReadImap", + "typeVersion": 2, + "position": [ + 352, + 656 + ], + "credentials": { + "imap": { + "id": "5qGEXTjFtPUZL8BB", + "name": "wills_portal IMAP" + } + } + } + ], + "connections": { + "Stage 1 - Static Filter": { + "main": [ + [ + { + "node": "Any Left?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any Left?": { + "main": [ + [ + { + "node": "Needs LLM Judgement?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Silent Stop", + "type": "main", + "index": 0 + } + ] + ] + }, + "Needs LLM Judgement?": { + "main": [ + [ + { + "node": "Judge with Local LLM", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Tag Definite Signal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Judge with Local LLM": { + "main": [ + [ + { + "node": "Parse LLM Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse LLM Result": { + "main": [ + [ + { + "node": "Merge All Signal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Tag Definite Signal": { + "main": [ + [ + { + "node": "Merge All Signal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge All Signal": { + "main": [ + [ + { + "node": "Format & Send", + "type": "main", + "index": 0 + }, + { + "node": "Format Email Notes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format & Send": { + "main": [ + [ + { + "node": "Send to Telegram", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Email Notes": { + "main": [ + [ + { + "node": "Write Email to Vault", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger": { + "main": [ + [ + { + "node": "Read Unseen Emails", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Unseen Emails": { + "main": [ + [ + { + "node": "Stage 1 - Static Filter", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "saveDataSuccessExecution": "all", + "saveDataErrorExecution": "all", + "saveManualExecutions": true + }, + "staticData": { + "node:Schedule Trigger": { + "recurrenceRules": [] + }, + "node:Read Unseen Emails": {}, + "node:Read wills_portal": { + "lastMessageUid": 8770 + }, + "node:Read squareffect": {}, + "node:Schedule wills_portal": { + "recurrenceRules": [] + }, + "node:Schedule squareffect": { + "recurrenceRules": [] + }, + "node:Email Trigger": {} + }, + "meta": null, + "pinData": {}, + "versionId": "8b39192f-1924-42d0-a421-afe88cdee3cf", + "activeVersionId": "8b39192f-1924-42d0-a421-afe88cdee3cf", + "versionCounter": 3824, + "triggerCount": 2, + "shared": [ + { + "updatedAt": "2026-03-18T05:20:48.224Z", + "createdAt": "2026-03-18T05:20:48.224Z", + "role": "workflow:owner", + "workflowId": "9sFwRyUDz51csAp7", + "projectId": "WGdp8QunI1tHpjXa", + "project": { + "updatedAt": "2026-03-11T21:08:10.005Z", + "createdAt": "2026-03-11T21:05:11.541Z", + "id": "WGdp8QunI1tHpjXa", + "name": "will will ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + } + ], + "tags": [ + { + "updatedAt": "2026-03-19T04:40:29.921Z", + "createdAt": "2026-03-19T04:40:29.921Z", + "id": "R9u3nhZlt6Vanvus", + "name": "telegram" + }, + { + "updatedAt": "2026-03-19T04:40:29.892Z", + "createdAt": "2026-03-19T04:40:29.892Z", + "id": "VfqIkUpiu2YMBSHw", + "name": "obsidian-sync" + }, + { + "updatedAt": "2026-03-19T04:40:29.877Z", + "createdAt": "2026-03-19T04:40:29.877Z", + "id": "qu6qwIegC1LgLKoA", + "name": "email-triage" + }, + { + "updatedAt": "2026-03-19T04:40:29.909Z", + "createdAt": "2026-03-19T04:40:29.909Z", + "id": "r3vsVtTwe9UfLrGi", + "name": "imap" + }, + { + "updatedAt": "2026-03-19T04:40:29.926Z", + "createdAt": "2026-03-19T04:40:29.926Z", + "id": "zKN5N7wCrUuKB7rV", + "name": "llm" + } + ], + "activeVersion": { + "updatedAt": "2026-05-14T00:02:05.678Z", + "createdAt": "2026-05-14T00:02:05.678Z", + "versionId": "8b39192f-1924-42d0-a421-afe88cdee3cf", + "workflowId": "9sFwRyUDz51csAp7", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + }, + "id": "n1", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 240, + 304 + ] + }, + { + "parameters": { + "jsCode": "// DEFINITE NOISE - never worth seeing\nconst NOISE_SENDERS = [\n 'discord', 'plex', 'spotify', 'youtube',\n 'lodge at redmond ridge', 'flex +',\n 'seattle jeep',\n 'no-reply', 'noreply', 'do-not-reply', 'donotreply',\n 'newsletter', 'marketing',\n];\nconst NOISE_SUBJECTS = [\n 'bulletin board', 'daily digest', 'weekly digest',\n 'most watchlisted', 'newsletter',\n 'mentioned you in',\n 'looking to see what your car',\n 'take your favorite music',\n 'introducing the take',\n];\n\n// DEFINITE SIGNAL - always pass through, skip LLM\nconst SIGNAL_PATTERNS = [\n 'login attempt', 'unauthorized', 'unusual sign',\n 'invoice', 'payment due', 'receipt',\n 'urgent', 'action required',\n 'password reset', 'verify your',\n 'github', 'gitea',\n];\n\nconst items = $input.all();\nif (items.length === 0) return [];\n\n// Ignore schedule/no-email pass-through items from polling mode\nconst emailish = items.filter(item => {\n const j = item.json || {};\n return !!(j.from || j.subject || j.text || j.textPlain || j.textHtml || j.html || j.headers || j.messageId);\n});\nif (emailish.length === 0) return [];\n\n\nconst definiteSignal = [];\nconst needsJudgement = [];\n\nfor (const item of items) {\n const from = (item.json.from || '').toLowerCase();\n const subject = (item.json.subject || '').toLowerCase();\n const combined = from + ' ' + subject;\n\n // Definite signal - fast path, no LLM needed\n if (SIGNAL_PATTERNS.some(p => combined.includes(p))) {\n definiteSignal.push({ ...item.json, _stage1: 'definite_signal', _account: item.json._account || 'unknown' });\n continue;\n }\n\n // Definite noise - drop\n const isNoise = \n NOISE_SENDERS.some(n => combined.includes(n)) ||\n NOISE_SUBJECTS.some(n => new RegExp(n, 'i').test(combined));\n if (isNoise) continue;\n\n // Everything else - send to LLM for judgement\n needsJudgement.push({ ...item.json, _stage1: 'needs_judgement', _account: item.json._account || 'unknown' });\n}\n\n// Return all items for next node; tag them so we can route\nconst all = [...definiteSignal, ...needsJudgement];\nif (all.length === 0) return [{ json: { _empty: true } }];\nreturn all.map(j => ({ json: j }));" + }, + "id": "n2", + "name": "Stage 1 - Static Filter", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 464, + 304 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose" + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json._empty }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "notEquals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "n3", + "name": "Any Left?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 688, + 304 + ] + }, + { + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json._stage1 }}", + "value2": "needs_judgement" + } + ] + } + }, + "id": "n4", + "name": "Needs LLM Judgement?", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [ + 912, + 208 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18806/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "application/json", + "body": "={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"temperature\": 0, \"max_tokens\": 256, \"messages\": [{\"role\": \"system\", \"content\": \"You are an email triage assistant for a software developer. Emails may be in any language \\u2014 translate mentally before judging. Reply with JSON only: {\\\"signal\\\": true|false, \\\"priority\\\": 1|2|3, \\\"reason\\\": \\\"one short phrase\\\"}. Priority: 1=act now, 2=read today, 3=FYI. Signal=false means drop silently. Always mark security alerts (login attempts, account access, suspicious activity) as signal priority 1, regardless of language.\"}, {\"role\": \"user\", \"content\": \"From: {{ $json.from }}\\nSubject: {{ $json.subject }}\"}]}", + "options": { + "response": { + "response": { + "responseFormat": "json" + } + }, + "timeout": 15000 + } + }, + "id": "n5", + "name": "Judge with Local LLM", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1120, + 128 + ] + }, + { + "parameters": { + "jsCode": "const item = $input.first();\nconst inputItem = $('Needs LLM Judgement?').first();\n\ntry {\n let content = '';\n const j = item.json || {};\n\n if (j.choices && j.choices[0] && j.choices[0].message) {\n content = j.choices[0].message.content || '';\n } else if (j._readableState && j._readableState.buffer && j._readableState.buffer[0] && j._readableState.buffer[0].data) {\n const bytes = j._readableState.buffer[0].data;\n const raw = Buffer.from(bytes).toString('utf8');\n const parsed = JSON.parse(raw);\n content = parsed.choices[0].message.content || '';\n }\n\n content = content.trim();\n if (!content) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_empty', _priority: 3, _reason: 'no llm response' } }];\n }\n\n // Strip markdown code fences\n const cleaned = content.replace(/^[^\\{]*/, '').replace(/[^\\}]*$/, '').trim();\n const result = JSON.parse(cleaned);\n\n if (!result.signal) return [];\n\n return [{ json: { ...inputItem.json, _stage2: 'llm_signal', _priority: result.priority || 3, _reason: result.reason || '' } }];\n} catch(e) {\n return [{ json: { ...inputItem.json, _stage2: 'llm_parse_error', _priority: 3, _reason: 'parse error: ' + e.message } }];\n}" + }, + "id": "n6", + "name": "Parse LLM Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1344, + 128 + ] + }, + { + "parameters": { + "jsCode": "const results = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n results.push({\n json: {\n from: String(j.from || ''),\n subject: String(j.subject || ''),\n date: String(j.date || ''),\n textPlain: String(j.textPlain || j.text || '').substring(0, 500),\n messageId: String(j.messageId || ''),\n _account: String(j._account || 'unknown'),\n _stage1: 'definite_signal',\n _stage2: 'definite_signal',\n _priority: 1,\n _reason: 'pattern match'\n }\n });\n}\nreturn results;" + }, + "id": "n7", + "name": "Tag Definite Signal", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 304 + ] + }, + { + "parameters": { + "aggregate": "aggregateAllItemData", + "destinationFieldName": "messages", + "options": {} + }, + "id": "n8", + "name": "Merge All Signal", + "type": "n8n-nodes-base.aggregate", + "typeVersion": 1, + "position": [ + 1568, + 208 + ] + }, + { + "parameters": { + "jsCode": "const messages = ($input.first().json.messages || [])\n .sort((a, b) => (a._priority || 3) - (b._priority || 3));\n\nif (messages.length === 0) return [];\n\nconst PRIORITY_EMOJI = { 1: '🔴', 2: '🟡', 3: '🔵' };\n\nconst lines = messages.map((m, i) => {\n const from = (m.from || '(unknown)').replace(/<[^>]+>/g, '').trim().substring(0, 50);\n const subject = (m.subject || '(no subject)').trim().substring(0, 75);\n const emoji = PRIORITY_EMOJI[m._priority] || '🔵';\n const reason = m._reason && m._reason !== 'pattern match' ? ` — _${m._reason}_` : '';\n const acct = m._account && m._account !== 'unknown' ? ` [${m._account}]` : '';\n return `${emoji} ${subject}\\n ${from}${acct}${reason}`;\n});\n\nconst text = `📬 *${messages.length} new email${messages.length > 1 ? 's' : ''}*\\n\\n${lines.join('\\n\\n')}`;\nreturn [{ json: { text } }];" + }, + "id": "n9", + "name": "Format & Send", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1792, + 128 + ] + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{ $json.text }}", + "additionalFields": { + "parse_mode": "Markdown" + } + }, + "id": "n10", + "name": "Send to Telegram", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [ + 2000, + 128 + ], + "webhookId": "795a0fc5-c932-4265-bd0d-095dd410f8a8", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + } + }, + { + "parameters": {}, + "id": "n11", + "name": "Silent Stop", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 688, + 464 + ] + }, + { + "parameters": { + "jsCode": "const wrapper = $input.first().json;\nconst messages = wrapper.messages || [];\nconst results = [];\n\nfor (const item of messages) {\n const now = new Date();\n const date = now.toISOString().split('T')[0];\n const subject = (item.subject || 'No Subject').replace(/[\\/\\\\?%*:|\"<>]/g, '-').substring(0, 80);\n const from = (item.from || 'unknown').replace(/<[^>]+>/g, '').trim();\n const snippet = (item.textPlain || '').substring(0, 500);\n const priority = item._priority || 3;\n const reason = item._reason || '';\n const PRIORITY_LABEL = {1: 'high', 2: 'medium', 3: 'low'};\n const PRIORITY_TAG = {1: 'priority-high', 2: 'priority-medium', 3: 'priority-low'};\n const frontmatter = '---\\ntitle: \"' + subject + '\"\\narea: notes\\ntags: [email, imap, ' + PRIORITY_TAG[priority] + ']\\ncreated: ' + date + '\\nupdated: ' + date + '\\nstatus: active\\nfrom: \"' + from + '\"\\npriority: ' + PRIORITY_LABEL[priority] + '\\nsignal_reason: \"' + reason + '\"\\n---';\n const content = frontmatter + '\\n\\n# ' + subject + '\\n\\n**From:** ' + from + '\\n**Date:** ' + date + '\\n**Priority:** ' + PRIORITY_LABEL[priority] + (reason ? ' — ' + reason : '') + '\\n\\n## Snippet\\n\\n' + snippet + '\\n\\n## Notes\\n\\n_Add notes here_\\n';\n results.push({ json: { path: 'Notes/' + date + ' ' + subject + '.md', content, subject, from, priority, date } });\n}\nreturn results;" + }, + "id": "n12", + "name": "Format Email Notes", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1792, + 304 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "=http://172.19.0.1:27123/vault/{{ encodeURIComponent($json.path).replace(/%2F/g, \"/\") }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{ $json.content }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "id": "n13", + "name": "Write Email to Vault", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2000, + 304 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + }, + "onError": "continueRegularOutput" + }, + { + "parameters": { + "options": {} + }, + "id": "n1a", + "name": "Read Unseen Emails", + "type": "n8n-nodes-base.emailReadImap", + "typeVersion": 2, + "position": [ + 352, + 656 + ], + "credentials": { + "imap": { + "id": "5qGEXTjFtPUZL8BB", + "name": "wills_portal IMAP" + } + } + } + ], + "connections": { + "Stage 1 - Static Filter": { + "main": [ + [ + { + "node": "Any Left?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any Left?": { + "main": [ + [ + { + "node": "Needs LLM Judgement?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Silent Stop", + "type": "main", + "index": 0 + } + ] + ] + }, + "Needs LLM Judgement?": { + "main": [ + [ + { + "node": "Judge with Local LLM", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Tag Definite Signal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Judge with Local LLM": { + "main": [ + [ + { + "node": "Parse LLM Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse LLM Result": { + "main": [ + [ + { + "node": "Merge All Signal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Tag Definite Signal": { + "main": [ + [ + { + "node": "Merge All Signal", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge All Signal": { + "main": [ + [ + { + "node": "Format & Send", + "type": "main", + "index": 0 + }, + { + "node": "Format Email Notes", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format & Send": { + "main": [ + [ + { + "node": "Send to Telegram", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Email Notes": { + "main": [ + [ + { + "node": "Write Email to Vault", + "type": "main", + "index": 0 + } + ] + ] + }, + "Schedule Trigger": { + "main": [ + [ + { + "node": "Read Unseen Emails", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Unseen Emails": { + "main": [ + [ + { + "node": "Stage 1 - Static Filter", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "will will", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T00:02:07.948Z", + "id": 1469, + "workflowId": "9sFwRyUDz51csAp7", + "versionId": "8b39192f-1924-42d0-a421-afe88cdee3cf", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:02:06.050Z", + "id": 1468, + "workflowId": "9sFwRyUDz51csAp7", + "versionId": "8b39192f-1924-42d0-a421-afe88cdee3cf", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + ] + } +} diff --git a/swarm-common/n8n-workflows/El1BHJZ56JlzhrRZ.json b/swarm-common/n8n-workflows/El1BHJZ56JlzhrRZ.json new file mode 100644 index 0000000..111a286 --- /dev/null +++ b/swarm-common/n8n-workflows/El1BHJZ56JlzhrRZ.json @@ -0,0 +1,486 @@ +{ + "updatedAt": "2026-05-14T00:03:13.116Z", + "createdAt": "2026-05-12T17:56:05.279Z", + "id": "El1BHJZ56JlzhrRZ", + "name": "Voice Memo Capture (Audio URL + Local Whisper)", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "voice-memo", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -980, + 0 + ], + "id": "9f1da0a8-32db-4e67-a6e4-18cf8b4d42ee", + "name": "Webhook - Voice Memo", + "webhookId": "06796590-13b3-4347-9582-1ac92719c95d" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\n\nconst audio_url = String(body.audio_url || body.url || '').trim();\nconst telegram_file_id = String(body.telegram_file_id || body.file_id || '').trim();\nconst discord_audio_url = String(body.discord_audio_url || '').trim();\nconst audio_base64 = String(body.audio_base64 || '').trim();\nconst audio_format = String(body.audio_format || body.format || 'ogg').trim();\nconst language = String(body.language || 'en').trim();\nconst title = String(body.title || 'Voice Memo').trim();\nconst tags = Array.isArray(body.tags) ? body.tags : String(body.tags || 'voice,memo').split(',').map(s => s.trim()).filter(Boolean);\nconst include_tts = body.include_tts === true || body.tts_readback === true;\nconst voice = String(body.voice || body.tts_voice || 'af_heart').trim();\nif (!audio_url && !telegram_file_id && !discord_audio_url && !audio_base64) {\n throw new Error('POST JSON must include audio_url, telegram_file_id, discord_audio_url, or audio_base64');\n}\nreturn [{ json: { audio_url, telegram_file_id, discord_audio_url, audio_base64, audio_format, language, title, tags, include_tts, voice } }];" + }, + "id": "vm-normalize-v2", + "name": "Normalize Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -680, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18813/process", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ audio_url: $json.audio_url, telegram_file_id: $json.telegram_file_id, discord_audio_url: $json.discord_audio_url, title: $json.title, tags: $json.tags, include_tts: $json.include_tts, voice: $json.voice }) }}", + "options": { + "timeout": 180000, + "fullResponse": false + } + }, + "id": "vm-process-v2", + "name": "Process Voice Memo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -460, + 0 + ] + }, + { + "parameters": { + "jsCode": "const input = $('Normalize Input').first().json;\nconst proc = $input.first().json;\n\nfunction slugify(s) { return String(s || 'voice-memo').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'voice-memo'; }\nfunction yaml(s) { return String(s ?? '').split('\\n').join(' ').replaceAll('\"', '\\\\\"'); }\n\nconst date = new Date(proc.created_at || Date.now());\nconst ymd = date.toISOString().slice(0,10);\nconst notePath = `Voice Memos/${ymd}-${slugify(proc.title || input.title)}.md`;\n\nconst title = proc.title || input.title || 'Voice Memo';\nconst tags = proc.tags || input.tags || ['voice', 'memo'];\nconst tagLines = tags.map(t => ` - ${yaml(t)}`).join('\\n');\nconst sourceType = proc.source_type || input.source || 'unknown';\nconst sourceUrl = input.source_url || '';\n\nlet audioNote = '';\nif (proc.tts_audio_url) {\n audioNote = `\\n## Audio Summary\\n\\n> Listen to the AI-generated summary: ${proc.tts_audio_url}\\n`;\n}\n\nconst markdown = `---\\ntitle: \"${yaml(title)}\"\\nsource: \"${yaml(sourceUrl)}\"\\nsource_type: \"${sourceType}\"\\ncreated: \"${date.toISOString()}\"\\ntags:\\n${tagLines}\\n---\\n\\n# ${title}\\n\\n## Summary\\n\\n${(proc.summary || '').trim()}\\n${audioNote}\\n## Transcript\\n\\n${proc.transcript || 'No transcript available.'}\\n`;\n\nreturn [{ json: { ...input, notePath, markdown, title, tts_audio_url: proc.tts_audio_url || null } }];\n" + }, + "id": "vm-build-obsidian-v2", + "name": "Build Obsidian Note", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -240, + 0 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "={{'http://172.19.0.1:27123/vault/' + encodeURIComponent($json.notePath).replace(/%2F/g, '/')}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "text/markdown" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{$json.markdown}}", + "options": { + "timeout": 30000 + }, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" + }, + "id": "vm-write-obsidian-v2", + "name": "Write Note to Obsidian", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 0, + 0 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + } + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{ \"Voice memo captured (\" + $json.source_type + \"): \" + $json.title + \"\\nObsidian: \" + $json.notePath + ($json.tts_audio_url ? \"\\nAudio summary: \" + $json.tts_audio_url : \"\") }}", + "additionalFields": {} + }, + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1160, + -80 + ], + "id": "41bf5a55-2047-400a-87c7-44744a0f2a42", + "name": "Send Telegram Notification", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + } + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ ok: true, notePath: $json.notePath, title: $json.title, source_type: $json.source_type, tts_audio_url: $json.tts_audio_url || null }) }}" + }, + "id": "vm-respond-v2", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 460, + 0 + ] + } + ], + "connections": { + "Webhook - Voice Memo": { + "main": [ + [ + { + "node": "Normalize Input", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Input": { + "main": [ + [ + { + "node": "Process Voice Memo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Process Voice Memo": { + "main": [ + [ + { + "node": "Build Obsidian Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Obsidian Note": { + "main": [ + [ + { + "node": "Write Note to Obsidian", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Note to Obsidian": { + "main": [ + [ + { + "node": "Send Telegram Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Telegram Notification": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "timezone": "America/Los_Angeles", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "none", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "staticData": null, + "meta": null, + "pinData": null, + "versionId": "4511e901-afab-493e-9b17-99a9d9865147", + "activeVersionId": "4511e901-afab-493e-9b17-99a9d9865147", + "versionCounter": 38, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-12T17:56:05.281Z", + "createdAt": "2026-05-12T17:56:05.281Z", + "role": "workflow:owner", + "workflowId": "El1BHJZ56JlzhrRZ", + "projectId": "WGdp8QunI1tHpjXa", + "project": { + "updatedAt": "2026-03-11T21:08:10.005Z", + "createdAt": "2026-03-11T21:05:11.541Z", + "id": "WGdp8QunI1tHpjXa", + "name": "will will ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-14T00:03:13.117Z", + "createdAt": "2026-05-14T00:03:13.117Z", + "versionId": "4511e901-afab-493e-9b17-99a9d9865147", + "workflowId": "El1BHJZ56JlzhrRZ", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "voice-memo", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -980, + 0 + ], + "id": "9f1da0a8-32db-4e67-a6e4-18cf8b4d42ee", + "name": "Webhook - Voice Memo", + "webhookId": "06796590-13b3-4347-9582-1ac92719c95d" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\n\nconst audio_url = String(body.audio_url || body.url || '').trim();\nconst telegram_file_id = String(body.telegram_file_id || body.file_id || '').trim();\nconst discord_audio_url = String(body.discord_audio_url || '').trim();\nconst audio_base64 = String(body.audio_base64 || '').trim();\nconst audio_format = String(body.audio_format || body.format || 'ogg').trim();\nconst language = String(body.language || 'en').trim();\nconst title = String(body.title || 'Voice Memo').trim();\nconst tags = Array.isArray(body.tags) ? body.tags : String(body.tags || 'voice,memo').split(',').map(s => s.trim()).filter(Boolean);\nconst include_tts = body.include_tts === true || body.tts_readback === true;\nconst voice = String(body.voice || body.tts_voice || 'af_heart').trim();\nif (!audio_url && !telegram_file_id && !discord_audio_url && !audio_base64) {\n throw new Error('POST JSON must include audio_url, telegram_file_id, discord_audio_url, or audio_base64');\n}\nreturn [{ json: { audio_url, telegram_file_id, discord_audio_url, audio_base64, audio_format, language, title, tags, include_tts, voice } }];" + }, + "id": "vm-normalize-v2", + "name": "Normalize Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -680, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18813/process", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ audio_url: $json.audio_url, telegram_file_id: $json.telegram_file_id, discord_audio_url: $json.discord_audio_url, title: $json.title, tags: $json.tags, include_tts: $json.include_tts, voice: $json.voice }) }}", + "options": { + "timeout": 180000, + "fullResponse": false + } + }, + "id": "vm-process-v2", + "name": "Process Voice Memo", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -460, + 0 + ] + }, + { + "parameters": { + "jsCode": "const input = $('Normalize Input').first().json;\nconst proc = $input.first().json;\n\nfunction slugify(s) { return String(s || 'voice-memo').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'voice-memo'; }\nfunction yaml(s) { return String(s ?? '').split('\\n').join(' ').replaceAll('\"', '\\\\\"'); }\n\nconst date = new Date(proc.created_at || Date.now());\nconst ymd = date.toISOString().slice(0,10);\nconst notePath = `Voice Memos/${ymd}-${slugify(proc.title || input.title)}.md`;\n\nconst title = proc.title || input.title || 'Voice Memo';\nconst tags = proc.tags || input.tags || ['voice', 'memo'];\nconst tagLines = tags.map(t => ` - ${yaml(t)}`).join('\\n');\nconst sourceType = proc.source_type || input.source || 'unknown';\nconst sourceUrl = input.source_url || '';\n\nlet audioNote = '';\nif (proc.tts_audio_url) {\n audioNote = `\\n## Audio Summary\\n\\n> Listen to the AI-generated summary: ${proc.tts_audio_url}\\n`;\n}\n\nconst markdown = `---\\ntitle: \"${yaml(title)}\"\\nsource: \"${yaml(sourceUrl)}\"\\nsource_type: \"${sourceType}\"\\ncreated: \"${date.toISOString()}\"\\ntags:\\n${tagLines}\\n---\\n\\n# ${title}\\n\\n## Summary\\n\\n${(proc.summary || '').trim()}\\n${audioNote}\\n## Transcript\\n\\n${proc.transcript || 'No transcript available.'}\\n`;\n\nreturn [{ json: { ...input, notePath, markdown, title, tts_audio_url: proc.tts_audio_url || null } }];\n" + }, + "id": "vm-build-obsidian-v2", + "name": "Build Obsidian Note", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -240, + 0 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "={{'http://172.19.0.1:27123/vault/' + encodeURIComponent($json.notePath).replace(/%2F/g, '/')}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "text/markdown" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{$json.markdown}}", + "options": { + "timeout": 30000 + }, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" + }, + "id": "vm-write-obsidian-v2", + "name": "Write Note to Obsidian", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 0, + 0 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + } + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{ \"Voice memo captured (\" + $json.source_type + \"): \" + $json.title + \"\\nObsidian: \" + $json.notePath + ($json.tts_audio_url ? \"\\nAudio summary: \" + $json.tts_audio_url : \"\") }}", + "additionalFields": {} + }, + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1160, + -80 + ], + "id": "41bf5a55-2047-400a-87c7-44744a0f2a42", + "name": "Send Telegram Notification", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + } + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ ok: true, notePath: $json.notePath, title: $json.title, source_type: $json.source_type, tts_audio_url: $json.tts_audio_url || null }) }}" + }, + "id": "vm-respond-v2", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 460, + 0 + ] + } + ], + "connections": { + "Webhook - Voice Memo": { + "main": [ + [ + { + "node": "Normalize Input", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Input": { + "main": [ + [ + { + "node": "Process Voice Memo", + "type": "main", + "index": 0 + } + ] + ] + }, + "Process Voice Memo": { + "main": [ + [ + { + "node": "Build Obsidian Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Obsidian Note": { + "main": [ + [ + { + "node": "Write Note to Obsidian", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Note to Obsidian": { + "main": [ + [ + { + "node": "Send Telegram Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Telegram Notification": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "will will", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T00:03:13.146Z", + "id": 1475, + "workflowId": "El1BHJZ56JlzhrRZ", + "versionId": "4511e901-afab-493e-9b17-99a9d9865147", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:03:13.139Z", + "id": 1474, + "workflowId": "El1BHJZ56JlzhrRZ", + "versionId": "4511e901-afab-493e-9b17-99a9d9865147", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + ] + } +} diff --git a/swarm-common/n8n-workflows/G9ylNbHbnJ6fWX2C.json b/swarm-common/n8n-workflows/G9ylNbHbnJ6fWX2C.json index 2916582..98b775e 100644 --- a/swarm-common/n8n-workflows/G9ylNbHbnJ6fWX2C.json +++ b/swarm-common/n8n-workflows/G9ylNbHbnJ6fWX2C.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-05-12T17:06:07.191Z", + "updatedAt": "2026-05-14T00:18:01.110Z", "createdAt": "2026-05-12T16:59:40.394Z", "id": "G9ylNbHbnJ6fWX2C", "name": "n8n Failure Digest", @@ -99,7 +99,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "const items = $input.all();\nconst windowMinutes = 65;\nconst now = Date.now();\nconst selfName = 'n8n Failure Digest';\nfunction arr(v) { return Array.isArray(v) ? v : (v == null ? [] : [v]); }\nfunction msg(err) {\n if (!err) return 'Unknown error';\n return String(err.message || err.description || err.name || err.code || JSON.stringify(err)).trim() || 'Unknown error';\n}\nfunction errType(err) { return String(err?.name || err?.type || err?.code || err?.httpCode || 'Error'); }\nfunction sig(s) {\n return String(s).split('\\n')[0]\n .replace(/https?:\\/\\/\\S+/g, '')\n .replace(/[0-9a-f]{8,}/gi, '')\n .replace(/\\b\\d{4,}\\b/g, '')\n .slice(0, 180);\n}\nfunction findErr(ex) {\n const rd = ex.data?.resultData || {};\n if (rd.error) return { node: rd.error.node?.name || rd.error.node || rd.lastNodeExecuted || 'unknown', error: rd.error };\n const runData = rd.runData || {};\n for (const [nodeName, attempts] of Object.entries(runData)) {\n for (const attempt of arr(attempts).slice().reverse()) {\n if (attempt?.error) return { node: nodeName, error: attempt.error };\n }\n }\n return { node: rd.lastNodeExecuted || 'unknown', error: ex.error || {} };\n}\nconst failures = [];\nfor (const item of items) {\n const ex = item.json || {};\n const workflowName = ex.workflowData?.name || ex.workflow?.name || `Workflow ${ex.workflowId || 'unknown'}`;\n if (workflowName === selfName) continue;\n const found = findErr(ex);\n const message = msg(found.error);\n const when = ex.stoppedAt || ex.startedAt || ex.createdAt || new Date(now).toISOString();\n failures.push({\n id: ex.id,\n workflowId: ex.workflowId || ex.workflowData?.id || 'unknown',\n workflowName,\n node: found.node || 'unknown',\n errorType: errType(found.error),\n message,\n signature: sig(message),\n when,\n status: ex.status || 'unknown',\n });\n}\nconst groups = new Map();\nfor (const f of failures) {\n const key = `${f.workflowId}\\u0000${f.node}\\u0000${f.errorType}\\u0000${f.signature}`;\n if (!groups.has(key)) groups.set(key, { workflowName: f.workflowName, workflowId: f.workflowId, node: f.node, errorType: f.errorType, signature: f.signature, count: 0, ids: [], latest: f.when });\n const g = groups.get(key);\n g.count++;\n if (g.ids.length < 8) g.ids.push(f.id);\n if (String(f.when) > String(g.latest)) g.latest = f.when;\n}\nconst sorted = [...groups.values()].sort((a,b) => b.count - a.count || String(b.latest).localeCompare(String(a.latest))).slice(0, 12);\nif (!sorted.length) return [];\nconst lines = [];\nlines.push(`\ud83d\udea8 n8n Failure Digest: ${failures.length} failed execution(s) in the last ${windowMinutes} min`);\nlines.push('');\nsorted.forEach((g, i) => {\n lines.push(`${i+1}. ${g.workflowName}`);\n lines.push(` Node: ${g.node}`);\n lines.push(` ${g.count}x ${g.errorType}: ${g.signature}`);\n lines.push(` Execs: ${g.ids.join(', ')} | latest ${g.latest}`);\n});\nlines.push('');\nlines.push('Open n8n: http://127.0.0.1:18808');\nreturn [{ json: { text: lines.join('\\n'), totalFailures: failures.length, groups: sorted, generatedAt: new Date(now).toISOString() } }];" + "jsCode": "const items = $input.all();\nconst windowMinutes = 65;\nconst now = Date.now();\nconst selfName = 'n8n Failure Digest';\nfunction arr(v) { return Array.isArray(v) ? v : (v == null ? [] : [v]); }\nfunction msg(err) {\n if (!err) return 'Unknown error';\n return String(err.message || err.description || err.name || err.code || JSON.stringify(err)).trim() || 'Unknown error';\n}\nfunction errType(err) { return String(err?.name || err?.type || err?.code || err?.httpCode || 'Error'); }\nfunction sig(s) {\n return String(s).split('\\n')[0]\n .replace(/https?:\\/\\/\\S+/g, '')\n .replace(/[0-9a-f]{8,}/gi, '')\n .replace(/\\b\\d{4,}\\b/g, '')\n .slice(0, 180);\n}\nfunction findErr(ex) {\n const rd = ex.data?.resultData || {};\n if (rd.error) return { node: rd.error.node?.name || rd.error.node || rd.lastNodeExecuted || 'unknown', error: rd.error };\n const runData = rd.runData || {};\n for (const [nodeName, attempts] of Object.entries(runData)) {\n for (const attempt of arr(attempts).slice().reverse()) {\n if (attempt?.error) return { node: nodeName, error: attempt.error };\n }\n }\n return { node: rd.lastNodeExecuted || 'unknown', error: ex.error || {} };\n}\nconst failures = [];\nfor (const item of items) {\n const ex = item.json || {};\n const workflowName = ex.workflowData?.name || ex.workflow?.name || `Workflow ${ex.workflowId || 'unknown'}`;\n if (workflowName === selfName) continue;\n const found = findErr(ex);\n const message = msg(found.error);\n const when = ex.stoppedAt || ex.startedAt || ex.createdAt || new Date(now).toISOString();\n failures.push({\n id: ex.id,\n workflowId: ex.workflowId || ex.workflowData?.id || 'unknown',\n workflowName,\n node: found.node || 'unknown',\n errorType: errType(found.error),\n message,\n signature: sig(message),\n when,\n status: ex.status || 'unknown',\n });\n}\nconst groups = new Map();\nfor (const f of failures) {\n const key = `${f.workflowId}\\u0000${f.node}\\u0000${f.errorType}\\u0000${f.signature}`;\n if (!groups.has(key)) groups.set(key, { workflowName: f.workflowName, workflowId: f.workflowId, node: f.node, errorType: f.errorType, signature: f.signature, count: 0, ids: [], latest: f.when });\n const g = groups.get(key);\n g.count++;\n if (g.ids.length < 8) g.ids.push(f.id);\n if (String(f.when) > String(g.latest)) g.latest = f.when;\n}\nconst sorted = [...groups.values()].sort((a,b) => b.count - a.count || String(b.latest).localeCompare(String(a.latest))).slice(0, 12);\nif (!sorted.length) return [];\nfunction telegramSafe(s) { return String(s || '').replace(/[\\u0000-\\u001f\\u007f]/g, ' ').slice(0, 3500); }\nconst lines = [];\nlines.push(`🚨 n8n Failure Digest: ${failures.length} failed execution(s) in the last ${windowMinutes} min`);\nlines.push('');\nsorted.forEach((g, i) => {\n lines.push(`${i+1}. ${g.workflowName}`);\n lines.push(` Node: ${g.node}`);\n lines.push(` ${g.count}x ${g.errorType}: ${g.signature}`);\n lines.push(` Execs: ${g.ids.join(', ')} | latest ${g.latest}`);\n});\nlines.push('');\nlines.push('Open n8n: http://127.0.0.1:18808');\n// Telegram node defaults to legacy Markdown, so escape characters that\n// commonly occur in workflow/node/error names (notably underscores).\nfunction telegramMarkdownSafe(s) { return String(s).replace(/([_*`\\[])/g, '\\\\$1'); }\nconst text = telegramMarkdownSafe(lines.join('\\n'));\nreturn [{ json: { text, totalFailures: failures.length, groups: sorted, generatedAt: new Date(now).toISOString() } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -115,7 +115,8 @@ "chatId": "8367012007", "text": "={{ $json.text }}", "additionalFields": { - "parse_mode": "Markdown" + "parse_mode": "", + "disable_web_page_preview": true } }, "type": "n8n-nodes-base.telegram", @@ -253,10 +254,9 @@ } }, "meta": null, - "pinData": null, - "versionId": "c198c473-6fed-4fa8-b203-9465bb084e89", - "activeVersionId": "c198c473-6fed-4fa8-b203-9465bb084e89", - "versionCounter": 19, + "versionId": "2d85e3bf-d8cf-4274-bf61-5377241897da", + "activeVersionId": "2d85e3bf-d8cf-4274-bf61-5377241897da", + "versionCounter": 36, "triggerCount": 1, "shared": [ { @@ -279,9 +279,9 @@ ], "tags": [], "activeVersion": { - "updatedAt": "2026-05-13T21:29:18.280Z", - "createdAt": "2026-05-13T21:29:18.280Z", - "versionId": "c198c473-6fed-4fa8-b203-9465bb084e89", + "updatedAt": "2026-05-14T00:18:01.111Z", + "createdAt": "2026-05-14T00:18:01.111Z", + "versionId": "2d85e3bf-d8cf-4274-bf61-5377241897da", "workflowId": "G9ylNbHbnJ6fWX2C", "nodes": [ { @@ -376,7 +376,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "const items = $input.all();\nconst windowMinutes = 65;\nconst now = Date.now();\nconst selfName = 'n8n Failure Digest';\nfunction arr(v) { return Array.isArray(v) ? v : (v == null ? [] : [v]); }\nfunction msg(err) {\n if (!err) return 'Unknown error';\n return String(err.message || err.description || err.name || err.code || JSON.stringify(err)).trim() || 'Unknown error';\n}\nfunction errType(err) { return String(err?.name || err?.type || err?.code || err?.httpCode || 'Error'); }\nfunction sig(s) {\n return String(s).split('\\n')[0]\n .replace(/https?:\\/\\/\\S+/g, '')\n .replace(/[0-9a-f]{8,}/gi, '')\n .replace(/\\b\\d{4,}\\b/g, '')\n .slice(0, 180);\n}\nfunction findErr(ex) {\n const rd = ex.data?.resultData || {};\n if (rd.error) return { node: rd.error.node?.name || rd.error.node || rd.lastNodeExecuted || 'unknown', error: rd.error };\n const runData = rd.runData || {};\n for (const [nodeName, attempts] of Object.entries(runData)) {\n for (const attempt of arr(attempts).slice().reverse()) {\n if (attempt?.error) return { node: nodeName, error: attempt.error };\n }\n }\n return { node: rd.lastNodeExecuted || 'unknown', error: ex.error || {} };\n}\nconst failures = [];\nfor (const item of items) {\n const ex = item.json || {};\n const workflowName = ex.workflowData?.name || ex.workflow?.name || `Workflow ${ex.workflowId || 'unknown'}`;\n if (workflowName === selfName) continue;\n const found = findErr(ex);\n const message = msg(found.error);\n const when = ex.stoppedAt || ex.startedAt || ex.createdAt || new Date(now).toISOString();\n failures.push({\n id: ex.id,\n workflowId: ex.workflowId || ex.workflowData?.id || 'unknown',\n workflowName,\n node: found.node || 'unknown',\n errorType: errType(found.error),\n message,\n signature: sig(message),\n when,\n status: ex.status || 'unknown',\n });\n}\nconst groups = new Map();\nfor (const f of failures) {\n const key = `${f.workflowId}\\u0000${f.node}\\u0000${f.errorType}\\u0000${f.signature}`;\n if (!groups.has(key)) groups.set(key, { workflowName: f.workflowName, workflowId: f.workflowId, node: f.node, errorType: f.errorType, signature: f.signature, count: 0, ids: [], latest: f.when });\n const g = groups.get(key);\n g.count++;\n if (g.ids.length < 8) g.ids.push(f.id);\n if (String(f.when) > String(g.latest)) g.latest = f.when;\n}\nconst sorted = [...groups.values()].sort((a,b) => b.count - a.count || String(b.latest).localeCompare(String(a.latest))).slice(0, 12);\nif (!sorted.length) return [];\nconst lines = [];\nlines.push(`\ud83d\udea8 n8n Failure Digest: ${failures.length} failed execution(s) in the last ${windowMinutes} min`);\nlines.push('');\nsorted.forEach((g, i) => {\n lines.push(`${i+1}. ${g.workflowName}`);\n lines.push(` Node: ${g.node}`);\n lines.push(` ${g.count}x ${g.errorType}: ${g.signature}`);\n lines.push(` Execs: ${g.ids.join(', ')} | latest ${g.latest}`);\n});\nlines.push('');\nlines.push('Open n8n: http://127.0.0.1:18808');\nreturn [{ json: { text: lines.join('\\n'), totalFailures: failures.length, groups: sorted, generatedAt: new Date(now).toISOString() } }];" + "jsCode": "const items = $input.all();\nconst windowMinutes = 65;\nconst now = Date.now();\nconst selfName = 'n8n Failure Digest';\nfunction arr(v) { return Array.isArray(v) ? v : (v == null ? [] : [v]); }\nfunction msg(err) {\n if (!err) return 'Unknown error';\n return String(err.message || err.description || err.name || err.code || JSON.stringify(err)).trim() || 'Unknown error';\n}\nfunction errType(err) { return String(err?.name || err?.type || err?.code || err?.httpCode || 'Error'); }\nfunction sig(s) {\n return String(s).split('\\n')[0]\n .replace(/https?:\\/\\/\\S+/g, '')\n .replace(/[0-9a-f]{8,}/gi, '')\n .replace(/\\b\\d{4,}\\b/g, '')\n .slice(0, 180);\n}\nfunction findErr(ex) {\n const rd = ex.data?.resultData || {};\n if (rd.error) return { node: rd.error.node?.name || rd.error.node || rd.lastNodeExecuted || 'unknown', error: rd.error };\n const runData = rd.runData || {};\n for (const [nodeName, attempts] of Object.entries(runData)) {\n for (const attempt of arr(attempts).slice().reverse()) {\n if (attempt?.error) return { node: nodeName, error: attempt.error };\n }\n }\n return { node: rd.lastNodeExecuted || 'unknown', error: ex.error || {} };\n}\nconst failures = [];\nfor (const item of items) {\n const ex = item.json || {};\n const workflowName = ex.workflowData?.name || ex.workflow?.name || `Workflow ${ex.workflowId || 'unknown'}`;\n if (workflowName === selfName) continue;\n const found = findErr(ex);\n const message = msg(found.error);\n const when = ex.stoppedAt || ex.startedAt || ex.createdAt || new Date(now).toISOString();\n failures.push({\n id: ex.id,\n workflowId: ex.workflowId || ex.workflowData?.id || 'unknown',\n workflowName,\n node: found.node || 'unknown',\n errorType: errType(found.error),\n message,\n signature: sig(message),\n when,\n status: ex.status || 'unknown',\n });\n}\nconst groups = new Map();\nfor (const f of failures) {\n const key = `${f.workflowId}\\u0000${f.node}\\u0000${f.errorType}\\u0000${f.signature}`;\n if (!groups.has(key)) groups.set(key, { workflowName: f.workflowName, workflowId: f.workflowId, node: f.node, errorType: f.errorType, signature: f.signature, count: 0, ids: [], latest: f.when });\n const g = groups.get(key);\n g.count++;\n if (g.ids.length < 8) g.ids.push(f.id);\n if (String(f.when) > String(g.latest)) g.latest = f.when;\n}\nconst sorted = [...groups.values()].sort((a,b) => b.count - a.count || String(b.latest).localeCompare(String(a.latest))).slice(0, 12);\nif (!sorted.length) return [];\nfunction telegramSafe(s) { return String(s || '').replace(/[\\u0000-\\u001f\\u007f]/g, ' ').slice(0, 3500); }\nconst lines = [];\nlines.push(`🚨 n8n Failure Digest: ${failures.length} failed execution(s) in the last ${windowMinutes} min`);\nlines.push('');\nsorted.forEach((g, i) => {\n lines.push(`${i+1}. ${g.workflowName}`);\n lines.push(` Node: ${g.node}`);\n lines.push(` ${g.count}x ${g.errorType}: ${g.signature}`);\n lines.push(` Execs: ${g.ids.join(', ')} | latest ${g.latest}`);\n});\nlines.push('');\nlines.push('Open n8n: http://127.0.0.1:18808');\n// Telegram node defaults to legacy Markdown, so escape characters that\n// commonly occur in workflow/node/error names (notably underscores).\nfunction telegramMarkdownSafe(s) { return String(s).replace(/([_*`\\[])/g, '\\\\$1'); }\nconst text = telegramMarkdownSafe(lines.join('\\n'));\nreturn [{ json: { text, totalFailures: failures.length, groups: sorted, generatedAt: new Date(now).toISOString() } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -392,7 +392,8 @@ "chatId": "8367012007", "text": "={{ $json.text }}", "additionalFields": { - "parse_mode": "Markdown" + "parse_mode": "", + "disable_web_page_preview": true } }, "type": "n8n-nodes-base.telegram", @@ -516,19 +517,19 @@ ] } }, - "authors": "import", + "authors": "will will", "name": null, "description": null, "autosaved": false, "workflowPublishHistory": [ { - "createdAt": "2026-05-13T21:30:20.994Z", - "id": 1429, + "createdAt": "2026-05-14T00:18:01.158Z", + "id": 1491, "workflowId": "G9ylNbHbnJ6fWX2C", - "versionId": "c198c473-6fed-4fa8-b203-9465bb084e89", + "versionId": "2d85e3bf-d8cf-4274-bf61-5377241897da", "event": "activated", "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" } ] } -} \ No newline at end of file +} diff --git a/swarm-common/n8n-workflows/GSmzuA5dgGgyRg5v.json b/swarm-common/n8n-workflows/GSmzuA5dgGgyRg5v.json new file mode 100644 index 0000000..0d141c0 --- /dev/null +++ b/swarm-common/n8n-workflows/GSmzuA5dgGgyRg5v.json @@ -0,0 +1,485 @@ +{ + "updatedAt": "2026-05-14T00:01:22.299Z", + "createdAt": "2026-05-12T17:48:01.214Z", + "id": "GSmzuA5dgGgyRg5v", + "name": "Web-to-Notes Capture (Local LLM + Obsidian)", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "web-to-notes", + "responseMode": "responseNode", + "options": {} + }, + "id": "02979a5e-67e7-43ae-8c9f-4694a5b36e56", + "name": "Webhook - Capture URL", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -900, + 0 + ], + "webhookId": "7958ecbc-c714-41d5-a829-882447ab95f8" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\nconst url = String(body.url || body.link || '').trim();\nif (!url || !/^https?:\\/\\//i.test(url)) throw new Error('POST JSON must include url starting with http:// or https://');\nconst title = String(body.title || '').trim();\nconst notes = String(body.notes || body.note || body.comment || '').trim();\nconst tags = Array.isArray(body.tags) ? body.tags : String(body.tags || 'web-capture').split(',').map(s => s.trim()).filter(Boolean);\nreturn [{ json: { url, title, notes, tags, capturedAt: new Date().toISOString() } }];" + }, + "id": "22ba0ac9-af51-4469-a8bd-b3d3c1dd049b", + "name": "Normalize Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -680, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18806/v1/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: \"gemma-4-26b\", messages: [{ role: \"system\", content: \"You are a concise summarizer. Extract key points, claims, and notable details. Format as clear markdown with a summary section and key points list.\" }, { role: \"user\", content: `Summarize this ${$json.content_type || \"web\"} content titled \"${$json.title || \"untitled\"}\":\\n\\n${($json.text || \"\").slice(0, 8000)}` }], temperature: 0.3, max_tokens: 1600 }) }}", + "options": { + "timeout": 120000 + } + }, + "id": "2ea254be-4a88-426a-97ff-16a80196b462", + "name": "Summarize with llama.cpp", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 0, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const extracted = $('Extract Content').first().json;\nconst input = $('Normalize Input').first().json;\n\nlet summary = '';\ntry { summary = $json.choices?.[0]?.message?.content || $json.body?.choices?.[0]?.message?.content || ''; } catch (e) {}\n// Dedent summary (LLM sometimes returns indented markdown)\nsummary = summary.split('\\n').map(l => l.replace(/^\\s{4}/, '')).join('\\n').trim();\nif (!summary) summary = 'LLM summary unavailable.\\n\\nContent excerpt:\\n\\n> ' + (extracted.text || '').slice(0, 1200);\n\nconst contentType = extracted.content_type || 'web';\nconst title = extracted.title || input.title || 'Untitled';\nconst sourceUrl = extracted.metadata?.source_url || input.url;\nconst notes = input.notes || '';\nconst tags = input.tags || ['web-capture'];\n\nif (contentType === 'youtube') tags.push('youtube', 'video-transcript');\nelse if (contentType === 'pdf') tags.push('pdf', 'document');\n\nconst meta = extracted.metadata || {};\nlet metaSection = '';\nif (contentType === 'youtube') {\n metaSection = `**Video ID:** ${meta.video_id || 'N/A'} \\n**Transcript Entries:** ${meta.transcript_entries || 0}`;\n} else if (contentType === 'pdf') {\n metaSection = `**Author:** ${meta.author || 'N/A'} \\n**Pages:** ${meta.page_count || 'N/A'}`;\n}\n\nfunction slugify(s) { return String(s || 'untitled').toLowerCase().replace(/https?:\\/\\//,'').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,80) || 'untitled'; }\nfunction yamlSafe(s) { return String(s || '').replace(/'/g, \"''\").replace(/\\n/g, ' '); }\n\nconst date = new Date().toISOString().split('T')[0];\nconst notePath = `Clippings/${date}-${slugify(title)}.md`;\n\nconst frontmatter = [\n '---',\n `title: '${yamlSafe(title)}'`,\n `source_url: ${sourceUrl}`,\n `content_type: ${contentType}`,\n `date: ${date}`,\n `tags: [${tags.map(t => \"'\" + t + \"'\").join(', ')}]`,\n '---',\n].join('\\n');\n\nconst body = [\n frontmatter,\n '',\n `# ${title}`,\n '',\n `> Source: [${title}](${sourceUrl})`,\n ...(metaSection ? ['', metaSection] : []),\n ...(notes ? ['', `## Notes\\n${notes}`] : []),\n '',\n '## Summary',\n '',\n summary,\n '',\n '---',\n `*Captured via Web-to-Notes (${contentType})*`,\n].join('\\n');\n\nreturn [{ json: { notePath, body, title, contentType, sourceUrl } }];\n" + }, + "id": "403dff8b-5789-4018-89ec-69d45569cd25", + "name": "Build Markdown Note", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "={{'http://172.19.0.1:27123/vault/' + encodeURIComponent($json.notePath).replace(/%2F/g, '/')}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "text/markdown" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{$json.body}}", + "options": { + "timeout": 30000 + }, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" + }, + "id": "1d00b920-985e-415c-b445-4a28674287a0", + "name": "Write Note to Obsidian", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 460, + 0 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + } + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{JSON.stringify({ok: true, notePath: $json.notePath, title: $json.title, source: $json.url})}}", + "options": {} + }, + "id": "c3d45b9e-a4d3-43ee-855a-7a76030e8888", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 700, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18812/extract", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ url: $json.url }) }}", + "options": { + "timeout": 120000, + "fullResponse": false + } + }, + "id": "extract-content-v2", + "name": "Extract Content", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -240, + 0 + ] + } + ], + "connections": { + "Webhook - Capture URL": { + "main": [ + [ + { + "node": "Normalize Input", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Input": { + "main": [ + [ + { + "node": "Extract Content", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Content": { + "main": [ + [ + { + "node": "Summarize with llama.cpp", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize with llama.cpp": { + "main": [ + [ + { + "node": "Build Markdown Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Markdown Note": { + "main": [ + [ + { + "node": "Write Note to Obsidian", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Note to Obsidian": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "staticData": null, + "meta": null, + "pinData": null, + "versionId": "f503ca32-52bf-42ef-9dd4-ceecf538ed08", + "activeVersionId": "f503ca32-52bf-42ef-9dd4-ceecf538ed08", + "versionCounter": 30, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-12T17:48:01.217Z", + "createdAt": "2026-05-12T17:48:01.217Z", + "role": "workflow:owner", + "workflowId": "GSmzuA5dgGgyRg5v", + "projectId": "WGdp8QunI1tHpjXa", + "project": { + "updatedAt": "2026-03-11T21:08:10.005Z", + "createdAt": "2026-03-11T21:05:11.541Z", + "id": "WGdp8QunI1tHpjXa", + "name": "will will ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-14T00:01:22.300Z", + "createdAt": "2026-05-14T00:01:22.300Z", + "versionId": "f503ca32-52bf-42ef-9dd4-ceecf538ed08", + "workflowId": "GSmzuA5dgGgyRg5v", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "web-to-notes", + "responseMode": "responseNode", + "options": {} + }, + "id": "02979a5e-67e7-43ae-8c9f-4694a5b36e56", + "name": "Webhook - Capture URL", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + -900, + 0 + ], + "webhookId": "7958ecbc-c714-41d5-a829-882447ab95f8" + }, + { + "parameters": { + "jsCode": "const body = $json.body ?? $json;\nconst url = String(body.url || body.link || '').trim();\nif (!url || !/^https?:\\/\\//i.test(url)) throw new Error('POST JSON must include url starting with http:// or https://');\nconst title = String(body.title || '').trim();\nconst notes = String(body.notes || body.note || body.comment || '').trim();\nconst tags = Array.isArray(body.tags) ? body.tags : String(body.tags || 'web-capture').split(',').map(s => s.trim()).filter(Boolean);\nreturn [{ json: { url, title, notes, tags, capturedAt: new Date().toISOString() } }];" + }, + "id": "22ba0ac9-af51-4469-a8bd-b3d3c1dd049b", + "name": "Normalize Input", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -680, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18806/v1/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: \"gemma-4-26b\", messages: [{ role: \"system\", content: \"You are a concise summarizer. Extract key points, claims, and notable details. Format as clear markdown with a summary section and key points list.\" }, { role: \"user\", content: `Summarize this ${$json.content_type || \"web\"} content titled \"${$json.title || \"untitled\"}\":\\n\\n${($json.text || \"\").slice(0, 8000)}` }], temperature: 0.3, max_tokens: 1600 }) }}", + "options": { + "timeout": 120000 + } + }, + "id": "2ea254be-4a88-426a-97ff-16a80196b462", + "name": "Summarize with llama.cpp", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 0, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const extracted = $('Extract Content').first().json;\nconst input = $('Normalize Input').first().json;\n\nlet summary = '';\ntry { summary = $json.choices?.[0]?.message?.content || $json.body?.choices?.[0]?.message?.content || ''; } catch (e) {}\n// Dedent summary (LLM sometimes returns indented markdown)\nsummary = summary.split('\\n').map(l => l.replace(/^\\s{4}/, '')).join('\\n').trim();\nif (!summary) summary = 'LLM summary unavailable.\\n\\nContent excerpt:\\n\\n> ' + (extracted.text || '').slice(0, 1200);\n\nconst contentType = extracted.content_type || 'web';\nconst title = extracted.title || input.title || 'Untitled';\nconst sourceUrl = extracted.metadata?.source_url || input.url;\nconst notes = input.notes || '';\nconst tags = input.tags || ['web-capture'];\n\nif (contentType === 'youtube') tags.push('youtube', 'video-transcript');\nelse if (contentType === 'pdf') tags.push('pdf', 'document');\n\nconst meta = extracted.metadata || {};\nlet metaSection = '';\nif (contentType === 'youtube') {\n metaSection = `**Video ID:** ${meta.video_id || 'N/A'} \\n**Transcript Entries:** ${meta.transcript_entries || 0}`;\n} else if (contentType === 'pdf') {\n metaSection = `**Author:** ${meta.author || 'N/A'} \\n**Pages:** ${meta.page_count || 'N/A'}`;\n}\n\nfunction slugify(s) { return String(s || 'untitled').toLowerCase().replace(/https?:\\/\\//,'').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,80) || 'untitled'; }\nfunction yamlSafe(s) { return String(s || '').replace(/'/g, \"''\").replace(/\\n/g, ' '); }\n\nconst date = new Date().toISOString().split('T')[0];\nconst notePath = `Clippings/${date}-${slugify(title)}.md`;\n\nconst frontmatter = [\n '---',\n `title: '${yamlSafe(title)}'`,\n `source_url: ${sourceUrl}`,\n `content_type: ${contentType}`,\n `date: ${date}`,\n `tags: [${tags.map(t => \"'\" + t + \"'\").join(', ')}]`,\n '---',\n].join('\\n');\n\nconst body = [\n frontmatter,\n '',\n `# ${title}`,\n '',\n `> Source: [${title}](${sourceUrl})`,\n ...(metaSection ? ['', metaSection] : []),\n ...(notes ? ['', `## Notes\\n${notes}`] : []),\n '',\n '## Summary',\n '',\n summary,\n '',\n '---',\n `*Captured via Web-to-Notes (${contentType})*`,\n].join('\\n');\n\nreturn [{ json: { notePath, body, title, contentType, sourceUrl } }];\n" + }, + "id": "403dff8b-5789-4018-89ec-69d45569cd25", + "name": "Build Markdown Note", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "={{'http://172.19.0.1:27123/vault/' + encodeURIComponent($json.notePath).replace(/%2F/g, '/')}}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "text/markdown" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{$json.body}}", + "options": { + "timeout": 30000 + }, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" + }, + "id": "1d00b920-985e-415c-b445-4a28674287a0", + "name": "Write Note to Obsidian", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 460, + 0 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + } + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{JSON.stringify({ok: true, notePath: $json.notePath, title: $json.title, source: $json.url})}}", + "options": {} + }, + "id": "c3d45b9e-a4d3-43ee-855a-7a76030e8888", + "name": "Respond", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.5, + "position": [ + 700, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18812/extract", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ url: $json.url }) }}", + "options": { + "timeout": 120000, + "fullResponse": false + } + }, + "id": "extract-content-v2", + "name": "Extract Content", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -240, + 0 + ] + } + ], + "connections": { + "Webhook - Capture URL": { + "main": [ + [ + { + "node": "Normalize Input", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Input": { + "main": [ + [ + { + "node": "Extract Content", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Content": { + "main": [ + [ + { + "node": "Summarize with llama.cpp", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize with llama.cpp": { + "main": [ + [ + { + "node": "Build Markdown Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Markdown Note": { + "main": [ + [ + { + "node": "Write Note to Obsidian", + "type": "main", + "index": 0 + } + ] + ] + }, + "Write Note to Obsidian": { + "main": [ + [ + { + "node": "Respond", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "will will", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T00:01:22.328Z", + "id": 1462, + "workflowId": "GSmzuA5dgGgyRg5v", + "versionId": "f503ca32-52bf-42ef-9dd4-ceecf538ed08", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:01:22.316Z", + "id": 1461, + "workflowId": "GSmzuA5dgGgyRg5v", + "versionId": "f503ca32-52bf-42ef-9dd4-ceecf538ed08", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + ] + } +} diff --git a/swarm-common/n8n-workflows/PlZywwqL8MRNEAN6.json b/swarm-common/n8n-workflows/PlZywwqL8MRNEAN6.json index 310e2e8..b14cdba 100644 --- a/swarm-common/n8n-workflows/PlZywwqL8MRNEAN6.json +++ b/swarm-common/n8n-workflows/PlZywwqL8MRNEAN6.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-05-13T21:40:33.847Z", + "updatedAt": "2026-05-14T00:04:59.343Z", "createdAt": "2026-05-13T21:40:33.847Z", "id": "PlZywwqL8MRNEAN6", "name": "Evening Digest", @@ -161,7 +161,7 @@ "url": "http://172.19.0.1:18806/v1/chat/completions", "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ model: 'gemma-4-26B-A4B-it-UD-IQ2_M.gguf', temperature: 0.3, max_tokens: 800, messages: [{ role: 'system', content: 'You are an evening digest assistant. Given data about today\\'s automation runs, failures, new notes, and infrastructure health, produce a concise evening digest under 400 words. Use emojis for section headers. Format for Telegram/Markdown. Sections: \ud83d\udd27 Executions Summary, \u26a0\ufe0f Failures, \ud83d\udcdd New Notes, \ud83c\udfe5 Infrastructure Health, \ud83d\udccb Action Items. Be factual and concise.' }, { role: 'user', content: 'Here is today\\'s data:\\n' + $json.summary }] }) }}", + "jsonBody": "={{ JSON.stringify({ model: 'gemma-4-26B-A4B-it-UD-IQ2_M.gguf', temperature: 0.3, max_tokens: 800, messages: [{ role: 'system', content: 'You are an evening digest assistant. Given data about today\\'s automation runs, failures, new notes, and infrastructure health, produce a concise evening digest under 400 words. Use emojis for section headers. Format for Telegram/Markdown. Sections: 🔧 Executions Summary, ⚠️ Failures, 📝 New Notes, 🏥 Infrastructure Health, 📋 Action Items. Be factual and concise.' }, { role: 'user', content: 'Here is today\\'s data:\\n' + $json.summary }] }) }}", "options": { "response": { "response": { @@ -183,7 +183,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "// Extract LLM response text and prepare messages for Telegram/Discord/Obsidian\nlet text = '';\ntry {\n const llmResponse = $input.first()?.json;\n text = llmResponse?.choices?.[0]?.message?.content || '';\n // Strip code fences if present\n text = text.replace(/^```(?:markdown)?\\s*/i, '').replace(/```\\s*$/i, '').trim();\n} catch(e) {\n text = 'Evening digest generation encountered an error.';\n}\n\nif (!text) {\n text = '\ud83c\udf19 Evening Digest\\n\\nNo data collected today. All collection nodes may have failed.';\n}\n\n// Escape special chars for Telegram MarkdownV1\nlet telegramText = text;\n// Replace problematic markdown chars for Telegram\ntelegramText = telegramText.replace(/([_*\\[\\]()~`>#+\\-=|{}.!])/g, (m) => {\n // Keep basic markdown formatting\n if (['*', '_', '`'].includes(m)) return m;\n return '\\\\' + m;\n});\n\nconst today = new Intl.DateTimeFormat('en-CA', {\n timeZone: 'America/Los_Angeles',\n year: 'numeric', month: '2-digit', day: '2-digit'\n}).format(new Date()).replaceAll('/', '-');\n\nreturn [{\n json: {\n text: telegramText,\n discordText: text.substring(0, 2000),\n obsidianContent: `---\\ntitle: Evening Digest\\narea: infrastructure\\ntags: [infrastructure, digest, automation, daily, evening]\\ncreated: ${today}\\nupdated: ${today}\\nstatus: active\\n---\\n\\n# Evening Digest - ${today}\\n\\n${text}\\n`,\n notePath: `Notes/${today} Evening Digest.md`,\n date: today\n }\n}];" + "jsCode": "// Extract LLM response text and prepare messages for Telegram/Discord/Obsidian\nlet text = '';\ntry {\n const llmResponse = $input.first()?.json;\n text = llmResponse?.choices?.[0]?.message?.content || '';\n // Strip code fences if present\n text = text.replace(/^```(?:markdown)?\\s*/i, '').replace(/```\\s*$/i, '').trim();\n} catch(e) {\n text = 'Evening digest generation encountered an error.';\n}\n\nif (!text) {\n text = '🌙 Evening Digest\\n\\nNo data collected today. All collection nodes may have failed.';\n}\n\n// Escape special chars for Telegram MarkdownV1\nlet telegramText = text;\n// Replace problematic markdown chars for Telegram\ntelegramText = telegramText.replace(/([_*\\[\\]()~`>#+\\-=|{}.!])/g, (m) => {\n // Keep basic markdown formatting\n if (['*', '_', '`'].includes(m)) return m;\n return '\\\\' + m;\n});\n\nconst today = new Intl.DateTimeFormat('en-CA', {\n timeZone: 'America/Los_Angeles',\n year: 'numeric', month: '2-digit', day: '2-digit'\n}).format(new Date()).replaceAll('/', '-');\n\nreturn [{\n json: {\n text: telegramText,\n discordText: text.substring(0, 2000),\n obsidianContent: `---\\ntitle: Evening Digest\\narea: infrastructure\\ntags: [infrastructure, digest, automation, daily, evening]\\ncreated: ${today}\\nupdated: ${today}\\nstatus: active\\n---\\n\\n# Evening Digest - ${today}\\n\\n${text}\\n`,\n notePath: `Notes/${today} Evening Digest.md`,\n date: today\n }\n}];" }, "id": "a1b2c3d4-0001-4000-8000-000000000008", "name": "Prepare Messages", @@ -396,7 +396,8 @@ "settings": { "executionOrder": "v1", "callerPolicy": "workflowsFromSameOwner", - "availableInMCP": false + "availableInMCP": false, + "timezone": "America/Los_Angeles" }, "staticData": { "node:Daily 9PM Schedule": { @@ -407,7 +408,7 @@ "pinData": null, "versionId": "afb71f4d-6ac3-434d-b659-de003d47c339", "activeVersionId": "afb71f4d-6ac3-434d-b659-de003d47c339", - "versionCounter": 4, + "versionCounter": 11, "triggerCount": 1, "shared": [ { @@ -589,7 +590,7 @@ "url": "http://172.19.0.1:18806/v1/chat/completions", "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ model: 'gemma-4-26B-A4B-it-UD-IQ2_M.gguf', temperature: 0.3, max_tokens: 800, messages: [{ role: 'system', content: 'You are an evening digest assistant. Given data about today\\'s automation runs, failures, new notes, and infrastructure health, produce a concise evening digest under 400 words. Use emojis for section headers. Format for Telegram/Markdown. Sections: \ud83d\udd27 Executions Summary, \u26a0\ufe0f Failures, \ud83d\udcdd New Notes, \ud83c\udfe5 Infrastructure Health, \ud83d\udccb Action Items. Be factual and concise.' }, { role: 'user', content: 'Here is today\\'s data:\\n' + $json.summary }] }) }}", + "jsonBody": "={{ JSON.stringify({ model: 'gemma-4-26B-A4B-it-UD-IQ2_M.gguf', temperature: 0.3, max_tokens: 800, messages: [{ role: 'system', content: 'You are an evening digest assistant. Given data about today\\'s automation runs, failures, new notes, and infrastructure health, produce a concise evening digest under 400 words. Use emojis for section headers. Format for Telegram/Markdown. Sections: 🔧 Executions Summary, ⚠️ Failures, 📝 New Notes, 🏥 Infrastructure Health, 📋 Action Items. Be factual and concise.' }, { role: 'user', content: 'Here is today\\'s data:\\n' + $json.summary }] }) }}", "options": { "response": { "response": { @@ -611,7 +612,7 @@ { "parameters": { "mode": "runOnceForAllItems", - "jsCode": "// Extract LLM response text and prepare messages for Telegram/Discord/Obsidian\nlet text = '';\ntry {\n const llmResponse = $input.first()?.json;\n text = llmResponse?.choices?.[0]?.message?.content || '';\n // Strip code fences if present\n text = text.replace(/^```(?:markdown)?\\s*/i, '').replace(/```\\s*$/i, '').trim();\n} catch(e) {\n text = 'Evening digest generation encountered an error.';\n}\n\nif (!text) {\n text = '\ud83c\udf19 Evening Digest\\n\\nNo data collected today. All collection nodes may have failed.';\n}\n\n// Escape special chars for Telegram MarkdownV1\nlet telegramText = text;\n// Replace problematic markdown chars for Telegram\ntelegramText = telegramText.replace(/([_*\\[\\]()~`>#+\\-=|{}.!])/g, (m) => {\n // Keep basic markdown formatting\n if (['*', '_', '`'].includes(m)) return m;\n return '\\\\' + m;\n});\n\nconst today = new Intl.DateTimeFormat('en-CA', {\n timeZone: 'America/Los_Angeles',\n year: 'numeric', month: '2-digit', day: '2-digit'\n}).format(new Date()).replaceAll('/', '-');\n\nreturn [{\n json: {\n text: telegramText,\n discordText: text.substring(0, 2000),\n obsidianContent: `---\\ntitle: Evening Digest\\narea: infrastructure\\ntags: [infrastructure, digest, automation, daily, evening]\\ncreated: ${today}\\nupdated: ${today}\\nstatus: active\\n---\\n\\n# Evening Digest - ${today}\\n\\n${text}\\n`,\n notePath: `Notes/${today} Evening Digest.md`,\n date: today\n }\n}];" + "jsCode": "// Extract LLM response text and prepare messages for Telegram/Discord/Obsidian\nlet text = '';\ntry {\n const llmResponse = $input.first()?.json;\n text = llmResponse?.choices?.[0]?.message?.content || '';\n // Strip code fences if present\n text = text.replace(/^```(?:markdown)?\\s*/i, '').replace(/```\\s*$/i, '').trim();\n} catch(e) {\n text = 'Evening digest generation encountered an error.';\n}\n\nif (!text) {\n text = '🌙 Evening Digest\\n\\nNo data collected today. All collection nodes may have failed.';\n}\n\n// Escape special chars for Telegram MarkdownV1\nlet telegramText = text;\n// Replace problematic markdown chars for Telegram\ntelegramText = telegramText.replace(/([_*\\[\\]()~`>#+\\-=|{}.!])/g, (m) => {\n // Keep basic markdown formatting\n if (['*', '_', '`'].includes(m)) return m;\n return '\\\\' + m;\n});\n\nconst today = new Intl.DateTimeFormat('en-CA', {\n timeZone: 'America/Los_Angeles',\n year: 'numeric', month: '2-digit', day: '2-digit'\n}).format(new Date()).replaceAll('/', '-');\n\nreturn [{\n json: {\n text: telegramText,\n discordText: text.substring(0, 2000),\n obsidianContent: `---\\ntitle: Evening Digest\\narea: infrastructure\\ntags: [infrastructure, digest, automation, daily, evening]\\ncreated: ${today}\\nupdated: ${today}\\nstatus: active\\n---\\n\\n# Evening Digest - ${today}\\n\\n${text}\\n`,\n notePath: `Notes/${today} Evening Digest.md`,\n date: today\n }\n}];" }, "id": "a1b2c3d4-0001-4000-8000-000000000008", "name": "Prepare Messages", @@ -833,7 +834,39 @@ "versionId": "afb71f4d-6ac3-434d-b659-de003d47c339", "event": "activated", "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:04:59.370Z", + "id": 1483, + "workflowId": "PlZywwqL8MRNEAN6", + "versionId": "afb71f4d-6ac3-434d-b659-de003d47c339", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:04:59.415Z", + "id": 1485, + "workflowId": "PlZywwqL8MRNEAN6", + "versionId": "afb71f4d-6ac3-434d-b659-de003d47c339", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:04:59.362Z", + "id": 1482, + "workflowId": "PlZywwqL8MRNEAN6", + "versionId": "afb71f4d-6ac3-434d-b659-de003d47c339", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:04:59.388Z", + "id": 1484, + "workflowId": "PlZywwqL8MRNEAN6", + "versionId": "afb71f4d-6ac3-434d-b659-de003d47c339", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" } ] } -} \ No newline at end of file +} diff --git a/swarm-common/n8n-workflows/QRCCdHNXZUHc2Oz4.json b/swarm-common/n8n-workflows/QRCCdHNXZUHc2Oz4.json new file mode 100644 index 0000000..e42da03 --- /dev/null +++ b/swarm-common/n8n-workflows/QRCCdHNXZUHc2Oz4.json @@ -0,0 +1,362 @@ +{ + "updatedAt": "2026-05-14T00:01:24.692Z", + "createdAt": "2026-03-18T20:17:45.262Z", + "id": "QRCCdHNXZUHc2Oz4", + "name": "Calendar to Obsidian Notes", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 6 + } + ] + } + }, + "id": "schedule-trigger", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 240, + 304 + ] + }, + { + "parameters": { + "operation": "getAll", + "calendar": { + "__rl": true, + "value": "william.valentin.info@gmail.com", + "mode": "list", + "cachedResultName": "Perso" + }, + "limit": 20, + "options": { + "timeMin": "={{ new Date().toISOString() }}", + "timeMax": "={{ new Date(Date.now() + 7*24*60*60*1000).toISOString() }}", + "singleEvents": true, + "orderBy": "startTime" + } + }, + "id": "get-events", + "name": "Get Upcoming Events", + "type": "n8n-nodes-base.googleCalendar", + "typeVersion": 1, + "position": [ + 464, + 304 + ], + "credentials": { + "googleCalendarOAuth2Api": { + "id": "458fY4bs1z49OTeZ", + "name": "Google Calendar account" + } + }, + "continueOnFail": true, + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const event = $input.item.json || {};\nconst now = new Date();\nconst today = now.toISOString().slice(0, 10);\nconst hasUsableEvent = event.start && (event.summary || event.id || event.htmlLink);\nif (event.error || event.message || !hasUsableEvent) {\n const detail = String(event.error?.message || event.message || event.error || 'Google Calendar returned no usable event; credentials may need reauthorization.').replace(/`/g, \"'\").slice(0, 1000);\n const content = `---\ntitle: \"Google Calendar sync needs attention\"\narea: notes\ntags: [calendar, automation, degraded]\ncreated: ${today}\nupdated: ${today}\nstatus: needs-reauth\n---\n\n# Google Calendar sync needs attention\n\nThe n8n Calendar to Obsidian workflow could not read Google Calendar events.\n\nLikely cause: expired Google OAuth credentials in n8n.\n\nAction: reauthorize the Google Calendar credential used by workflow QRCCdHNXZUHc2Oz4, then run the workflow manually.\n\nLast observed detail:\n\n> ${detail}\n`;\n return [{ json: { path: `Notes/Calendar Sync Status/${today} Google Calendar Needs Reauth.md`, content, title: 'Google Calendar sync needs attention', date: today, degraded: true } }];\n}\nconst event = $input.item.json;\nconst startRaw = event.start?.dateTime || event.start?.date || \"\";\nconst date = startRaw.split(\"T\")[0];\nconst title = (event.summary || \"Untitled Event\").replace(/[\\/\\\\?%*:|\"<>]/g, \"-\").substring(0, 80);\nconst location = event.location || \"\";\nconst description = event.description || \"\";\nconst attendees = (event.attendees || []).map(a => a.email).join(\", \");\nconst endRaw = event.end?.dateTime || event.end?.date || \"\";\nconst startTime = startRaw.includes(\"T\") ? startRaw.split(\"T\")[1].substring(0,5) : \"All day\";\nconst endTime = endRaw.includes(\"T\") ? endRaw.split(\"T\")[1].substring(0,5) : \"\";\nconst timeStr = endTime ? `${startTime} – ${endTime}` : startTime;\nconst frontmatter = `---\\ntitle: \"${title}\"\\narea: notes\\ntags: [calendar, event]\\ncreated: ${date}\\nupdated: ${date}\\nstatus: active\\nevent_date: ${date}\\nevent_time: \"${timeStr}\"\\n---`;\nconst content = `${frontmatter}\\n\\n# ${title}\\n\\n**Date:** ${date}\\n**Time:** ${timeStr}\\n${location ? `**Location:** ${location}\\n` : \"\"}${attendees ? `**Attendees:** ${attendees}\\n` : \"\"}\\n## Description\\n\\n${description || \"_No description_\"}\\n\\n## Notes\\n\\n_Add notes here_\\n`;\nreturn [{ json: { path: `Notes/${date} ${title}.md`, content, title, date, timeStr } }];" + }, + "id": "format-note", + "name": "Format Event Note", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 688, + 304 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "=http://192.168.153.130:27123/vault/{{ encodeURIComponent($json.path).replace(/%2F/g, \"/\") }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{ $json.content }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "id": "write-to-vault", + "name": "Write to Vault", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 912, + 304 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + } + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "Get Upcoming Events", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Upcoming Events": { + "main": [ + [ + { + "node": "Format Event Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Event Note": { + "main": [ + [ + { + "node": "Write to Vault", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "staticData": { + "node:Schedule Trigger": { + "recurrenceRules": [ + 6 + ] + } + }, + "meta": null, + "pinData": {}, + "versionId": "40b22838-7ce4-4632-b186-b78ccda438c4", + "activeVersionId": "40b22838-7ce4-4632-b186-b78ccda438c4", + "versionCounter": 1636, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-03-18T20:17:45.264Z", + "createdAt": "2026-03-18T20:17:45.264Z", + "role": "workflow:owner", + "workflowId": "QRCCdHNXZUHc2Oz4", + "projectId": "WGdp8QunI1tHpjXa", + "project": { + "updatedAt": "2026-03-11T21:08:10.005Z", + "createdAt": "2026-03-11T21:05:11.541Z", + "id": "WGdp8QunI1tHpjXa", + "name": "will will ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + } + ], + "tags": [ + { + "updatedAt": "2026-03-19T04:40:29.915Z", + "createdAt": "2026-03-19T04:40:29.915Z", + "id": "GLr9Awuvw8uO7ZRP", + "name": "calendar" + }, + { + "updatedAt": "2026-03-19T04:40:29.892Z", + "createdAt": "2026-03-19T04:40:29.892Z", + "id": "VfqIkUpiu2YMBSHw", + "name": "obsidian-sync" + } + ], + "activeVersion": { + "updatedAt": "2026-05-14T00:01:24.693Z", + "createdAt": "2026-05-14T00:01:24.693Z", + "versionId": "40b22838-7ce4-4632-b186-b78ccda438c4", + "workflowId": "QRCCdHNXZUHc2Oz4", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 6 + } + ] + } + }, + "id": "schedule-trigger", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 240, + 304 + ] + }, + { + "parameters": { + "operation": "getAll", + "calendar": { + "__rl": true, + "value": "william.valentin.info@gmail.com", + "mode": "list", + "cachedResultName": "Perso" + }, + "limit": 20, + "options": { + "timeMin": "={{ new Date().toISOString() }}", + "timeMax": "={{ new Date(Date.now() + 7*24*60*60*1000).toISOString() }}", + "singleEvents": true, + "orderBy": "startTime" + } + }, + "id": "get-events", + "name": "Get Upcoming Events", + "type": "n8n-nodes-base.googleCalendar", + "typeVersion": 1, + "position": [ + 464, + 304 + ], + "credentials": { + "googleCalendarOAuth2Api": { + "id": "458fY4bs1z49OTeZ", + "name": "Google Calendar account" + } + }, + "continueOnFail": true, + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const event = $input.item.json || {};\nconst now = new Date();\nconst today = now.toISOString().slice(0, 10);\nconst hasUsableEvent = event.start && (event.summary || event.id || event.htmlLink);\nif (event.error || event.message || !hasUsableEvent) {\n const detail = String(event.error?.message || event.message || event.error || 'Google Calendar returned no usable event; credentials may need reauthorization.').replace(/`/g, \"'\").slice(0, 1000);\n const content = `---\ntitle: \"Google Calendar sync needs attention\"\narea: notes\ntags: [calendar, automation, degraded]\ncreated: ${today}\nupdated: ${today}\nstatus: needs-reauth\n---\n\n# Google Calendar sync needs attention\n\nThe n8n Calendar to Obsidian workflow could not read Google Calendar events.\n\nLikely cause: expired Google OAuth credentials in n8n.\n\nAction: reauthorize the Google Calendar credential used by workflow QRCCdHNXZUHc2Oz4, then run the workflow manually.\n\nLast observed detail:\n\n> ${detail}\n`;\n return [{ json: { path: `Notes/Calendar Sync Status/${today} Google Calendar Needs Reauth.md`, content, title: 'Google Calendar sync needs attention', date: today, degraded: true } }];\n}\nconst event = $input.item.json;\nconst startRaw = event.start?.dateTime || event.start?.date || \"\";\nconst date = startRaw.split(\"T\")[0];\nconst title = (event.summary || \"Untitled Event\").replace(/[\\/\\\\?%*:|\"<>]/g, \"-\").substring(0, 80);\nconst location = event.location || \"\";\nconst description = event.description || \"\";\nconst attendees = (event.attendees || []).map(a => a.email).join(\", \");\nconst endRaw = event.end?.dateTime || event.end?.date || \"\";\nconst startTime = startRaw.includes(\"T\") ? startRaw.split(\"T\")[1].substring(0,5) : \"All day\";\nconst endTime = endRaw.includes(\"T\") ? endRaw.split(\"T\")[1].substring(0,5) : \"\";\nconst timeStr = endTime ? `${startTime} – ${endTime}` : startTime;\nconst frontmatter = `---\\ntitle: \"${title}\"\\narea: notes\\ntags: [calendar, event]\\ncreated: ${date}\\nupdated: ${date}\\nstatus: active\\nevent_date: ${date}\\nevent_time: \"${timeStr}\"\\n---`;\nconst content = `${frontmatter}\\n\\n# ${title}\\n\\n**Date:** ${date}\\n**Time:** ${timeStr}\\n${location ? `**Location:** ${location}\\n` : \"\"}${attendees ? `**Attendees:** ${attendees}\\n` : \"\"}\\n## Description\\n\\n${description || \"_No description_\"}\\n\\n## Notes\\n\\n_Add notes here_\\n`;\nreturn [{ json: { path: `Notes/${date} ${title}.md`, content, title, date, timeStr } }];" + }, + "id": "format-note", + "name": "Format Event Note", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 688, + 304 + ] + }, + { + "parameters": { + "method": "PUT", + "url": "=http://192.168.153.130:27123/vault/{{ encodeURIComponent($json.path).replace(/%2F/g, \"/\") }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{ $json.content }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "id": "write-to-vault", + "name": "Write to Vault", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 912, + 304 + ], + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + } + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "Get Upcoming Events", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Upcoming Events": { + "main": [ + [ + { + "node": "Format Event Note", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Event Note": { + "main": [ + [ + { + "node": "Write to Vault", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "will will", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T00:01:24.723Z", + "id": 1466, + "workflowId": "QRCCdHNXZUHc2Oz4", + "versionId": "40b22838-7ce4-4632-b186-b78ccda438c4", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:01:24.711Z", + "id": 1465, + "workflowId": "QRCCdHNXZUHc2Oz4", + "versionId": "40b22838-7ce4-4632-b186-b78ccda438c4", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + ] + } +} diff --git a/swarm-common/n8n-workflows/morning-brief.json b/swarm-common/n8n-workflows/morning-brief.json index 0f10485..4a9ec65 100644 --- a/swarm-common/n8n-workflows/morning-brief.json +++ b/swarm-common/n8n-workflows/morning-brief.json @@ -1 +1,893 @@ -[{"updatedAt":"2026-05-13T21:41:17.798Z","createdAt":"2026-05-13T21:41:17.798Z","id":"g3IdGZCK1EtTsv9T","name":"Morning Brief","description":null,"active":true,"isArchived":false,"nodes":[{"parameters":{"rule":{"interval":[{"field":"cronExpression","expression":"30 6 * * *"}]}},"type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.3,"position":[0,0],"id":"16110cb5-e50a-4d99-a613-448057221422","name":"Daily 06:30 PT"},{"parameters":{"method":"GET","url":"http://wttr.in/Seattle?format=j1","options":{"timeout":10000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,-400],"id":"a119dfe9-46db-43ca-98b2-f0690bc0f6f5","name":"Weather","continueOnFail":true},{"parameters":{"method":"GET","url":"http://172.19.0.1:18809/health","options":{"timeout":10000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,-250],"id":"05f60eba-ab11-4fe0-b761-d1ca9ae557d4","name":"Swarm Health","continueOnFail":true},{"parameters":{"method":"GET","url":"http://127.0.0.1:5678/healthz","options":{"timeout":10000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,-100],"id":"4b5c3f4c-7f11-4e0c-9c56-3b8596a1d25d","name":"n8n Health","continueOnFail":true},{"parameters":{"method":"GET","url":"http://172.19.0.1:18804/health/liveliness","options":{"timeout":10000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,50],"id":"a8e4e45c-60a1-4f90-8ecc-49782d7be900","name":"LiteLLM Health","continueOnFail":true},{"parameters":{"method":"GET","url":"http://127.0.0.1:5678/api/v1/executions","sendQuery":true,"queryParameters":{"parameters":[{"name":"workflowId","value":"9sFwRyUDz51csAp7"},{"name":"limit","value":"5"},{"name":"status","value":"success"}]},"sendHeaders":true,"headerParameters":{"parameters":[{"name":"X-N8N-API-KEY","value":"={{ $env.N8N_API_KEY }}"}]},"options":{"timeout":15000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,200],"id":"c688abdf-9b63-43b4-81da-7c81388b73f8","name":"Email Highlights","continueOnFail":true},{"parameters":{"method":"GET","url":"=https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin={{ $now.format('yyyy-MM-dd') }}T00:00:00-07:00&timeMax={{ $now.plus({days:1}).format('yyyy-MM-dd') }}T23:59:59-07:00&singleEvents=true&orderBy=startTime","authentication":"oAuth2","options":{"timeout":10000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[300,350],"id":"d3c5a4ce-9f81-4da8-8dc8-7256bd96285b","name":"Calendar","credentials":{"oAuth2Api":{"id":"458fY4bs1z49OTeZ","name":"Google OAuth"}},"continueOnFail":true},{"parameters":{"mode":"runOnceForAllItems","jsCode":"\nfunction getSafe(nodeName) {\n try {\n const items = $(nodeName).all();\n if (items && items.length > 0 && items[0].json) {\n return items[0].json;\n }\n } catch (e) {}\n return { error: 'Node failed or returned no data' };\n}\n\nconst weather = getSafe('Weather');\nconst swarmHealth = getSafe('Swarm Health');\nconst n8nHealth = getSafe('n8n Health');\nconst litellmHealth = getSafe('LiteLLM Health');\nconst emailData = getSafe('Email Highlights');\nconst calendar = getSafe('Calendar');\n\n// Extract weather summary\nlet weatherSummary = {};\nif (weather.current_condition && weather.current_condition[0]) {\n const c = weather.current_condition[0];\n weatherSummary = {\n temp_F: c.FeelsLikeF || c.temp_F,\n description: c.weatherDesc ? c.weatherDesc[0].value : 'unknown',\n humidity: c.humidity,\n wind_mph: c.windspeedMiles\n };\n} else {\n weatherSummary = { error: 'Weather data unavailable' };\n}\n\n// Count healthy/unhealthy containers\nlet infraSummary = { healthy: 0, unhealthy: 0, details: [] };\nif (Array.isArray(swarmHealth)) {\n for (const c of swarmHealth) {\n if (c.health === 'healthy' || c.status === 'running') {\n infraSummary.healthy++;\n } else {\n infraSummary.unhealthy++;\n }\n infraSummary.details.push({ name: c.name || c.Names, status: c.status, health: c.health });\n }\n} else if (swarmHealth.containers && Array.isArray(swarmHealth.containers)) {\n for (const c of swarmHealth.containers) {\n if (c.health === 'healthy' || c.status === 'running') {\n infraSummary.healthy++;\n } else {\n infraSummary.unhealthy++;\n }\n infraSummary.details.push({ name: c.name, status: c.status, health: c.health });\n }\n} else if (swarmHealth.error) {\n infraSummary = { error: 'Swarm health endpoint unavailable' };\n}\n\nconst n8nOk = (n8nHealth && !n8nHealth.error);\nconst litellmOk = (litellmHealth && !litellmHealth.error);\n\n// Extract email info from execution data\nlet emailHighlights = [];\nif (emailData && emailData.data && Array.isArray(emailData.data)) {\n for (const exec of emailData.data.slice(0, 5)) {\n emailHighlights.push({\n id: exec.id,\n finished: exec.stoppedAt || 'unknown'\n });\n }\n}\n\n// Calendar events\nlet calendarEvents = [];\nif (calendar && calendar.items && Array.isArray(calendar.items)) {\n for (const ev of calendar.items.slice(0, 10)) {\n calendarEvents.push({\n summary: ev.summary || '(no title)',\n start: (ev.start && (ev.start.dateTime || ev.start.date)) || 'unknown',\n end: (ev.end && (ev.end.dateTime || ev.end.date)) || 'unknown'\n });\n }\n}\n\nconst dataForLLM = {\n date: new Date().toISOString().split('T')[0],\n weather: weatherSummary,\n infrastructure: {\n swarm: infraSummary,\n n8n: n8nOk ? 'healthy' : 'unhealthy',\n litellm: litellmOk ? 'healthy' : 'unhealthy'\n },\n email: emailHighlights.length > 0 ? emailHighlights : [{ info: 'No recent email triage data' }],\n calendar: calendarEvents.length > 0 ? calendarEvents : [{ info: 'Calendar unavailable or no events today' }]\n};\n\nreturn [{ json: { dataJson: JSON.stringify(dataForLLM, null, 2) } }];\n"},"type":"n8n-nodes-base.code","typeVersion":2,"position":[650,0],"id":"1d2b39db-3649-4316-8ce9-b5c83c981017","name":"Merge Data"},{"parameters":{"method":"POST","url":"http://172.19.0.1:18806/v1/chat/completions","sendBody":true,"specifyBody":"json","jsonBody":"={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"messages\": [{\"role\": \"system\", \"content\": \"You are a personal morning brief assistant. Given raw data about weather, infrastructure, email, and calendar, produce a concise morning brief under 400 words. Use emojis for section headers. Format for Telegram using HTML (use for bold, for code). Keep it scannable and actionable. If any section data is missing or shows errors, note it briefly and move on.\"}, {\"role\": \"user\", \"content\": \"Here is today's data:\\n{{ $json.dataJson }}\"}], \"temperature\": 0.3, \"max_tokens\": 800}","options":{"timeout":60000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[950,0],"id":"f2eb23d3-bf07-46d8-8556-2ba6a0185f5a","name":"Synthesize with LLM","continueOnFail":false},{"parameters":{"mode":"runOnceForAllItems","jsCode":"\nconst response = $input.first().json;\nlet brief = '';\n\nif (response.choices && response.choices[0] && response.choices[0].message) {\n brief = response.choices[0].message.content;\n} else if (typeof response === 'string') {\n brief = response;\n} else {\n brief = 'Error: LLM synthesis failed. Raw data: ' + JSON.stringify(response).substring(0, 500);\n}\n\nconst today = new Date().toISOString().split('T')[0];\nconst yamlFrontmatter = '---\\ncreated: ' + today + '\\ntype: morning-brief\\ntags: [daily, brief]\\n---\\n\\n';\n\nreturn [{\n json: {\n brief: brief,\n briefWithFrontmatter: yamlFrontmatter + '# Morning Brief - ' + today + '\\n\\n' + brief,\n date: today\n }\n}];\n"},"type":"n8n-nodes-base.code","typeVersion":2,"position":[1250,0],"id":"0adac542-7d95-4002-a3e2-080442cfd9e3","name":"Extract Brief"},{"parameters":{"chatId":"8367012007","text":"={{ $json.brief }}","additionalFields":{"parse_mode":"HTML"}},"type":"n8n-nodes-base.telegram","typeVersion":1.2,"position":[1550,-150],"id":"8242ada9-20c8-4689-b00c-3cd2787b2eb5","name":"Send Telegram","credentials":{"telegramApi":{"id":"aox4dyIWVSRdcH5z","name":"Telegram Bot (OpenClaw)"}},"continueOnFail":true},{"parameters":{"method":"PUT","url":"=http://172.19.0.1:27123/vault/Notes/{{ $json.date }} Morning Brief.md","sendHeaders":true,"headerParameters":{"parameters":[{"name":"Content-Type","value":"text/markdown"}]},"sendBody":true,"contentType":"raw","rawContentType":"text/markdown","body":"={{ $json.briefWithFrontmatter }}","options":{"timeout":10000}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1550,150],"id":"0f1fd6a2-86c0-4d3f-a948-32ce701d9f9f","name":"Save to Obsidian","credentials":{"httpHeaderAuth":{"id":"465Swz2b71O2KRAK","name":"Obsidian Local REST API"}},"continueOnFail":true}],"connections":{"Daily 06:30 PT":{"main":[[{"node":"Weather","type":"main","index":0},{"node":"Swarm Health","type":"main","index":0},{"node":"n8n Health","type":"main","index":0},{"node":"LiteLLM Health","type":"main","index":0},{"node":"Email Highlights","type":"main","index":0},{"node":"Calendar","type":"main","index":0}]]},"Weather":{"main":[[{"node":"Merge Data","type":"main","index":0}]]},"Swarm Health":{"main":[[{"node":"Merge Data","type":"main","index":0}]]},"n8n Health":{"main":[[{"node":"Merge Data","type":"main","index":0}]]},"LiteLLM Health":{"main":[[{"node":"Merge Data","type":"main","index":0}]]},"Email Highlights":{"main":[[{"node":"Merge Data","type":"main","index":0}]]},"Calendar":{"main":[[{"node":"Merge Data","type":"main","index":0}]]},"Merge Data":{"main":[[{"node":"Synthesize with LLM","type":"main","index":0}]]},"Synthesize with LLM":{"main":[[{"node":"Extract Brief","type":"main","index":0}]]},"Extract Brief":{"main":[[{"node":"Send Telegram","type":"main","index":0},{"node":"Save to Obsidian","type":"main","index":0}]]}},"settings":{"executionOrder":"v1","timezone":"America/Los_Angeles","callerPolicy":"workflowsFromSameOwner","availableInMCP":false},"staticData":{"node:Daily 06:30 PT":{"recurrenceRules":[]}},"meta":null,"pinData":null,"versionId":"2e269319-d17c-4d15-8500-43d8e2bbf0b9","activeVersionId":"2e269319-d17c-4d15-8500-43d8e2bbf0b9","versionCounter":4,"triggerCount":1,"tags":[],"shared":[{"updatedAt":"2026-05-13T21:41:17.800Z","createdAt":"2026-05-13T21:41:17.800Z","role":"workflow:owner","workflowId":"g3IdGZCK1EtTsv9T","projectId":"WGdp8QunI1tHpjXa","project":{"updatedAt":"2026-03-11T21:08:10.005Z","createdAt":"2026-03-11T21:05:11.541Z","id":"WGdp8QunI1tHpjXa","name":"will will ","type":"personal","icon":null,"description":null,"creatorId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"}}],"versionMetadata":{"name":null,"description":null}}] \ No newline at end of file +{ + "updatedAt": "2026-05-14T00:21:08.433Z", + "createdAt": "2026-05-13T21:41:17.798Z", + "id": "g3IdGZCK1EtTsv9T", + "name": "Morning Brief", + "description": null, + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "30 6 * * *" + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + 0, + 0 + ], + "id": "16110cb5-e50a-4d99-a613-448057221422", + "name": "Daily 06:30 PT" + }, + { + "parameters": { + "method": "GET", + "url": "http://wttr.in/Seattle?format=j1", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + -400 + ], + "id": "a119dfe9-46db-43ca-98b2-f0690bc0f6f5", + "name": "Weather", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://172.19.0.1:18809/health", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + -250 + ], + "id": "05f60eba-ab11-4fe0-b761-d1ca9ae557d4", + "name": "Swarm Health", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://127.0.0.1:5678/healthz", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + -100 + ], + "id": "4b5c3f4c-7f11-4e0c-9c56-3b8596a1d25d", + "name": "n8n Health", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://172.19.0.1:18804/health/liveliness", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + 50 + ], + "id": "a8e4e45c-60a1-4f90-8ecc-49782d7be900", + "name": "LiteLLM Health", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://127.0.0.1:5678/api/v1/executions", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "workflowId", + "value": "9sFwRyUDz51csAp7" + }, + { + "name": "limit", + "value": "5" + }, + { + "name": "status", + "value": "success" + } + ] + }, + "options": { + "timeout": 15000 + }, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + 200 + ], + "id": "c688abdf-9b63-43b4-81da-7c81388b73f8", + "name": "Email Highlights", + "continueOnFail": true, + "credentials": { + "httpHeaderAuth": { + "id": "UPAHgUJVRqZQceL4", + "name": "n8n Public API (Failure Digest)" + } + } + }, + { + "parameters": { + "method": "GET", + "url": "=https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin={{ $now.format('yyyy-MM-dd') }}T00:00:00-07:00&timeMax={{ $now.plus({days:1}).format('yyyy-MM-dd') }}T23:59:59-07:00&singleEvents=true&orderBy=startTime", + "authentication": "oAuth2", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + 350 + ], + "id": "d3c5a4ce-9f81-4da8-8dc8-7256bd96285b", + "name": "Calendar", + "credentials": { + "oAuth2Api": { + "id": "458fY4bs1z49OTeZ", + "name": "Google OAuth" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nfunction getSafe(nodeName) {\n try {\n const items = $(nodeName).all();\n if (items && items.length > 0 && items[0].json) {\n return items[0].json;\n }\n } catch (e) {}\n return { error: 'Node failed or returned no data' };\n}\n\nconst weather = getSafe('Weather');\nconst swarmHealth = getSafe('Swarm Health');\nconst n8nHealth = getSafe('n8n Health');\nconst litellmHealth = getSafe('LiteLLM Health');\nconst emailData = getSafe('Email Highlights');\nconst calendar = getSafe('Calendar');\n\n// Extract weather summary\nlet weatherSummary = {};\nif (weather.current_condition && weather.current_condition[0]) {\n const c = weather.current_condition[0];\n weatherSummary = {\n temp_F: c.FeelsLikeF || c.temp_F,\n description: c.weatherDesc ? c.weatherDesc[0].value : 'unknown',\n humidity: c.humidity,\n wind_mph: c.windspeedMiles\n };\n} else {\n weatherSummary = { error: 'Weather data unavailable' };\n}\n\n// Count healthy/unhealthy containers\nlet infraSummary = { healthy: 0, unhealthy: 0, details: [] };\nif (Array.isArray(swarmHealth)) {\n for (const c of swarmHealth) {\n if (c.health === 'healthy' || c.status === 'running') {\n infraSummary.healthy++;\n } else {\n infraSummary.unhealthy++;\n }\n infraSummary.details.push({ name: c.name || c.Names, status: c.status, health: c.health });\n }\n} else if (swarmHealth.containers && Array.isArray(swarmHealth.containers)) {\n for (const c of swarmHealth.containers) {\n if (c.health === 'healthy' || c.status === 'running') {\n infraSummary.healthy++;\n } else {\n infraSummary.unhealthy++;\n }\n infraSummary.details.push({ name: c.name, status: c.status, health: c.health });\n }\n} else if (swarmHealth.error) {\n infraSummary = { error: 'Swarm health endpoint unavailable' };\n}\n\nconst n8nOk = (n8nHealth && !n8nHealth.error);\nconst litellmOk = (litellmHealth && !litellmHealth.error);\n\n// Extract email info from execution data\nlet emailHighlights = [];\nif (emailData && emailData.data && Array.isArray(emailData.data)) {\n for (const exec of emailData.data.slice(0, 5)) {\n emailHighlights.push({\n id: exec.id,\n finished: exec.stoppedAt || 'unknown'\n });\n }\n}\n\n// Calendar events\nlet calendarEvents = [];\nif (calendar && calendar.items && Array.isArray(calendar.items)) {\n for (const ev of calendar.items.slice(0, 10)) {\n calendarEvents.push({\n summary: ev.summary || '(no title)',\n start: (ev.start && (ev.start.dateTime || ev.start.date)) || 'unknown',\n end: (ev.end && (ev.end.dateTime || ev.end.date)) || 'unknown'\n });\n }\n}\n\nconst dataForLLM = {\n date: new Date().toISOString().split('T')[0],\n weather: weatherSummary,\n infrastructure: {\n swarm: infraSummary,\n n8n: n8nOk ? 'healthy' : 'unhealthy',\n litellm: litellmOk ? 'healthy' : 'unhealthy'\n },\n email: emailHighlights.length > 0 ? emailHighlights : [{ info: 'No recent email triage data' }],\n calendar: calendarEvents.length > 0 ? calendarEvents : [{ info: 'Calendar unavailable or no events today' }]\n};\n\nreturn [{ json: { dataJson: JSON.stringify(dataForLLM, null, 2) } }];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 650, + 0 + ], + "id": "1d2b39db-3649-4316-8ce9-b5c83c981017", + "name": "Merge Data" + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18806/v1/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"messages\": [{\"role\": \"system\", \"content\": \"You are a personal morning brief assistant. Given raw data about weather, infrastructure, email, and calendar, produce a concise morning brief under 400 words. Use emojis for section headers. Format for Telegram using HTML (use for bold, for code). Keep it scannable and actionable. If any section data is missing or shows errors, note it briefly and move on.\"}, {\"role\": \"user\", \"content\": \"Here is today's data:\\n{{ $json.dataJson }}\"}], \"temperature\": 0.3, \"max_tokens\": 800}", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 950, + 0 + ], + "id": "f2eb23d3-bf07-46d8-8556-2ba6a0185f5a", + "name": "Synthesize with LLM", + "continueOnFail": false + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst response = $input.first().json;\nlet brief = '';\n\nif (response.choices && response.choices[0] && response.choices[0].message) {\n brief = response.choices[0].message.content;\n} else if (typeof response === 'string') {\n brief = response;\n} else {\n brief = 'Error: LLM synthesis failed. Raw data: ' + JSON.stringify(response).substring(0, 500);\n}\n\nconst today = new Date().toISOString().split('T')[0];\nconst yamlFrontmatter = '---\\ncreated: ' + today + '\\ntype: morning-brief\\ntags: [daily, brief]\\n---\\n\\n';\n\nreturn [{\n json: {\n brief: brief,\n briefWithFrontmatter: yamlFrontmatter + '# Morning Brief - ' + today + '\\n\\n' + brief,\n date: today\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1250, + 0 + ], + "id": "0adac542-7d95-4002-a3e2-080442cfd9e3", + "name": "Extract Brief" + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{ $json.brief }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1550, + -150 + ], + "id": "8242ada9-20c8-4689-b00c-3cd2787b2eb5", + "name": "Send Telegram", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "method": "PUT", + "url": "=http://172.19.0.1:27123/vault/Notes/{{ $json.date }} Morning Brief.md", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "text/markdown" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{ $json.briefWithFrontmatter }}", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1550, + 150 + ], + "id": "0f1fd6a2-86c0-4d3f-a948-32ce701d9f9f", + "name": "Save to Obsidian", + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + }, + "continueOnFail": true + } + ], + "connections": { + "Daily 06:30 PT": { + "main": [ + [ + { + "node": "Weather", + "type": "main", + "index": 0 + }, + { + "node": "Swarm Health", + "type": "main", + "index": 0 + }, + { + "node": "n8n Health", + "type": "main", + "index": 0 + }, + { + "node": "LiteLLM Health", + "type": "main", + "index": 0 + }, + { + "node": "Email Highlights", + "type": "main", + "index": 0 + }, + { + "node": "Calendar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Weather": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Swarm Health": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "n8n Health": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "LiteLLM Health": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Email Highlights": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Calendar": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge Data": { + "main": [ + [ + { + "node": "Synthesize with LLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Synthesize with LLM": { + "main": [ + [ + { + "node": "Extract Brief", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Brief": { + "main": [ + [ + { + "node": "Send Telegram", + "type": "main", + "index": 0 + }, + { + "node": "Save to Obsidian", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "timezone": "America/Los_Angeles", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "staticData": { + "node:Daily 06:30 PT": { + "recurrenceRules": [] + } + }, + "meta": null, + "versionId": "c23946c7-29cf-42dc-985c-c1b818b8d010", + "activeVersionId": "c23946c7-29cf-42dc-985c-c1b818b8d010", + "versionCounter": 10, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-13T21:41:17.800Z", + "createdAt": "2026-05-13T21:41:17.800Z", + "role": "workflow:owner", + "workflowId": "g3IdGZCK1EtTsv9T", + "projectId": "WGdp8QunI1tHpjXa", + "project": { + "updatedAt": "2026-03-11T21:08:10.005Z", + "createdAt": "2026-03-11T21:05:11.541Z", + "id": "WGdp8QunI1tHpjXa", + "name": "will will ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-14T00:21:08.434Z", + "createdAt": "2026-05-14T00:21:08.434Z", + "versionId": "c23946c7-29cf-42dc-985c-c1b818b8d010", + "workflowId": "g3IdGZCK1EtTsv9T", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "30 6 * * *" + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + 0, + 0 + ], + "id": "16110cb5-e50a-4d99-a613-448057221422", + "name": "Daily 06:30 PT" + }, + { + "parameters": { + "method": "GET", + "url": "http://wttr.in/Seattle?format=j1", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + -400 + ], + "id": "a119dfe9-46db-43ca-98b2-f0690bc0f6f5", + "name": "Weather", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://172.19.0.1:18809/health", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + -250 + ], + "id": "05f60eba-ab11-4fe0-b761-d1ca9ae557d4", + "name": "Swarm Health", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://127.0.0.1:5678/healthz", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + -100 + ], + "id": "4b5c3f4c-7f11-4e0c-9c56-3b8596a1d25d", + "name": "n8n Health", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://172.19.0.1:18804/health/liveliness", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + 50 + ], + "id": "a8e4e45c-60a1-4f90-8ecc-49782d7be900", + "name": "LiteLLM Health", + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "http://127.0.0.1:5678/api/v1/executions", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "workflowId", + "value": "9sFwRyUDz51csAp7" + }, + { + "name": "limit", + "value": "5" + }, + { + "name": "status", + "value": "success" + } + ] + }, + "options": { + "timeout": 15000 + }, + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth" + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + 200 + ], + "id": "c688abdf-9b63-43b4-81da-7c81388b73f8", + "name": "Email Highlights", + "continueOnFail": true, + "credentials": { + "httpHeaderAuth": { + "id": "UPAHgUJVRqZQceL4", + "name": "n8n Public API (Failure Digest)" + } + } + }, + { + "parameters": { + "method": "GET", + "url": "=https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin={{ $now.format('yyyy-MM-dd') }}T00:00:00-07:00&timeMax={{ $now.plus({days:1}).format('yyyy-MM-dd') }}T23:59:59-07:00&singleEvents=true&orderBy=startTime", + "authentication": "oAuth2", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 300, + 350 + ], + "id": "d3c5a4ce-9f81-4da8-8dc8-7256bd96285b", + "name": "Calendar", + "credentials": { + "oAuth2Api": { + "id": "458fY4bs1z49OTeZ", + "name": "Google OAuth" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nfunction getSafe(nodeName) {\n try {\n const items = $(nodeName).all();\n if (items && items.length > 0 && items[0].json) {\n return items[0].json;\n }\n } catch (e) {}\n return { error: 'Node failed or returned no data' };\n}\n\nconst weather = getSafe('Weather');\nconst swarmHealth = getSafe('Swarm Health');\nconst n8nHealth = getSafe('n8n Health');\nconst litellmHealth = getSafe('LiteLLM Health');\nconst emailData = getSafe('Email Highlights');\nconst calendar = getSafe('Calendar');\n\n// Extract weather summary\nlet weatherSummary = {};\nif (weather.current_condition && weather.current_condition[0]) {\n const c = weather.current_condition[0];\n weatherSummary = {\n temp_F: c.FeelsLikeF || c.temp_F,\n description: c.weatherDesc ? c.weatherDesc[0].value : 'unknown',\n humidity: c.humidity,\n wind_mph: c.windspeedMiles\n };\n} else {\n weatherSummary = { error: 'Weather data unavailable' };\n}\n\n// Count healthy/unhealthy containers\nlet infraSummary = { healthy: 0, unhealthy: 0, details: [] };\nif (Array.isArray(swarmHealth)) {\n for (const c of swarmHealth) {\n if (c.health === 'healthy' || c.status === 'running') {\n infraSummary.healthy++;\n } else {\n infraSummary.unhealthy++;\n }\n infraSummary.details.push({ name: c.name || c.Names, status: c.status, health: c.health });\n }\n} else if (swarmHealth.containers && Array.isArray(swarmHealth.containers)) {\n for (const c of swarmHealth.containers) {\n if (c.health === 'healthy' || c.status === 'running') {\n infraSummary.healthy++;\n } else {\n infraSummary.unhealthy++;\n }\n infraSummary.details.push({ name: c.name, status: c.status, health: c.health });\n }\n} else if (swarmHealth.error) {\n infraSummary = { error: 'Swarm health endpoint unavailable' };\n}\n\nconst n8nOk = (n8nHealth && !n8nHealth.error);\nconst litellmOk = (litellmHealth && !litellmHealth.error);\n\n// Extract email info from execution data\nlet emailHighlights = [];\nif (emailData && emailData.data && Array.isArray(emailData.data)) {\n for (const exec of emailData.data.slice(0, 5)) {\n emailHighlights.push({\n id: exec.id,\n finished: exec.stoppedAt || 'unknown'\n });\n }\n}\n\n// Calendar events\nlet calendarEvents = [];\nif (calendar && calendar.items && Array.isArray(calendar.items)) {\n for (const ev of calendar.items.slice(0, 10)) {\n calendarEvents.push({\n summary: ev.summary || '(no title)',\n start: (ev.start && (ev.start.dateTime || ev.start.date)) || 'unknown',\n end: (ev.end && (ev.end.dateTime || ev.end.date)) || 'unknown'\n });\n }\n}\n\nconst dataForLLM = {\n date: new Date().toISOString().split('T')[0],\n weather: weatherSummary,\n infrastructure: {\n swarm: infraSummary,\n n8n: n8nOk ? 'healthy' : 'unhealthy',\n litellm: litellmOk ? 'healthy' : 'unhealthy'\n },\n email: emailHighlights.length > 0 ? emailHighlights : [{ info: 'No recent email triage data' }],\n calendar: calendarEvents.length > 0 ? calendarEvents : [{ info: 'Calendar unavailable or no events today' }]\n};\n\nreturn [{ json: { dataJson: JSON.stringify(dataForLLM, null, 2) } }];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 650, + 0 + ], + "id": "1d2b39db-3649-4316-8ce9-b5c83c981017", + "name": "Merge Data" + }, + { + "parameters": { + "method": "POST", + "url": "http://172.19.0.1:18806/v1/chat/completions", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\"model\": \"gemma-4-26B-A4B-it-UD-IQ2_M.gguf\", \"messages\": [{\"role\": \"system\", \"content\": \"You are a personal morning brief assistant. Given raw data about weather, infrastructure, email, and calendar, produce a concise morning brief under 400 words. Use emojis for section headers. Format for Telegram using HTML (use for bold, for code). Keep it scannable and actionable. If any section data is missing or shows errors, note it briefly and move on.\"}, {\"role\": \"user\", \"content\": \"Here is today's data:\\n{{ $json.dataJson }}\"}], \"temperature\": 0.3, \"max_tokens\": 800}", + "options": { + "timeout": 60000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 950, + 0 + ], + "id": "f2eb23d3-bf07-46d8-8556-2ba6a0185f5a", + "name": "Synthesize with LLM", + "continueOnFail": false + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "\nconst response = $input.first().json;\nlet brief = '';\n\nif (response.choices && response.choices[0] && response.choices[0].message) {\n brief = response.choices[0].message.content;\n} else if (typeof response === 'string') {\n brief = response;\n} else {\n brief = 'Error: LLM synthesis failed. Raw data: ' + JSON.stringify(response).substring(0, 500);\n}\n\nconst today = new Date().toISOString().split('T')[0];\nconst yamlFrontmatter = '---\\ncreated: ' + today + '\\ntype: morning-brief\\ntags: [daily, brief]\\n---\\n\\n';\n\nreturn [{\n json: {\n brief: brief,\n briefWithFrontmatter: yamlFrontmatter + '# Morning Brief - ' + today + '\\n\\n' + brief,\n date: today\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1250, + 0 + ], + "id": "0adac542-7d95-4002-a3e2-080442cfd9e3", + "name": "Extract Brief" + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{ $json.brief }}", + "additionalFields": { + "parse_mode": "HTML" + } + }, + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + 1550, + -150 + ], + "id": "8242ada9-20c8-4689-b00c-3cd2787b2eb5", + "name": "Send Telegram", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + }, + "continueOnFail": true + }, + { + "parameters": { + "method": "PUT", + "url": "=http://172.19.0.1:27123/vault/Notes/{{ $json.date }} Morning Brief.md", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "text/markdown" + } + ] + }, + "sendBody": true, + "contentType": "raw", + "rawContentType": "text/markdown", + "body": "={{ $json.briefWithFrontmatter }}", + "options": { + "timeout": 10000 + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1550, + 150 + ], + "id": "0f1fd6a2-86c0-4d3f-a948-32ce701d9f9f", + "name": "Save to Obsidian", + "credentials": { + "httpHeaderAuth": { + "id": "465Swz2b71O2KRAK", + "name": "Obsidian Local REST API" + } + }, + "continueOnFail": true + } + ], + "connections": { + "Daily 06:30 PT": { + "main": [ + [ + { + "node": "Weather", + "type": "main", + "index": 0 + }, + { + "node": "Swarm Health", + "type": "main", + "index": 0 + }, + { + "node": "n8n Health", + "type": "main", + "index": 0 + }, + { + "node": "LiteLLM Health", + "type": "main", + "index": 0 + }, + { + "node": "Email Highlights", + "type": "main", + "index": 0 + }, + { + "node": "Calendar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Weather": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Swarm Health": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "n8n Health": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "LiteLLM Health": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Email Highlights": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Calendar": { + "main": [ + [ + { + "node": "Merge Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge Data": { + "main": [ + [ + { + "node": "Synthesize with LLM", + "type": "main", + "index": 0 + } + ] + ] + }, + "Synthesize with LLM": { + "main": [ + [ + { + "node": "Extract Brief", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Brief": { + "main": [ + [ + { + "node": "Send Telegram", + "type": "main", + "index": 0 + }, + { + "node": "Save to Obsidian", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "will will", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T00:21:08.466Z", + "id": 1493, + "workflowId": "g3IdGZCK1EtTsv9T", + "versionId": "c23946c7-29cf-42dc-985c-c1b818b8d010", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + ] + } +} diff --git a/swarm-common/n8n-workflows/swarm-health-watchdog.json b/swarm-common/n8n-workflows/swarm-health-watchdog.json index bcf6708..32d21b5 100644 --- a/swarm-common/n8n-workflows/swarm-health-watchdog.json +++ b/swarm-common/n8n-workflows/swarm-health-watchdog.json @@ -1 +1,415 @@ -{"updatedAt":"2026-05-13T21:33:29.860Z","createdAt":"2026-05-12T17:48:01.214Z","id":"lDKocSFXBQWQrDd3","name":"Swarm Health Watchdog","description":"Every 15 minutes, checks core swarm endpoints from inside n8n. Alerts after two consecutive failures and reports recoveries to Telegram and Discord.","active":true,"isArchived":false,"nodes":[{"parameters":{},"type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[-620,-100],"id":"3759f3cd-fa90-49b6-ad08-322d21f3d727","name":"Manual Trigger"},{"parameters":{"rule":{"interval":[{"field":"minutes","minutesInterval":15}]}},"type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.3,"position":[-620,100],"id":"9d209ddb-8da7-48ad-850c-ec0e452760ca","name":"Every 15 Minutes"},{"parameters":{"mode":"runOnceForAllItems","jsCode":"const staticData = $getWorkflowStaticData('global');\nconst CONFIG = {\n timeoutMs: 5000,\n failureThreshold: 2,\n reminderEveryFailedRuns: 6,\n};\nconst services = [\n { key: 'brave', name: 'Brave MCP', port: 18802, url: 'http://172.19.0.1:18802/mcp', ok: [200, 400, 404, 405, 406], docker: 'brave-search' },\n { key: 'searxng', name: 'SearXNG', port: 18803, url: 'http://172.19.0.1:18803/search?q=health&format=json', ok: [200], docker: 'searxng' },\n { key: 'litellm', name: 'LiteLLM', port: 18804, url: 'http://172.19.0.1:18804/health/liveliness', ok: [200], docker: 'litellm' },\n { key: 'kokoro', name: 'Kokoro TTS', port: 18805, url: 'http://172.19.0.1:18805/health', ok: [200], docker: 'kokoro-tts' },\n { key: 'llamacpp', name: 'llama.cpp', port: 18806, url: 'http://172.19.0.1:18806/health', ok: [200] },\n { key: 'ollama', name: 'Ollama embeddings', port: 18807, url: 'http://172.19.0.1:18807/api/version', ok: [200] },\n { key: 'n8n', name: 'n8n', port: 18808, url: 'http://127.0.0.1:5678/healthz', ok: [200], docker: 'n8n-agent' },\n { key: 'whisper', name: 'Whisper', port: 18811, url: 'http://172.19.0.1:18811/', ok: [200, 404], docker: 'whisper-server' },\n];\n\n// Fetch Docker container health from host-side endpoint\nlet dockerHealth = {};\ntry {\n const dhRes = await fetch('http://172.19.0.1:18809/health', { signal: AbortSignal.timeout(3000) });\n if (dhRes.ok) {\n const dhData = await dhRes.json();\n for (const c of (dhData.containers || [])) {\n dockerHealth[c.name] = c;\n }\n }\n} catch (_) {\n // Docker health endpoint unavailable - continue without it\n}\n\nasync function check(svc) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), CONFIG.timeoutMs);\n const started = Date.now();\n try {\n const res = await fetch(svc.url, { method: 'GET', signal: controller.signal });\n const ms = Date.now() - started;\n const body = await res.text().catch(() => '');\n return {\n ...svc,\n healthy: svc.ok.includes(res.status),\n status: res.status,\n ms,\n detail: body.slice(0, 160).replace(/\\s+/g, ' ').trim(),\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n } catch (error) {\n return {\n ...svc,\n healthy: false,\n status: 'error',\n ms: Date.now() - started,\n detail: error.message,\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction suggestedFix(r) {\n const dh = r.docker;\n const dockerInfo = dh ? ` [docker: ${dh.status}/${dh.health} restarts=${dh.restarts}]` : '';\n if (r.key === 'llamacpp') return 'systemctl status llama-server.service; restart only if it is down.' + dockerInfo;\n if (r.key === 'ollama') return 'systemctl --user status ollama.service; verify port 18807 and nomic-embed-text.' + dockerInfo;\n if (r.key === 'n8n') return 'docker logs n8n-agent --tail 100; check database/API health.' + dockerInfo;\n if (['searxng','litellm','kokoro','whisper','brave'].includes(r.key)) return `cd ~/lab/swarm && docker compose ps; inspect ${r.name} logs.${dockerInfo}`;\n return 'Check service logs and port listener.' + dockerInfo;\n}\n\nconst results = await Promise.all(services.map(check));\nconst now = new Date().toISOString();\nstaticData.services = staticData.services || {};\nconst alerts = [];\nconst recoveries = [];\nfor (const r of results) {\n const prev = staticData.services[r.key] || { failedRuns: 0, alerted: false };\n if (r.healthy) {\n if (prev.alerted) recoveries.push({ ...r, previousFailedRuns: prev.failedRuns });\n staticData.services[r.key] = { failedRuns: 0, alerted: false, lastOk: now, lastStatus: r.status, lastDetail: r.detail };\n } else {\n const failedRuns = (prev.failedRuns || 0) + 1;\n const shouldAlert = failedRuns >= CONFIG.failureThreshold && (!prev.alerted || (CONFIG.reminderEveryFailedRuns > 0 && failedRuns % CONFIG.reminderEveryFailedRuns === 0));\n staticData.services[r.key] = { failedRuns, alerted: prev.alerted || shouldAlert, lastFailure: now, lastStatus: r.status, lastDetail: r.detail };\n if (shouldAlert) alerts.push({ ...r, failedRuns, suggestedFix: suggestedFix(r) });\n }\n}\n\nif (!alerts.length && !recoveries.length) return [];\nlet lines = [];\nif (alerts.length) {\n lines.push('\\u{1F6A8} Swarm Health Watchdog');\n for (const a of alerts) {\n const dh = a.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}/restarts=${dh.restarts}` : '';\n lines.push(`- ${a.name} :${a.port} failed ${a.failedRuns} checks; status=${a.status}${dockerStr}; detail=${a.detail || 'n/a'}; fix=${a.suggestedFix}`);\n }\n}\nif (recoveries.length) {\n lines.push('\\u2705 Swarm service recovered');\n for (const r of recoveries) {\n const dh = r.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}` : '';\n lines.push(`- ${r.name} :${r.port} healthy again; status=${r.status}; latency=${r.ms}ms${dockerStr}`);\n }\n}\nlines.push(`checked=${now}`);\nreturn [{ json: { text: lines.join('\\n'), alerts, recoveries, results, dockerHealth, checkedAt: now } }];"},"type":"n8n-nodes-base.code","typeVersion":2,"position":[-340,0],"id":"b3f76d53-204b-45bb-9a48-8cf20262319d","name":"Check Swarm Services"},{"parameters":{"chatId":"8367012007","text":"={{$json.text}}","additionalFields":{"parse_mode":"Markdown"}},"type":"n8n-nodes-base.telegram","typeVersion":1.2,"position":[-80,-80],"id":"32d7ad9f-80bb-4acf-b546-89f04db32a6a","name":"Send Telegram Alert","credentials":{"telegramApi":{"id":"aox4dyIWVSRdcH5z","name":"Telegram Bot (OpenClaw)"}}},{"parameters":{"authentication":"predefinedCredentialType","nodeCredentialType":"httpHeaderAuth","method":"POST","url":"https://discord.com/api/v10/channels/425781661268049931/messages","sendBody":true,"specifyBody":"json","jsonBody":"={{ { content: $json.text } }}","options":{}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-80,100],"id":"7eb589f5-6e50-4e1e-8a37-391f06785ad87","name":"Send Discord Alert","credentials":{"httpHeaderAuth":{"id":"UgPqYcoCNNIgr55m","name":"Discord Bot Auth"}}}],"connections":{"Manual Trigger":{"main":[[{"node":"Check Swarm Services","type":"main","index":0}]]},"Every 15 Minutes":{"main":[[{"node":"Check Swarm Services","type":"main","index":0}]]},"Check Swarm Services":{"main":[[{"node":"Send Telegram Alert","type":"main","index":0},{"node":"Send Discord Alert","type":"main","index":0}]]}},"settings":{"executionOrder":"v1","timezone":"America/Los_Angeles","saveDataErrorExecution":"all","saveDataSuccessExecution":"none","callerPolicy":"workflowsFromSameOwner","availableInMCP":false},"staticData":{"node:Every 15 Minutes":{"recurrenceRules":[]}},"meta":null,"pinData":null,"versionId":"0be0d265-2373-49f8-9066-a9c8aabb7861","activeVersionId":"0be0d265-2373-49f8-9066-a9c8aabb7861","versionCounter":28,"triggerCount":1,"shared":[{"updatedAt":"2026-05-12T17:39:10.124Z","createdAt":"2026-05-12T17:39:10.124Z","role":"workflow:owner","workflowId":"lDKocSFXBQWQrDd3","projectId":"WGdp8QunI1tHpjXa","project":{"updatedAt":"2026-03-11T21:08:10.005Z","createdAt":"2026-03-11T21:05:11.541Z","id":"WGdp8QunI1tHpjXa","name":"will will ","type":"personal","icon":null,"description":null,"creatorId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"}}],"tags":[],"activeVersion":{"updatedAt":"2026-05-13T21:33:29.861Z","createdAt":"2026-05-13T21:33:29.861Z","versionId":"0be0d265-2373-49f8-9066-a9c8aabb7861","workflowId":"lDKocSFXBQWQrDd3","nodes":[{"parameters":{},"type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[-620,-100],"id":"3759f3cd-fa90-49b6-ad08-322d21f3d727","name":"Manual Trigger"},{"parameters":{"rule":{"interval":[{"field":"minutes","minutesInterval":15}]}},"type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.3,"position":[-620,100],"id":"9d209ddb-8da7-48ad-850c-ec0e452760ca","name":"Every 15 Minutes"},{"parameters":{"mode":"runOnceForAllItems","jsCode":"const staticData = $getWorkflowStaticData('global');\nconst CONFIG = {\n timeoutMs: 5000,\n failureThreshold: 2,\n reminderEveryFailedRuns: 6,\n};\nconst services = [\n { key: 'brave', name: 'Brave MCP', port: 18802, url: 'http://172.19.0.1:18802/mcp', ok: [200, 400, 404, 405, 406], docker: 'brave-search' },\n { key: 'searxng', name: 'SearXNG', port: 18803, url: 'http://172.19.0.1:18803/search?q=health&format=json', ok: [200], docker: 'searxng' },\n { key: 'litellm', name: 'LiteLLM', port: 18804, url: 'http://172.19.0.1:18804/health/liveliness', ok: [200], docker: 'litellm' },\n { key: 'kokoro', name: 'Kokoro TTS', port: 18805, url: 'http://172.19.0.1:18805/health', ok: [200], docker: 'kokoro-tts' },\n { key: 'llamacpp', name: 'llama.cpp', port: 18806, url: 'http://172.19.0.1:18806/health', ok: [200] },\n { key: 'ollama', name: 'Ollama embeddings', port: 18807, url: 'http://172.19.0.1:18807/api/version', ok: [200] },\n { key: 'n8n', name: 'n8n', port: 18808, url: 'http://127.0.0.1:5678/healthz', ok: [200], docker: 'n8n-agent' },\n { key: 'whisper', name: 'Whisper', port: 18811, url: 'http://172.19.0.1:18811/', ok: [200, 404], docker: 'whisper-server' },\n];\n\n// Fetch Docker container health from host-side endpoint\nlet dockerHealth = {};\ntry {\n const dhRes = await fetch('http://172.19.0.1:18809/health', { signal: AbortSignal.timeout(3000) });\n if (dhRes.ok) {\n const dhData = await dhRes.json();\n for (const c of (dhData.containers || [])) {\n dockerHealth[c.name] = c;\n }\n }\n} catch (_) {\n // Docker health endpoint unavailable - continue without it\n}\n\nasync function check(svc) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), CONFIG.timeoutMs);\n const started = Date.now();\n try {\n const res = await fetch(svc.url, { method: 'GET', signal: controller.signal });\n const ms = Date.now() - started;\n const body = await res.text().catch(() => '');\n return {\n ...svc,\n healthy: svc.ok.includes(res.status),\n status: res.status,\n ms,\n detail: body.slice(0, 160).replace(/\\s+/g, ' ').trim(),\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n } catch (error) {\n return {\n ...svc,\n healthy: false,\n status: 'error',\n ms: Date.now() - started,\n detail: error.message,\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n } finally {\n clearTimeout(timer);\n }\n}\n\nfunction suggestedFix(r) {\n const dh = r.docker;\n const dockerInfo = dh ? ` [docker: ${dh.status}/${dh.health} restarts=${dh.restarts}]` : '';\n if (r.key === 'llamacpp') return 'systemctl status llama-server.service; restart only if it is down.' + dockerInfo;\n if (r.key === 'ollama') return 'systemctl --user status ollama.service; verify port 18807 and nomic-embed-text.' + dockerInfo;\n if (r.key === 'n8n') return 'docker logs n8n-agent --tail 100; check database/API health.' + dockerInfo;\n if (['searxng','litellm','kokoro','whisper','brave'].includes(r.key)) return `cd ~/lab/swarm && docker compose ps; inspect ${r.name} logs.${dockerInfo}`;\n return 'Check service logs and port listener.' + dockerInfo;\n}\n\nconst results = await Promise.all(services.map(check));\nconst now = new Date().toISOString();\nstaticData.services = staticData.services || {};\nconst alerts = [];\nconst recoveries = [];\nfor (const r of results) {\n const prev = staticData.services[r.key] || { failedRuns: 0, alerted: false };\n if (r.healthy) {\n if (prev.alerted) recoveries.push({ ...r, previousFailedRuns: prev.failedRuns });\n staticData.services[r.key] = { failedRuns: 0, alerted: false, lastOk: now, lastStatus: r.status, lastDetail: r.detail };\n } else {\n const failedRuns = (prev.failedRuns || 0) + 1;\n const shouldAlert = failedRuns >= CONFIG.failureThreshold && (!prev.alerted || (CONFIG.reminderEveryFailedRuns > 0 && failedRuns % CONFIG.reminderEveryFailedRuns === 0));\n staticData.services[r.key] = { failedRuns, alerted: prev.alerted || shouldAlert, lastFailure: now, lastStatus: r.status, lastDetail: r.detail };\n if (shouldAlert) alerts.push({ ...r, failedRuns, suggestedFix: suggestedFix(r) });\n }\n}\n\nif (!alerts.length && !recoveries.length) return [];\nlet lines = [];\nif (alerts.length) {\n lines.push('\\u{1F6A8} Swarm Health Watchdog');\n for (const a of alerts) {\n const dh = a.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}/restarts=${dh.restarts}` : '';\n lines.push(`- ${a.name} :${a.port} failed ${a.failedRuns} checks; status=${a.status}${dockerStr}; detail=${a.detail || 'n/a'}; fix=${a.suggestedFix}`);\n }\n}\nif (recoveries.length) {\n lines.push('\\u2705 Swarm service recovered');\n for (const r of recoveries) {\n const dh = r.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}` : '';\n lines.push(`- ${r.name} :${r.port} healthy again; status=${r.status}; latency=${r.ms}ms${dockerStr}`);\n }\n}\nlines.push(`checked=${now}`);\nreturn [{ json: { text: lines.join('\\n'), alerts, recoveries, results, dockerHealth, checkedAt: now } }];"},"type":"n8n-nodes-base.code","typeVersion":2,"position":[-340,0],"id":"b3f76d53-204b-45bb-9a48-8cf20262319d","name":"Check Swarm Services"},{"parameters":{"chatId":"8367012007","text":"={{$json.text}}","additionalFields":{"parse_mode":"Markdown"}},"type":"n8n-nodes-base.telegram","typeVersion":1.2,"position":[-80,-80],"id":"32d7ad9f-80bb-4acf-b546-89f04db32a6a","name":"Send Telegram Alert","credentials":{"telegramApi":{"id":"aox4dyIWVSRdcH5z","name":"Telegram Bot (OpenClaw)"}}},{"parameters":{"authentication":"predefinedCredentialType","nodeCredentialType":"httpHeaderAuth","method":"POST","url":"https://discord.com/api/v10/channels/425781661268049931/messages","sendBody":true,"specifyBody":"json","jsonBody":"={{ { content: $json.text } }}","options":{}},"type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-80,100],"id":"7eb589f5-6e50-4e1e-8a37-391f06785ad87","name":"Send Discord Alert","credentials":{"httpHeaderAuth":{"id":"UgPqYcoCNNIgr55m","name":"Discord Bot Auth"}}}],"connections":{"Manual Trigger":{"main":[[{"node":"Check Swarm Services","type":"main","index":0}]]},"Every 15 Minutes":{"main":[[{"node":"Check Swarm Services","type":"main","index":0}]]},"Check Swarm Services":{"main":[[{"node":"Send Telegram Alert","type":"main","index":0},{"node":"Send Discord Alert","type":"main","index":0}]]}},"authors":"will will","name":null,"description":null,"autosaved":false,"workflowPublishHistory":[{"createdAt":"2026-05-13T21:33:29.886Z","id":1431,"workflowId":"lDKocSFXBQWQrDd3","versionId":"0be0d265-2373-49f8-9066-a9c8aabb7861","event":"activated","userId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"},{"createdAt":"2026-05-13T21:33:29.877Z","id":1430,"workflowId":"lDKocSFXBQWQrDd3","versionId":"0be0d265-2373-49f8-9066-a9c8aabb7861","event":"deactivated","userId":"5ad50ead-6e6a-4d12-ab5b-e5db15835bb5"}]}} \ No newline at end of file +{ + "updatedAt": "2026-05-14T00:32:57.803Z", + "createdAt": "2026-05-12T17:48:01.214Z", + "id": "lDKocSFXBQWQrDd3", + "name": "Swarm Health Watchdog", + "description": "Every 15 minutes, checks core swarm endpoints from inside n8n. Alerts after two consecutive failures and reports recoveries to Telegram and Discord.", + "active": true, + "isArchived": false, + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -620, + -100 + ], + "id": "3759f3cd-fa90-49b6-ad08-322d21f3d727", + "name": "Manual Trigger" + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + -620, + 100 + ], + "id": "9d209ddb-8da7-48ad-850c-ec0e452760ca", + "name": "Every 15 Minutes" + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst CONFIG = {\n timeoutMs: 5000,\n failureThreshold: 2,\n reminderEveryFailedRuns: 6,\n};\nconst services = [\n { key: 'brave', name: 'Brave MCP', port: 18802, url: 'http://172.19.0.1:18802/mcp', ok: [200, 400, 404, 405, 406], docker: 'brave-search' },\n { key: 'searxng', name: 'SearXNG', port: 18803, url: 'http://172.19.0.1:18803/search?q=health&format=json', ok: [200], docker: 'searxng' },\n { key: 'litellm', name: 'LiteLLM', port: 18804, url: 'http://172.19.0.1:18804/health/liveliness', ok: [200], docker: 'litellm' },\n { key: 'kokoro', name: 'Kokoro TTS', port: 18805, url: 'http://172.19.0.1:18805/health', ok: [200], docker: 'kokoro-tts' },\n { key: 'llamacpp', name: 'llama.cpp', port: 18806, url: 'http://172.19.0.1:18806/health', ok: [200] },\n { key: 'ollama', name: 'Ollama embeddings', port: 18807, url: 'http://172.19.0.1:18807/api/version', ok: [200] },\n { key: 'n8n', name: 'n8n', port: 18808, url: 'http://127.0.0.1:5678/healthz', ok: [200], docker: 'n8n-agent' },\n { key: 'whisper', name: 'Whisper', port: 18811, url: 'http://172.19.0.1:18811/', ok: [200, 404], docker: 'whisper-server' },\n];\n\nasync function fetchWithTimeout(url, options = {}, timeoutMs = CONFIG.timeoutMs) {\n let timer;\n const timeoutPromise = new Promise((_, reject) => {\n timer = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);\n });\n try {\n return await Promise.race([fetch(url, options), timeoutPromise]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n// Fetch Docker container health from host-side endpoint\nlet dockerHealth = {};\ntry {\n const dhRes = await fetchWithTimeout('http://172.19.0.1:18809/health', { method: 'GET' }, 3000);\n if (dhRes.ok) {\n const dhData = await dhRes.json();\n for (const c of (dhData.containers || [])) {\n dockerHealth[c.name] = c;\n }\n }\n} catch (_) {\n // Docker health endpoint unavailable - continue without it\n}\n\nasync function check(svc) {\n const started = Date.now();\n try {\n const res = await fetchWithTimeout(svc.url, { method: 'GET' }, CONFIG.timeoutMs);\n const ms = Date.now() - started;\n const body = await res.text().catch(() => '');\n return {\n ...svc,\n healthy: svc.ok.includes(res.status),\n status: res.status,\n ms,\n detail: body.slice(0, 160).replace(/\\s+/g, ' ').trim(),\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n } catch (error) {\n return {\n ...svc,\n healthy: false,\n status: 'error',\n ms: Date.now() - started,\n detail: error.message,\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n }\n}\n\nfunction suggestedFix(r) {\n const dh = r.docker;\n const dockerInfo = dh ? ` [docker: ${dh.status}/${dh.health} restarts=${dh.restarts}]` : '';\n if (r.key === 'llamacpp') return 'systemctl status llama-server.service; restart only if it is down.' + dockerInfo;\n if (r.key === 'ollama') return 'systemctl --user status ollama.service; verify port 18807 and nomic-embed-text.' + dockerInfo;\n if (r.key === 'n8n') return 'docker logs n8n-agent --tail 100; check database/API health.' + dockerInfo;\n if (['searxng','litellm','kokoro','whisper','brave'].includes(r.key)) return `cd ~/lab/swarm && docker compose ps; inspect ${r.name} logs.${dockerInfo}`;\n return 'Check service logs and port listener.' + dockerInfo;\n}\n\nconst results = await Promise.all(services.map(check));\nconst now = new Date().toISOString();\nstaticData.services = staticData.services || {};\nconst alerts = [];\nconst recoveries = [];\nfor (const r of results) {\n const prev = staticData.services[r.key] || { failedRuns: 0, alerted: false };\n if (r.healthy) {\n if (prev.alerted) recoveries.push({ ...r, previousFailedRuns: prev.failedRuns });\n staticData.services[r.key] = { failedRuns: 0, alerted: false, lastOk: now, lastStatus: r.status, lastDetail: r.detail };\n } else {\n const failedRuns = (prev.failedRuns || 0) + 1;\n const shouldAlert = failedRuns >= CONFIG.failureThreshold && (!prev.alerted || (CONFIG.reminderEveryFailedRuns > 0 && failedRuns % CONFIG.reminderEveryFailedRuns === 0));\n staticData.services[r.key] = { failedRuns, alerted: prev.alerted || shouldAlert, lastFailure: now, lastStatus: r.status, lastDetail: r.detail };\n if (shouldAlert) alerts.push({ ...r, failedRuns, suggestedFix: suggestedFix(r) });\n }\n}\n\nif (!alerts.length && !recoveries.length) return [];\nlet lines = [];\nif (alerts.length) {\n lines.push('\\u{1F6A8} Swarm Health Watchdog');\n for (const a of alerts) {\n const dh = a.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}/restarts=${dh.restarts}` : '';\n lines.push(`- ${a.name} :${a.port} failed ${a.failedRuns} checks; status=${a.status}${dockerStr}; detail=${a.detail || 'n/a'}; fix=${a.suggestedFix}`);\n }\n}\nif (recoveries.length) {\n lines.push('\\u2705 Swarm service recovered');\n for (const r of recoveries) {\n const dh = r.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}` : '';\n lines.push(`- ${r.name} :${r.port} healthy again; status=${r.status}; latency=${r.ms}ms${dockerStr}`);\n }\n}\nlines.push(`checked=${now}`);\nreturn [{ json: { text: lines.join('\\n'), alerts, recoveries, results, dockerHealth, checkedAt: now } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -340, + 0 + ], + "id": "b3f76d53-204b-45bb-9a48-8cf20262319d", + "name": "Check Swarm Services" + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{$json.text}}", + "additionalFields": { + "parse_mode": "Markdown" + } + }, + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + -80, + -80 + ], + "id": "32d7ad9f-80bb-4acf-b546-89f04db32a6a", + "name": "Send Telegram Alert", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + } + }, + { + "parameters": { + "authentication": "predefinedCredentialType", + "nodeCredentialType": "httpHeaderAuth", + "method": "POST", + "url": "https://discord.com/api/v10/channels/425781661268049931/messages", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: $json.text } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -80, + 100 + ], + "id": "7eb589f5-6e50-4e1e-8a37-391f06785ad87", + "name": "Send Discord Alert", + "credentials": { + "httpHeaderAuth": { + "id": "UgPqYcoCNNIgr55m", + "name": "Discord Bot Auth" + } + } + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Check Swarm Services", + "type": "main", + "index": 0 + } + ] + ] + }, + "Every 15 Minutes": { + "main": [ + [ + { + "node": "Check Swarm Services", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Swarm Services": { + "main": [ + [ + { + "node": "Send Telegram Alert", + "type": "main", + "index": 0 + }, + { + "node": "Send Discord Alert", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "timezone": "America/Los_Angeles", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "staticData": { + "node:Every 15 Minutes": { + "recurrenceRules": [] + }, + "global": { + "services": { + "brave": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "searxng": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "litellm": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "kokoro": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "llamacpp": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "ollama": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "n8n": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + }, + "whisper": { + "failedRuns": 4, + "alerted": true, + "lastFailure": "2026-05-14T00:30:40.067Z", + "lastStatus": "error", + "lastDetail": "fetch is not defined" + } + } + } + }, + "meta": null, + "versionId": "eec5521b-fb44-44ea-b238-aff842560f98", + "activeVersionId": "eec5521b-fb44-44ea-b238-aff842560f98", + "versionCounter": 52, + "triggerCount": 1, + "shared": [ + { + "updatedAt": "2026-05-12T17:39:10.124Z", + "createdAt": "2026-05-12T17:39:10.124Z", + "role": "workflow:owner", + "workflowId": "lDKocSFXBQWQrDd3", + "projectId": "WGdp8QunI1tHpjXa", + "project": { + "updatedAt": "2026-03-11T21:08:10.005Z", + "createdAt": "2026-03-11T21:05:11.541Z", + "id": "WGdp8QunI1tHpjXa", + "name": "will will ", + "type": "personal", + "icon": null, + "description": null, + "creatorId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + } + ], + "tags": [], + "activeVersion": { + "updatedAt": "2026-05-14T00:09:09.316Z", + "createdAt": "2026-05-14T00:09:09.316Z", + "versionId": "eec5521b-fb44-44ea-b238-aff842560f98", + "workflowId": "lDKocSFXBQWQrDd3", + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -620, + -100 + ], + "id": "3759f3cd-fa90-49b6-ad08-322d21f3d727", + "name": "Manual Trigger" + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.3, + "position": [ + -620, + 100 + ], + "id": "9d209ddb-8da7-48ad-850c-ec0e452760ca", + "name": "Every 15 Minutes" + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst CONFIG = {\n timeoutMs: 5000,\n failureThreshold: 2,\n reminderEveryFailedRuns: 6,\n};\nconst services = [\n { key: 'brave', name: 'Brave MCP', port: 18802, url: 'http://172.19.0.1:18802/mcp', ok: [200, 400, 404, 405, 406], docker: 'brave-search' },\n { key: 'searxng', name: 'SearXNG', port: 18803, url: 'http://172.19.0.1:18803/search?q=health&format=json', ok: [200], docker: 'searxng' },\n { key: 'litellm', name: 'LiteLLM', port: 18804, url: 'http://172.19.0.1:18804/health/liveliness', ok: [200], docker: 'litellm' },\n { key: 'kokoro', name: 'Kokoro TTS', port: 18805, url: 'http://172.19.0.1:18805/health', ok: [200], docker: 'kokoro-tts' },\n { key: 'llamacpp', name: 'llama.cpp', port: 18806, url: 'http://172.19.0.1:18806/health', ok: [200] },\n { key: 'ollama', name: 'Ollama embeddings', port: 18807, url: 'http://172.19.0.1:18807/api/version', ok: [200] },\n { key: 'n8n', name: 'n8n', port: 18808, url: 'http://127.0.0.1:5678/healthz', ok: [200], docker: 'n8n-agent' },\n { key: 'whisper', name: 'Whisper', port: 18811, url: 'http://172.19.0.1:18811/', ok: [200, 404], docker: 'whisper-server' },\n];\n\nasync function fetchWithTimeout(url, options = {}, timeoutMs = CONFIG.timeoutMs) {\n let timer;\n const timeoutPromise = new Promise((_, reject) => {\n timer = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);\n });\n try {\n return await Promise.race([fetch(url, options), timeoutPromise]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n// Fetch Docker container health from host-side endpoint\nlet dockerHealth = {};\ntry {\n const dhRes = await fetchWithTimeout('http://172.19.0.1:18809/health', { method: 'GET' }, 3000);\n if (dhRes.ok) {\n const dhData = await dhRes.json();\n for (const c of (dhData.containers || [])) {\n dockerHealth[c.name] = c;\n }\n }\n} catch (_) {\n // Docker health endpoint unavailable - continue without it\n}\n\nasync function check(svc) {\n const started = Date.now();\n try {\n const res = await fetchWithTimeout(svc.url, { method: 'GET' }, CONFIG.timeoutMs);\n const ms = Date.now() - started;\n const body = await res.text().catch(() => '');\n return {\n ...svc,\n healthy: svc.ok.includes(res.status),\n status: res.status,\n ms,\n detail: body.slice(0, 160).replace(/\\s+/g, ' ').trim(),\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n } catch (error) {\n return {\n ...svc,\n healthy: false,\n status: 'error',\n ms: Date.now() - started,\n detail: error.message,\n docker: svc.docker ? (dockerHealth[svc.docker] || { name: svc.docker, status: 'unknown', health: 'unknown', restarts: -1 }) : null,\n };\n }\n}\n\nfunction suggestedFix(r) {\n const dh = r.docker;\n const dockerInfo = dh ? ` [docker: ${dh.status}/${dh.health} restarts=${dh.restarts}]` : '';\n if (r.key === 'llamacpp') return 'systemctl status llama-server.service; restart only if it is down.' + dockerInfo;\n if (r.key === 'ollama') return 'systemctl --user status ollama.service; verify port 18807 and nomic-embed-text.' + dockerInfo;\n if (r.key === 'n8n') return 'docker logs n8n-agent --tail 100; check database/API health.' + dockerInfo;\n if (['searxng','litellm','kokoro','whisper','brave'].includes(r.key)) return `cd ~/lab/swarm && docker compose ps; inspect ${r.name} logs.${dockerInfo}`;\n return 'Check service logs and port listener.' + dockerInfo;\n}\n\nconst results = await Promise.all(services.map(check));\nconst now = new Date().toISOString();\nstaticData.services = staticData.services || {};\nconst alerts = [];\nconst recoveries = [];\nfor (const r of results) {\n const prev = staticData.services[r.key] || { failedRuns: 0, alerted: false };\n if (r.healthy) {\n if (prev.alerted) recoveries.push({ ...r, previousFailedRuns: prev.failedRuns });\n staticData.services[r.key] = { failedRuns: 0, alerted: false, lastOk: now, lastStatus: r.status, lastDetail: r.detail };\n } else {\n const failedRuns = (prev.failedRuns || 0) + 1;\n const shouldAlert = failedRuns >= CONFIG.failureThreshold && (!prev.alerted || (CONFIG.reminderEveryFailedRuns > 0 && failedRuns % CONFIG.reminderEveryFailedRuns === 0));\n staticData.services[r.key] = { failedRuns, alerted: prev.alerted || shouldAlert, lastFailure: now, lastStatus: r.status, lastDetail: r.detail };\n if (shouldAlert) alerts.push({ ...r, failedRuns, suggestedFix: suggestedFix(r) });\n }\n}\n\nif (!alerts.length && !recoveries.length) return [];\nlet lines = [];\nif (alerts.length) {\n lines.push('\\u{1F6A8} Swarm Health Watchdog');\n for (const a of alerts) {\n const dh = a.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}/restarts=${dh.restarts}` : '';\n lines.push(`- ${a.name} :${a.port} failed ${a.failedRuns} checks; status=${a.status}${dockerStr}; detail=${a.detail || 'n/a'}; fix=${a.suggestedFix}`);\n }\n}\nif (recoveries.length) {\n lines.push('\\u2705 Swarm service recovered');\n for (const r of recoveries) {\n const dh = r.docker;\n const dockerStr = dh ? ` | docker:${dh.status}/${dh.health}` : '';\n lines.push(`- ${r.name} :${r.port} healthy again; status=${r.status}; latency=${r.ms}ms${dockerStr}`);\n }\n}\nlines.push(`checked=${now}`);\nreturn [{ json: { text: lines.join('\\n'), alerts, recoveries, results, dockerHealth, checkedAt: now } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + -340, + 0 + ], + "id": "b3f76d53-204b-45bb-9a48-8cf20262319d", + "name": "Check Swarm Services" + }, + { + "parameters": { + "chatId": "8367012007", + "text": "={{$json.text}}", + "additionalFields": { + "parse_mode": "Markdown" + } + }, + "type": "n8n-nodes-base.telegram", + "typeVersion": 1.2, + "position": [ + -80, + -80 + ], + "id": "32d7ad9f-80bb-4acf-b546-89f04db32a6a", + "name": "Send Telegram Alert", + "credentials": { + "telegramApi": { + "id": "aox4dyIWVSRdcH5z", + "name": "Telegram Bot (OpenClaw)" + } + } + }, + { + "parameters": { + "authentication": "predefinedCredentialType", + "nodeCredentialType": "httpHeaderAuth", + "method": "POST", + "url": "https://discord.com/api/v10/channels/425781661268049931/messages", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: $json.text } }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -80, + 100 + ], + "id": "7eb589f5-6e50-4e1e-8a37-391f06785ad87", + "name": "Send Discord Alert", + "credentials": { + "httpHeaderAuth": { + "id": "UgPqYcoCNNIgr55m", + "name": "Discord Bot Auth" + } + } + } + ], + "connections": { + "Manual Trigger": { + "main": [ + [ + { + "node": "Check Swarm Services", + "type": "main", + "index": 0 + } + ] + ] + }, + "Every 15 Minutes": { + "main": [ + [ + { + "node": "Check Swarm Services", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Swarm Services": { + "main": [ + [ + { + "node": "Send Telegram Alert", + "type": "main", + "index": 0 + }, + { + "node": "Send Discord Alert", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "authors": "will will", + "name": null, + "description": null, + "autosaved": false, + "workflowPublishHistory": [ + { + "createdAt": "2026-05-14T00:09:09.344Z", + "id": 1489, + "workflowId": "lDKocSFXBQWQrDd3", + "versionId": "eec5521b-fb44-44ea-b238-aff842560f98", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:32:57.833Z", + "id": 1495, + "workflowId": "lDKocSFXBQWQrDd3", + "versionId": "eec5521b-fb44-44ea-b238-aff842560f98", + "event": "activated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + }, + { + "createdAt": "2026-05-14T00:32:57.790Z", + "id": 1494, + "workflowId": "lDKocSFXBQWQrDd3", + "versionId": "eec5521b-fb44-44ea-b238-aff842560f98", + "event": "deactivated", + "userId": "5ad50ead-6e6a-4d12-ab5b-e5db15835bb5" + } + ] + } +}