mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +02:00
direct gmail initial commit
This commit is contained in:
parent
b01af12148
commit
e4244a8ce5
9 changed files with 318 additions and 75 deletions
|
|
@ -324,7 +324,8 @@ live:
|
|||
Maintain a digest of email threads worth attention today, as a single \`emails\` block.
|
||||
|
||||
Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the
|
||||
full digest from scratch.
|
||||
full digest from scratch. In each email entry, store only \`threadId\` or \`threadUrl\`,
|
||||
optional \`summary\`, and optional \`draft_response\`; the block hydrates Gmail metadata.
|
||||
|
||||
With an event payload (event run): integrate the new thread into the existing digest —
|
||||
add it if new, update its entry if the threadId is already shown — and don't re-list
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
|||
// on-disk `templateVersion` against this constant — if older or missing, the
|
||||
// existing file is renamed to Today.md.bkp.<ISO-stamp> and replaced with the
|
||||
// new template. v2 is the live-note rewrite (single objective, no `track:`).
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 2;
|
||||
const CANONICAL_DAILY_NOTE_VERSION = 3;
|
||||
|
||||
const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
|
||||
objective:
|
||||
|
|
@ -24,7 +24,7 @@ const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
|
|||
|
||||
2. **Calendar** — today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\` → \`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`.
|
||||
|
||||
3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
|
||||
3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry should contain only \`threadId\` and/or \`threadUrl\`, plus optional \`summary\` if useful for why it matters. Do **not** copy \`subject\`, \`from\`, \`date\`, or \`latest_email\` from Gmail into the block — the email block hydrates the latest thread from Gmail directly. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`, and use the filename/Thread ID to set \`threadId\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails."
|
||||
|
||||
4. **What you missed** — a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings/<source>/<yesterday>/\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag."
|
||||
|
||||
|
|
|
|||
|
|
@ -163,11 +163,11 @@ If there are events, include them:
|
|||
1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
|
||||
2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
|
||||
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
|
||||
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
|
||||
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Each entry should contain only \`threadId\` and/or \`threadUrl\`, plus optional \`summary\` if useful. Do not copy sender, subject, date, or latest body into the block; the renderer hydrates those from Gmail. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
|
||||
|
||||
\`\`\`
|
||||
\\\`\\\`\\\`emails
|
||||
{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender <sender@example.com>","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"Security alert","subject":"New sign-in from Chrome","from":"Google <no-reply@accounts.google.com>","date":"2026-04-01T09:15:00+05:30","latest_email":"A new sign-in to your account was detected."}]}
|
||||
{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation needs acknowledgement.","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"FYI security alert."}]}
|
||||
\\\`\\\`\\\`
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -221,4 +221,4 @@ When you see a target region associated with your task (during a scheduled run),
|
|||
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
|
||||
- Do NOT include the target tags themselves in your response
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,19 +160,17 @@ Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\
|
|||
|
||||
## \`email\` — single email or thread digest (JSON)
|
||||
|
||||
Use for: surfacing one important thread — latest message body, summary of prior context, optional draft reply.
|
||||
Use for: surfacing one important Gmail thread. Prefer storing only a Gmail thread reference plus an optional draft reply; the renderer hydrates sender, subject, date, and latest body from Gmail.
|
||||
|
||||
\`\`\`email
|
||||
{
|
||||
"subject": "Q3 launch readiness",
|
||||
"from": "sarah@acme.com",
|
||||
"date": "2026-04-19T16:42:00Z",
|
||||
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
|
||||
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
|
||||
"threadId": "18d5a3b2c1e4f567",
|
||||
"summary": "Needs a reply on the Q3 launch blocker.",
|
||||
"draft_response": "Thanks for flagging. I'll check with infra and get back to you today."
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
|
||||
Required: \`threadId\` or \`threadUrl\`. Optional: \`summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both"). Legacy fields \`subject\`, \`from\`, \`to\`, \`date\`, \`latest_email\`, and \`past_summary\` are supported for older notes, but don't emit them for Gmail threads.
|
||||
|
||||
For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ interface SyncedThread {
|
|||
markdown: string;
|
||||
}
|
||||
|
||||
export interface GmailThreadSnapshot {
|
||||
threadId: string;
|
||||
threadUrl: string;
|
||||
summary?: string;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
date?: string;
|
||||
latest_email?: string;
|
||||
past_summary?: string;
|
||||
}
|
||||
|
||||
function summarizeGmailSync(threads: SyncedThread[]): string {
|
||||
const lines: string[] = [
|
||||
`# Gmail sync update`,
|
||||
|
|
@ -124,6 +136,59 @@ function getBody(payload: gmail.Schema$MessagePart): string {
|
|||
return body;
|
||||
}
|
||||
|
||||
function normalizeBody(body: string): string {
|
||||
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
|
||||
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
|
||||
}
|
||||
|
||||
export async function fetchThreadSnapshot(threadId: string): Promise<GmailThreadSnapshot | null> {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (!auth) {
|
||||
throw new Error('Gmail is not connected.');
|
||||
}
|
||||
|
||||
const gmailClient = google.gmail({ version: 'v1', auth });
|
||||
const res = await gmailClient.users.threads.get({ userId: 'me', id: threadId });
|
||||
const messages = res.data.messages;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
const parsed = messages.map((msg) => {
|
||||
const headers = msg.payload?.headers || [];
|
||||
return {
|
||||
from: headerValue(headers, 'From') || 'Unknown',
|
||||
to: headerValue(headers, 'To'),
|
||||
date: headerValue(headers, 'Date'),
|
||||
subject: headerValue(headers, 'Subject') || '(No Subject)',
|
||||
body: msg.payload ? normalizeBody(getBody(msg.payload)) : '',
|
||||
};
|
||||
});
|
||||
|
||||
const latest = parsed[parsed.length - 1]!;
|
||||
const earlier = parsed.slice(0, -1);
|
||||
const earlierSummary = earlier
|
||||
.map((msg) => {
|
||||
const date = msg.date ? ` (${msg.date})` : '';
|
||||
const body = msg.body.replace(/\s+/g, ' ').slice(0, 500).trim();
|
||||
return `${msg.from}${date}: ${body}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
threadId,
|
||||
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
|
||||
subject: latest.subject || parsed[0]?.subject,
|
||||
from: latest.from,
|
||||
to: latest.to,
|
||||
date: latest.date,
|
||||
latest_email: latest.body,
|
||||
past_summary: earlierSummary || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
|
||||
const filename = part.filename;
|
||||
const attId = part.body?.attachmentId;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue