diff options
| -rw-r--r-- | .ai/workflows/triage-intake.org | 2 | ||||
| -rw-r--r-- | .ai/workflows/triage-intake.telegram.org | 198 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/triage-intake.org | 2 | ||||
| -rw-r--r-- | claude-templates/.ai/workflows/triage-intake.telegram.org | 198 |
4 files changed, 398 insertions, 2 deletions
diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org index e4d433d..265b883 100644 --- a/.ai/workflows/triage-intake.org +++ b/.ai/workflows/triage-intake.org @@ -48,7 +48,7 @@ The engine has no sources baked in. It discovers them by globbing *two* director ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2>/dev/null #+end_src -- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, personal GitHub PRs). +- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, Telegram, personal GitHub PRs). - =.ai/project-workflows/triage-intake.*.org= — *PROJECT-SPECIFIC* source plugins, never synced, owned by this project (e.g. a work project's Linear, work Gmail, work Slack, enterprise-GitHub PRs). ⚠ *THE #1 FAILURE MODE — read this twice.* Globbing only =.ai/workflows/= and silently missing every project plugin. If you skip =.ai/project-workflows/=, the sweep runs with *half its sources* and Craig never learns what it dropped — the omission is invisible, because a missing source looks identical to a quiet source in the output. There is no error, no empty block, no warning. The sweep just lies by omission. *Glob both directories. Always.* diff --git a/.ai/workflows/triage-intake.telegram.org b/.ai/workflows/triage-intake.telegram.org new file mode 100644 index 0000000..68604bc --- /dev/null +++ b/.ai/workflows/triage-intake.telegram.org @@ -0,0 +1,198 @@ +#+TITLE: Triage Intake — Telegram Source +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-06-09 + +# Source plugin for the triage-intake engine. See triage-intake.org for the +# contract and the Phase A-D orchestration. This file declares ONE source. +# +# General (personal) source: Telegram via the Emacs telega.el package (tdlib +# backend). It lives in .ai/workflows/ and is template-synced, sitting with the +# other general personal sources (personal-gmail, cmail, personal-calendar, +# signal, github-prs) — not the project plugins. Telegram is personal +# messaging, not project-specific. +# +# Unlike signal-cli (a standalone CLI), Telegram has no headless CLI here. The +# client is telega.el running inside Craig's long-lived `emacs --daemon`, so the +# plugin drives it over `emacsclient -e`. tdlib keeps a persisted session in +# ~/.telega (td.binlog), so a started telega reconnects without re-auth. + +* Source: telegram +:PROPERTIES: +:ORDER: 24 +:ENABLED: command -v emacsclient && emacsclient -e "(or (featurep 'telega) (fboundp 'telega))" | grep -q t +:ANCHOR: none +:SUBAGENT_OVER: 40 +:END: + +** Scan + +Telegram direct messages and groups via telega.el in the running Emacs daemon. +=ANCHOR: none= because telega reports live unread *state* (each chat's +=:unread_count=), not a since-window — the engine substitutes no cutoff. Phase B +uses each message's timestamp only to order and label recency. + +The scan reads the =telega--chats= hash table (chat-id → chat plist), which +telega populates as chats sync. *This is robust to the tdlib server crashing +mid-session* (see the SEGFAULT gotcha below): the hash retains the last-synced +unread counts and =:last_message= even after the server dies, so a scan reading +the hash still returns the most recent known state. + +*** Leave-no-trace lifecycle (start only if needed, shut down only if we started it) + +telega is a long-lived client inside Craig's daemon. If he already has it +running, the scan must leave it running. If it's *not* running, the scan starts +it for the read and shuts it down cleanly afterward, restoring the daemon to its +prior state. The discipline: *record the prior liveness, branch on it at the +end.* + +*** Step 0 — record prior state + +#+begin_src bash +# t if telega's tdlib server was ALREADY live before this scan, nil otherwise. +# Hold this value; Step 3 reads it to decide whether to shut telega down. +TELEGA_WAS_RUNNING=$(emacsclient -e "(and (fboundp 'telega-server-live-p) (telega-server-live-p) t)" 2>/dev/null) +#+end_src + +*** Step 1 — start (docker mode) if not already running, wait for Ready + +#+begin_src bash +# `(telega t)` starts without popping the root buffer. Docker mode (the stable +# path — see the SEGFAULT gotcha) reconnects the persisted ~/.telega session in +# ~2s. Then load the main chat list so telega--chats populates. +emacsclient -e "(progn + (unless (and (fboundp 'telega-server-live-p) (telega-server-live-p)) (telega t)) + 'started)" +# Poll until Ready with chats synced, or a crash/timeout. Background this with an +# until-loop so the wait doesn't block; exit on Ready-with-chats OR an abnormal +# server exit. Then force a chat-list load if the hash is thin: +emacsclient -e "(progn (ignore-errors (telega--loadChats 'main)) (ignore-errors (telega--loadChats 'main)) 'loaded)" +#+end_src + +On a persisted session telega reaches status "Ready" within ~2s; the chat list +loads over a few more. If =(hash-table-count telega--chats)= is 0 or thin, +re-issue =telega--loadChats= and poll until it stabilizes. + +*** Step 2 — read unread, classified by last-message type + +The single most important filter: =messageContactRegistered=. Telegram counts a +"<name> joined Telegram" service notice as one unread message, so every contact +from Craig's old address book who ever joined shows as a 1-unread "DM" *that +person never actually sent*. On the 2026-06-09 first scan this was 30 of ~50 +unread chats. Drop them entirely (tally only). + +#+begin_src bash +emacsclient -e "(let (real svc other) + (when (boundp 'telega--chats) + (maphash (lambda (id chat) + (let* ((uc (or (plist-get chat :unread_count) 0)) + (lm (plist-get chat :last_message)) + (ctype (when lm (plist-get (plist-get lm :content) :@type))) + (title (or (ignore-errors (substring-no-properties (telega-chat-title chat))) \"?\"))) + (when (> uc 0) + (cond + ((equal ctype \"messageContactRegistered\") (push title svc)) + ((member ctype '(\"messageText\" \"messagePhoto\" \"messageVideo\" \"messageDocument\" \"messageVoiceNote\" \"messageSticker\" \"messageAnimation\")) + (push (list title uc ctype) real)) + (t (push (list title uc (or ctype \"nil\")) other)))))) + telega--chats)) + (list (cons 'real (nreverse real)) + (cons 'joined-telegram-count (length svc)) + (cons 'other (nreverse other))))" +#+end_src + +For a chat that survives as Action-worthy, pull the last message's text to +classify and summarize: + +#+begin_src bash +# <CHAT-ID> from the maphash key (the scan can also return ids alongside titles) +emacsclient -e "(let ((c (gethash <CHAT-ID> telega--chats))) + (substring-no-properties + (or (telega--tl-get c :last_message :content :text :text) \"\")))" +#+end_src + +*** Step 3 — restore prior state (shut down only if we started it) + +#+begin_src bash +# If telega was NOT running before this scan, shut it down cleanly to leave the +# daemon as we found it. If Craig already had it running, leave it alone. +if [ "$TELEGA_WAS_RUNNING" != "t" ]; then + emacsclient -e "(progn (ignore-errors (telega-server-kill)) (ignore-errors (telega-kill t)) 'killed)" +fi +#+end_src + +⚠ *In docker mode, =telega-kill= alone is not enough.* =telega-kill= buries the +telega buffers but leaves the dockerized tdlib server running (=telega-server-live-p= +stays non-nil). =telega-server-kill= is what actually stops the server. Call +*both* — server-kill then kill — for a clean teardown. Verified clean afterward: +=telega-server-live-p= → nil, root buffer gone, no =zevlg/telega-server= container +left in =docker ps=. Skipping this whole branch when =TELEGA_WAS_RUNNING= is t is +the point of Step 0: never tear down a session Craig is actively using. + +⚠ *SEGFAULT GOTCHA — docker mode is the fix.* The tdlib =telega-server= can exit +abnormally (code 139, SIGSEGV) during or just after the initial sync. Observed +on the 2026-06-09 first run; the cure was *docker mode* — =telega-use-docker= = t +with the =zevlg/telega-server:latest= image (already pulled on Craig's box). +Restarted under docker, the server reached "Ready" in ~2s, synced 73 chats, and +held through a live re-scan with no crash. So: *ensure docker mode is on before +the sweep.* If =telega-use-docker= is nil, set it (=(setq telega-use-docker t)=) +and restart telega. + +Defense in depth: even if the server does die, the scan still works because it +reads the cached =telega--chats= hash, not a live query. A dead server is +*scan-only* — you can still report unread state, but cannot read new bodies, mark +read, or reply until it restarts. Treat that as "scan-only, no actions this run" +and say so. + +** Classify + +Bias: Craig's personal Telegram is *spam-dominated* with a thin layer of real +signal. The opposite of Signal (high signal/low volume) — here the volume is +high and almost all noise. Filter aggressively; surface only the few real +threads. + +- *Noise-trash (tally only, never itemized):* + - =messageContactRegistered= "joined Telegram" notices — always noise, no + matter whose name is on them. The real contacts Craig knows live here; a + join notice is not a message from them. + - Romance/crypto spam DMs — the signature is an emoji-laden handle or a + two-word "RealName + FantasyWord" suffix (=Gayle ⚾🤎RoyalVineyard=, + =Cherie🌷🏰 InfiniteRhapsody=, =Jane 🍒🔥=, =Luna Skye=). One unread, + unsolicited, no prior thread. + - =Deleted Account-NNNN= threads, blank-title chats, bot channels + (=Z-Library Official=), Telegram's own =✔️Telegram= service notices. +- *FYI:* unread in dev-community groups Craig follows — =GNU Emacs=, =zed=, + =Kitty=, and similar. Worth awareness, no response owed. List the group + + unread count; don't enumerate the messages. +- *Action:* a real text/voice/media message from a *known personal contact* in + an existing one-to-one thread — an explicit ask, a question, a reply owed. On + a spam-heavy account these are rare; when one appears, surface it prominently + with the sender + gist, because it's the needle in the haystack. + +The 2026-06-09 calibration run: 30 join-notices + ~10 spam/deleted/bot + 3 dev +groups + 0 real personal DMs. Expect most sweeps to look like this — a clean +"nothing real" is the common, correct result. + +** Render + +#+begin_example +**Telegram — N unread chats (M real after filtering).** <one-line summary> +- Action: <real DMs from known contacts, sender + gist, reply owed called out> +- FYI: <dev groups — name + count> +- Noise: K joined-Telegram notices, J spam/bot/deleted (tally only) +#+end_example + +Omit the block if there are zero unread chats. If the only unread is +join-notices + spam + groups (the common case), render the one-line summary plus +the Noise tally and skip Action entirely. + +** Actions + +Actions need the tdlib server *live* (see the SEGFAULT gotcha — a dead server is +scan-only). All run through telega in the daemon: + +- reply :: =emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"= — public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for multi-line. +- mark-read :: =emacsclient -e "(telega-chat--mark-read (telega-chat-get <CHAT-ID>))"= — clears a specific chat's unread. Never mark the whole account read blindly; the join-notice noise is fine to leave or clear per Craig's call. +- open :: =emacsclient -e "(telega-chat-with (telega-chat-get <CHAT-ID>))"= — pop the chat buffer for Craig to read/handle by hand (useful when a real DM needs a considered reply). + +No blanket mark-read verb: the account's unread is mostly join-notices and spam, +and a real DM should be handled deliberately, not swept. diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org index e4d433d..265b883 100644 --- a/claude-templates/.ai/workflows/triage-intake.org +++ b/claude-templates/.ai/workflows/triage-intake.org @@ -48,7 +48,7 @@ The engine has no sources baked in. It discovers them by globbing *two* director ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2>/dev/null #+end_src -- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, personal GitHub PRs). +- =.ai/workflows/triage-intake.*.org= — *general* source plugins, template-synced (personal Gmail, personal calendar, cmail/Proton, Signal, Telegram, personal GitHub PRs). - =.ai/project-workflows/triage-intake.*.org= — *PROJECT-SPECIFIC* source plugins, never synced, owned by this project (e.g. a work project's Linear, work Gmail, work Slack, enterprise-GitHub PRs). ⚠ *THE #1 FAILURE MODE — read this twice.* Globbing only =.ai/workflows/= and silently missing every project plugin. If you skip =.ai/project-workflows/=, the sweep runs with *half its sources* and Craig never learns what it dropped — the omission is invisible, because a missing source looks identical to a quiet source in the output. There is no error, no empty block, no warning. The sweep just lies by omission. *Glob both directories. Always.* diff --git a/claude-templates/.ai/workflows/triage-intake.telegram.org b/claude-templates/.ai/workflows/triage-intake.telegram.org new file mode 100644 index 0000000..68604bc --- /dev/null +++ b/claude-templates/.ai/workflows/triage-intake.telegram.org @@ -0,0 +1,198 @@ +#+TITLE: Triage Intake — Telegram Source +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-06-09 + +# Source plugin for the triage-intake engine. See triage-intake.org for the +# contract and the Phase A-D orchestration. This file declares ONE source. +# +# General (personal) source: Telegram via the Emacs telega.el package (tdlib +# backend). It lives in .ai/workflows/ and is template-synced, sitting with the +# other general personal sources (personal-gmail, cmail, personal-calendar, +# signal, github-prs) — not the project plugins. Telegram is personal +# messaging, not project-specific. +# +# Unlike signal-cli (a standalone CLI), Telegram has no headless CLI here. The +# client is telega.el running inside Craig's long-lived `emacs --daemon`, so the +# plugin drives it over `emacsclient -e`. tdlib keeps a persisted session in +# ~/.telega (td.binlog), so a started telega reconnects without re-auth. + +* Source: telegram +:PROPERTIES: +:ORDER: 24 +:ENABLED: command -v emacsclient && emacsclient -e "(or (featurep 'telega) (fboundp 'telega))" | grep -q t +:ANCHOR: none +:SUBAGENT_OVER: 40 +:END: + +** Scan + +Telegram direct messages and groups via telega.el in the running Emacs daemon. +=ANCHOR: none= because telega reports live unread *state* (each chat's +=:unread_count=), not a since-window — the engine substitutes no cutoff. Phase B +uses each message's timestamp only to order and label recency. + +The scan reads the =telega--chats= hash table (chat-id → chat plist), which +telega populates as chats sync. *This is robust to the tdlib server crashing +mid-session* (see the SEGFAULT gotcha below): the hash retains the last-synced +unread counts and =:last_message= even after the server dies, so a scan reading +the hash still returns the most recent known state. + +*** Leave-no-trace lifecycle (start only if needed, shut down only if we started it) + +telega is a long-lived client inside Craig's daemon. If he already has it +running, the scan must leave it running. If it's *not* running, the scan starts +it for the read and shuts it down cleanly afterward, restoring the daemon to its +prior state. The discipline: *record the prior liveness, branch on it at the +end.* + +*** Step 0 — record prior state + +#+begin_src bash +# t if telega's tdlib server was ALREADY live before this scan, nil otherwise. +# Hold this value; Step 3 reads it to decide whether to shut telega down. +TELEGA_WAS_RUNNING=$(emacsclient -e "(and (fboundp 'telega-server-live-p) (telega-server-live-p) t)" 2>/dev/null) +#+end_src + +*** Step 1 — start (docker mode) if not already running, wait for Ready + +#+begin_src bash +# `(telega t)` starts without popping the root buffer. Docker mode (the stable +# path — see the SEGFAULT gotcha) reconnects the persisted ~/.telega session in +# ~2s. Then load the main chat list so telega--chats populates. +emacsclient -e "(progn + (unless (and (fboundp 'telega-server-live-p) (telega-server-live-p)) (telega t)) + 'started)" +# Poll until Ready with chats synced, or a crash/timeout. Background this with an +# until-loop so the wait doesn't block; exit on Ready-with-chats OR an abnormal +# server exit. Then force a chat-list load if the hash is thin: +emacsclient -e "(progn (ignore-errors (telega--loadChats 'main)) (ignore-errors (telega--loadChats 'main)) 'loaded)" +#+end_src + +On a persisted session telega reaches status "Ready" within ~2s; the chat list +loads over a few more. If =(hash-table-count telega--chats)= is 0 or thin, +re-issue =telega--loadChats= and poll until it stabilizes. + +*** Step 2 — read unread, classified by last-message type + +The single most important filter: =messageContactRegistered=. Telegram counts a +"<name> joined Telegram" service notice as one unread message, so every contact +from Craig's old address book who ever joined shows as a 1-unread "DM" *that +person never actually sent*. On the 2026-06-09 first scan this was 30 of ~50 +unread chats. Drop them entirely (tally only). + +#+begin_src bash +emacsclient -e "(let (real svc other) + (when (boundp 'telega--chats) + (maphash (lambda (id chat) + (let* ((uc (or (plist-get chat :unread_count) 0)) + (lm (plist-get chat :last_message)) + (ctype (when lm (plist-get (plist-get lm :content) :@type))) + (title (or (ignore-errors (substring-no-properties (telega-chat-title chat))) \"?\"))) + (when (> uc 0) + (cond + ((equal ctype \"messageContactRegistered\") (push title svc)) + ((member ctype '(\"messageText\" \"messagePhoto\" \"messageVideo\" \"messageDocument\" \"messageVoiceNote\" \"messageSticker\" \"messageAnimation\")) + (push (list title uc ctype) real)) + (t (push (list title uc (or ctype \"nil\")) other)))))) + telega--chats)) + (list (cons 'real (nreverse real)) + (cons 'joined-telegram-count (length svc)) + (cons 'other (nreverse other))))" +#+end_src + +For a chat that survives as Action-worthy, pull the last message's text to +classify and summarize: + +#+begin_src bash +# <CHAT-ID> from the maphash key (the scan can also return ids alongside titles) +emacsclient -e "(let ((c (gethash <CHAT-ID> telega--chats))) + (substring-no-properties + (or (telega--tl-get c :last_message :content :text :text) \"\")))" +#+end_src + +*** Step 3 — restore prior state (shut down only if we started it) + +#+begin_src bash +# If telega was NOT running before this scan, shut it down cleanly to leave the +# daemon as we found it. If Craig already had it running, leave it alone. +if [ "$TELEGA_WAS_RUNNING" != "t" ]; then + emacsclient -e "(progn (ignore-errors (telega-server-kill)) (ignore-errors (telega-kill t)) 'killed)" +fi +#+end_src + +⚠ *In docker mode, =telega-kill= alone is not enough.* =telega-kill= buries the +telega buffers but leaves the dockerized tdlib server running (=telega-server-live-p= +stays non-nil). =telega-server-kill= is what actually stops the server. Call +*both* — server-kill then kill — for a clean teardown. Verified clean afterward: +=telega-server-live-p= → nil, root buffer gone, no =zevlg/telega-server= container +left in =docker ps=. Skipping this whole branch when =TELEGA_WAS_RUNNING= is t is +the point of Step 0: never tear down a session Craig is actively using. + +⚠ *SEGFAULT GOTCHA — docker mode is the fix.* The tdlib =telega-server= can exit +abnormally (code 139, SIGSEGV) during or just after the initial sync. Observed +on the 2026-06-09 first run; the cure was *docker mode* — =telega-use-docker= = t +with the =zevlg/telega-server:latest= image (already pulled on Craig's box). +Restarted under docker, the server reached "Ready" in ~2s, synced 73 chats, and +held through a live re-scan with no crash. So: *ensure docker mode is on before +the sweep.* If =telega-use-docker= is nil, set it (=(setq telega-use-docker t)=) +and restart telega. + +Defense in depth: even if the server does die, the scan still works because it +reads the cached =telega--chats= hash, not a live query. A dead server is +*scan-only* — you can still report unread state, but cannot read new bodies, mark +read, or reply until it restarts. Treat that as "scan-only, no actions this run" +and say so. + +** Classify + +Bias: Craig's personal Telegram is *spam-dominated* with a thin layer of real +signal. The opposite of Signal (high signal/low volume) — here the volume is +high and almost all noise. Filter aggressively; surface only the few real +threads. + +- *Noise-trash (tally only, never itemized):* + - =messageContactRegistered= "joined Telegram" notices — always noise, no + matter whose name is on them. The real contacts Craig knows live here; a + join notice is not a message from them. + - Romance/crypto spam DMs — the signature is an emoji-laden handle or a + two-word "RealName + FantasyWord" suffix (=Gayle ⚾🤎RoyalVineyard=, + =Cherie🌷🏰 InfiniteRhapsody=, =Jane 🍒🔥=, =Luna Skye=). One unread, + unsolicited, no prior thread. + - =Deleted Account-NNNN= threads, blank-title chats, bot channels + (=Z-Library Official=), Telegram's own =✔️Telegram= service notices. +- *FYI:* unread in dev-community groups Craig follows — =GNU Emacs=, =zed=, + =Kitty=, and similar. Worth awareness, no response owed. List the group + + unread count; don't enumerate the messages. +- *Action:* a real text/voice/media message from a *known personal contact* in + an existing one-to-one thread — an explicit ask, a question, a reply owed. On + a spam-heavy account these are rare; when one appears, surface it prominently + with the sender + gist, because it's the needle in the haystack. + +The 2026-06-09 calibration run: 30 join-notices + ~10 spam/deleted/bot + 3 dev +groups + 0 real personal DMs. Expect most sweeps to look like this — a clean +"nothing real" is the common, correct result. + +** Render + +#+begin_example +**Telegram — N unread chats (M real after filtering).** <one-line summary> +- Action: <real DMs from known contacts, sender + gist, reply owed called out> +- FYI: <dev groups — name + count> +- Noise: K joined-Telegram notices, J spam/bot/deleted (tally only) +#+end_example + +Omit the block if there are zero unread chats. If the only unread is +join-notices + spam + groups (the common case), render the one-line summary plus +the Noise tally and skip Action entirely. + +** Actions + +Actions need the tdlib server *live* (see the SEGFAULT gotcha — a dead server is +scan-only). All run through telega in the daemon: + +- reply :: =emacsclient -e "(telega-chat-send-msg (telega-chat-get <CHAT-ID>) \"<body>\")"= — public-facing (goes out under Craig's name), so run =/voice personal= before sending. Prefer a body file for multi-line. +- mark-read :: =emacsclient -e "(telega-chat--mark-read (telega-chat-get <CHAT-ID>))"= — clears a specific chat's unread. Never mark the whole account read blindly; the join-notice noise is fine to leave or clear per Craig's call. +- open :: =emacsclient -e "(telega-chat-with (telega-chat-get <CHAT-ID>))"= — pop the chat buffer for Craig to read/handle by hand (useful when a real DM needs a considered reply). + +No blanket mark-read verb: the account's unread is mostly join-notices and spam, +and a real DM should be handled deliberately, not swept. |
