nothumanallowed 4.1.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -3
- package/src/cli.mjs +181 -5
- package/src/commands/autostart.mjs +342 -0
- package/src/commands/chat.mjs +14 -8
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/ops.mjs +37 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +28 -7
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +61 -0
- package/src/constants.mjs +9 -1
- package/src/services/llm.mjs +22 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/memory.mjs +627 -0
- package/src/services/message-responder.mjs +778 -0
- package/src/services/microsoft-calendar.mjs +319 -0
- package/src/services/microsoft-mail.mjs +308 -0
- package/src/services/microsoft-oauth.mjs +345 -0
- package/src/services/ops-daemon.mjs +620 -11
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/tool-executor.mjs +392 -0
- package/src/services/web-ui.mjs +187 -1
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
import fs from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
17
|
import { callAgent, callLLM } from './llm.mjs';
|
|
18
|
-
import { getUnreadImportant, getTodayEmails } from './
|
|
19
|
-
import { getTodayEvents, getUpcomingEvents } from './
|
|
18
|
+
import { getUnreadImportant, getTodayEmails } from './mail-router.mjs';
|
|
19
|
+
import { getTodayEvents, getUpcomingEvents } from './mail-router.mjs';
|
|
20
20
|
import { getTasks, bulkAddTasks } from './task-store.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { hasMailProvider } from './mail-router.mjs';
|
|
22
22
|
import { NHA_DIR } from '../constants.mjs';
|
|
23
23
|
import { info, ok, fail, warn, D, C, G, Y, W, BOLD, NC } from '../ui.mjs';
|
|
24
24
|
|
|
@@ -47,8 +47,7 @@ export async function runPlanningPipeline(config, opts = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const startTime = Date.now();
|
|
50
|
-
const
|
|
51
|
-
const hasGoogle = !!tokens;
|
|
50
|
+
const hasMail = hasMailProvider();
|
|
52
51
|
|
|
53
52
|
console.log(`\n ${BOLD}NHA Daily Plan — ${dateStr}${NC}`);
|
|
54
53
|
console.log(` ${D}5 specialist agents analyzing your day${NC}\n`);
|
|
@@ -60,7 +59,7 @@ export async function runPlanningPipeline(config, opts = {}) {
|
|
|
60
59
|
let events = [];
|
|
61
60
|
const tasks = getTasks(dateStr);
|
|
62
61
|
|
|
63
|
-
if (
|
|
62
|
+
if (hasMail) {
|
|
64
63
|
try {
|
|
65
64
|
[emails, events] = await Promise.all([
|
|
66
65
|
getUnreadImportant(config, 30),
|
|
@@ -68,11 +67,11 @@ export async function runPlanningPipeline(config, opts = {}) {
|
|
|
68
67
|
]);
|
|
69
68
|
ok(`${emails.length} emails, ${events.length} events, ${tasks.length} tasks`);
|
|
70
69
|
} catch (err) {
|
|
71
|
-
warn(`
|
|
70
|
+
warn(`Mail API error: ${err.message}`);
|
|
72
71
|
info('Continuing with tasks only...');
|
|
73
72
|
}
|
|
74
73
|
} else {
|
|
75
|
-
warn('
|
|
74
|
+
warn('No mail provider connected. Using tasks only. Run "nha google auth" or "nha microsoft auth" to connect.');
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// Build context for agents
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Encrypted token storage — AES-256-GCM with machine-derived key.
|
|
3
|
-
*
|
|
3
|
+
* Supports multiple providers (google, microsoft) via separate encrypted files.
|
|
4
|
+
* Stores OAuth tokens at ~/.nha/ops/tokens-<provider>.enc
|
|
5
|
+
* Legacy single-file (~/.nha/ops/tokens.enc) is treated as 'google' for backward compat.
|
|
4
6
|
* Zero dependencies — uses Node.js native crypto.
|
|
5
7
|
*/
|
|
6
8
|
|
|
@@ -11,7 +13,15 @@ import path from 'path';
|
|
|
11
13
|
import { NHA_DIR } from '../constants.mjs';
|
|
12
14
|
|
|
13
15
|
const OPS_DIR = path.join(NHA_DIR, 'ops');
|
|
14
|
-
const
|
|
16
|
+
const LEGACY_TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
|
|
17
|
+
|
|
18
|
+
/** Get tokens file path for a given provider */
|
|
19
|
+
function getTokensFile(provider) {
|
|
20
|
+
if (!provider || provider === 'google') {
|
|
21
|
+
return LEGACY_TOKENS_FILE; // backward compatible — Google uses the original file
|
|
22
|
+
}
|
|
23
|
+
return path.join(OPS_DIR, `tokens-${provider}.enc`);
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
/** Derive encryption key from machine-specific fingerprint */
|
|
17
27
|
function deriveKey() {
|
|
@@ -49,21 +59,25 @@ function decrypt(envelope) {
|
|
|
49
59
|
/**
|
|
50
60
|
* Save OAuth tokens (encrypted).
|
|
51
61
|
* @param {object} tokens — { access_token, refresh_token, expires_at, scope, email }
|
|
62
|
+
* @param {string} provider — 'google' (default) or 'microsoft'
|
|
52
63
|
*/
|
|
53
|
-
export function saveTokens(tokens) {
|
|
64
|
+
export function saveTokens(tokens, provider) {
|
|
54
65
|
fs.mkdirSync(OPS_DIR, { recursive: true });
|
|
66
|
+
const file = getTokensFile(provider);
|
|
55
67
|
const envelope = encrypt(tokens);
|
|
56
|
-
fs.writeFileSync(
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(envelope, null, 2), { mode: 0o600 });
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
72
|
* Load OAuth tokens (decrypted).
|
|
73
|
+
* @param {string} provider — 'google' (default) or 'microsoft'
|
|
61
74
|
* @returns {object|null} tokens or null if not stored
|
|
62
75
|
*/
|
|
63
|
-
export function loadTokens() {
|
|
64
|
-
|
|
76
|
+
export function loadTokens(provider) {
|
|
77
|
+
const file = getTokensFile(provider);
|
|
78
|
+
if (!fs.existsSync(file)) return null;
|
|
65
79
|
try {
|
|
66
|
-
const envelope = JSON.parse(fs.readFileSync(
|
|
80
|
+
const envelope = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
67
81
|
return decrypt(envelope);
|
|
68
82
|
} catch {
|
|
69
83
|
return null;
|
|
@@ -72,9 +86,11 @@ export function loadTokens() {
|
|
|
72
86
|
|
|
73
87
|
/**
|
|
74
88
|
* Delete stored tokens.
|
|
89
|
+
* @param {string} provider — 'google' (default) or 'microsoft'
|
|
75
90
|
*/
|
|
76
|
-
export function deleteTokens() {
|
|
77
|
-
|
|
91
|
+
export function deleteTokens(provider) {
|
|
92
|
+
const file = getTokensFile(provider);
|
|
93
|
+
if (fs.existsSync(file)) fs.rmSync(file);
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
/**
|
|
@@ -88,7 +104,7 @@ export function isExpired(tokens) {
|
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
/**
|
|
91
|
-
* Refresh access token using refresh_token.
|
|
107
|
+
* Refresh access token using refresh_token (Google-specific).
|
|
92
108
|
* @param {string} clientId
|
|
93
109
|
* @param {string} clientSecret
|
|
94
110
|
* @param {string} refreshToken
|
|
@@ -123,12 +139,12 @@ export async function refreshAccessToken(clientId, clientSecret, refreshToken) {
|
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
/**
|
|
126
|
-
* Get a valid access token — refreshes automatically if expired.
|
|
142
|
+
* Get a valid Google access token — refreshes automatically if expired.
|
|
127
143
|
* @param {object} config — NHA config with google.clientId etc.
|
|
128
144
|
* @returns {Promise<string>} access_token
|
|
129
145
|
*/
|
|
130
146
|
export async function getAccessToken(config) {
|
|
131
|
-
let tokens = loadTokens();
|
|
147
|
+
let tokens = loadTokens('google');
|
|
132
148
|
if (!tokens) throw new Error('Not authenticated. Run: nha google auth');
|
|
133
149
|
|
|
134
150
|
if (isExpired(tokens)) {
|
|
@@ -137,9 +153,20 @@ export async function getAccessToken(config) {
|
|
|
137
153
|
if (!clientId) throw new Error('Google client ID not configured');
|
|
138
154
|
|
|
139
155
|
tokens = await refreshAccessToken(clientId, clientSecret, tokens.refresh_token);
|
|
140
|
-
tokens.email = loadTokens()?.email; // preserve email
|
|
141
|
-
saveTokens(tokens);
|
|
156
|
+
tokens.email = loadTokens('google')?.email; // preserve email
|
|
157
|
+
saveTokens(tokens, 'google');
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
return tokens.access_token;
|
|
145
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check which providers are currently authenticated.
|
|
165
|
+
* @returns {{ google: boolean, microsoft: boolean }}
|
|
166
|
+
*/
|
|
167
|
+
export function getAuthenticatedProviders() {
|
|
168
|
+
return {
|
|
169
|
+
google: loadTokens('google') !== null,
|
|
170
|
+
microsoft: loadTokens('microsoft') !== null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool executor and action parser — used by chat.mjs, ui.mjs, and voice.mjs.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes the JSON action block parsing and tool execution logic
|
|
5
|
+
* to eliminate code duplication across the three PAO interfaces.
|
|
6
|
+
*
|
|
7
|
+
* Zero dependencies — pure Node.js 22.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
listMessages,
|
|
12
|
+
getMessage,
|
|
13
|
+
getUnreadImportant,
|
|
14
|
+
sendEmail,
|
|
15
|
+
createDraft,
|
|
16
|
+
getTodayEvents,
|
|
17
|
+
getUpcomingEvents,
|
|
18
|
+
getEventsForDate,
|
|
19
|
+
createEvent,
|
|
20
|
+
updateEvent,
|
|
21
|
+
} from './mail-router.mjs';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
getTasks,
|
|
25
|
+
addTask,
|
|
26
|
+
completeTask,
|
|
27
|
+
moveTask,
|
|
28
|
+
} from './task-store.mjs';
|
|
29
|
+
|
|
30
|
+
import { notify } from './notification.mjs';
|
|
31
|
+
|
|
32
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Actions that mutate external state and require user confirmation. */
|
|
35
|
+
export const DESTRUCTIVE_ACTIONS = new Set([
|
|
36
|
+
'gmail_send',
|
|
37
|
+
'gmail_reply',
|
|
38
|
+
'calendar_create',
|
|
39
|
+
'calendar_move',
|
|
40
|
+
'task_done',
|
|
41
|
+
'notify_remind',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// ── System Prompt (tool definitions) ─────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the tool definitions system prompt block.
|
|
48
|
+
* Callers should replace {{TODAY}} and {{TIMEZONE}} and append their own persona.
|
|
49
|
+
*/
|
|
50
|
+
export const TOOL_DEFINITIONS = `
|
|
51
|
+
You have access to the following tools. When the user's message requires an action,
|
|
52
|
+
output EXACTLY ONE fenced JSON block per action:
|
|
53
|
+
|
|
54
|
+
\`\`\`json
|
|
55
|
+
{"action": "<tool_name>", "params": { ... }}
|
|
56
|
+
\`\`\`
|
|
57
|
+
|
|
58
|
+
You may include conversational text BEFORE or AFTER the JSON block. If no action
|
|
59
|
+
is needed, respond normally without any JSON block.
|
|
60
|
+
|
|
61
|
+
TOOLS:
|
|
62
|
+
|
|
63
|
+
1. gmail_list(query: string, maxResults?: number)
|
|
64
|
+
Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
|
|
65
|
+
Default maxResults = 10.
|
|
66
|
+
|
|
67
|
+
2. gmail_read(messageId: string)
|
|
68
|
+
Read the full body of an email by its ID (returned by gmail_list).
|
|
69
|
+
|
|
70
|
+
3. gmail_send(to: string, subject: string, body: string)
|
|
71
|
+
Send an email. ALWAYS confirm with the user before sending.
|
|
72
|
+
|
|
73
|
+
4. gmail_draft(to: string, subject: string, body: string)
|
|
74
|
+
Create a draft email (safe — does not send).
|
|
75
|
+
|
|
76
|
+
5. gmail_reply(messageId: string, body: string)
|
|
77
|
+
Reply to an existing email thread. ALWAYS confirm before sending.
|
|
78
|
+
|
|
79
|
+
6. calendar_today()
|
|
80
|
+
List all events for today.
|
|
81
|
+
|
|
82
|
+
7. calendar_tomorrow()
|
|
83
|
+
List all events for tomorrow.
|
|
84
|
+
|
|
85
|
+
8. calendar_upcoming(hours?: number)
|
|
86
|
+
List upcoming events in the next N hours (default 2).
|
|
87
|
+
|
|
88
|
+
9. calendar_create(summary: string, start: string, end: string, attendees?: string[], description?: string)
|
|
89
|
+
Create a calendar event. start/end are ISO 8601 datetime strings.
|
|
90
|
+
ALWAYS confirm with the user before creating.
|
|
91
|
+
|
|
92
|
+
10. calendar_move(eventId: string, newStart: string, newEnd: string)
|
|
93
|
+
Reschedule an event. ALWAYS confirm before moving.
|
|
94
|
+
|
|
95
|
+
11. task_list()
|
|
96
|
+
List today's tasks.
|
|
97
|
+
|
|
98
|
+
12. task_add(description: string, priority?: "low"|"medium"|"high"|"critical", due?: string)
|
|
99
|
+
Add a new task for today.
|
|
100
|
+
|
|
101
|
+
13. task_done(id: number)
|
|
102
|
+
Mark a task as completed. Confirm before completing.
|
|
103
|
+
|
|
104
|
+
14. task_move(id: number, toDate: string)
|
|
105
|
+
Move a task to another date (YYYY-MM-DD).
|
|
106
|
+
|
|
107
|
+
15. notify_remind(message: string, atTime: string)
|
|
108
|
+
Set a desktop reminder. atTime is ISO 8601 or relative like "in 30 minutes".
|
|
109
|
+
|
|
110
|
+
RULES:
|
|
111
|
+
- For search/read operations, execute immediately and present results conversationally.
|
|
112
|
+
- For write/send/delete operations (gmail_send, gmail_reply, calendar_create, calendar_move, task_done, notify_remind), DESCRIBE what you're about to do and include the JSON block so the system can ask the user for confirmation.
|
|
113
|
+
- When presenting email results, show From, Subject, Date, and a brief snippet. Never dump raw JSON.
|
|
114
|
+
- When presenting calendar events, show Time, Title, Location/Link. Format times in a human-readable way.
|
|
115
|
+
- When presenting tasks, show ID, Description, Priority, Status.
|
|
116
|
+
- If you need multiple actions in sequence (e.g., read an email then reply), do them ONE AT A TIME — wait for the result of each before proceeding.
|
|
117
|
+
- Dates: today is {{TODAY}}. Infer relative dates from this.
|
|
118
|
+
- The user's timezone is {{TIMEZONE}}.
|
|
119
|
+
- CRITICAL: when creating calendar events, always use LOCAL time in format "YYYY-MM-DDTHH:MM:SS" WITHOUT any Z suffix or timezone offset.
|
|
120
|
+
`.trim();
|
|
121
|
+
|
|
122
|
+
// ── Action Parser ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract JSON action blocks from LLM response text.
|
|
126
|
+
* Supports ```json ... ``` fenced blocks.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} text
|
|
129
|
+
* @returns {{ textParts: string[], actions: Array<{action: string, params: object}> }}
|
|
130
|
+
*/
|
|
131
|
+
export function parseActions(text) {
|
|
132
|
+
const actions = [];
|
|
133
|
+
const textParts = [];
|
|
134
|
+
const fenceRegex = /```json\s*\n?([\s\S]*?)```/g;
|
|
135
|
+
let lastIndex = 0;
|
|
136
|
+
let match;
|
|
137
|
+
|
|
138
|
+
while ((match = fenceRegex.exec(text)) !== null) {
|
|
139
|
+
const before = text.slice(lastIndex, match.index).trim();
|
|
140
|
+
if (before) textParts.push(before);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(match[1].trim());
|
|
144
|
+
if (parsed.action && typeof parsed.action === 'string') {
|
|
145
|
+
actions.push({ action: parsed.action, params: parsed.params || {} });
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
textParts.push(match[0]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
lastIndex = match.index + match[0].length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const trailing = text.slice(lastIndex).trim();
|
|
155
|
+
if (trailing) textParts.push(trailing);
|
|
156
|
+
|
|
157
|
+
return { textParts, actions };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Formatting Helpers ───────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Format an ISO timestamp into a human-readable time string.
|
|
164
|
+
*/
|
|
165
|
+
export function formatTime(isoStr) {
|
|
166
|
+
try {
|
|
167
|
+
const d = new Date(isoStr);
|
|
168
|
+
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
169
|
+
} catch {
|
|
170
|
+
return isoStr;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format an array of calendar events into human-readable text.
|
|
176
|
+
*/
|
|
177
|
+
export function formatEvents(events) {
|
|
178
|
+
return events.map((e, i) => {
|
|
179
|
+
const time = e.isAllDay ? 'All day' : `${formatTime(e.start)} - ${formatTime(e.end)}`;
|
|
180
|
+
const location = e.location ? ` | ${e.location}` : '';
|
|
181
|
+
const link = e.hangoutLink ? ` | ${e.hangoutLink}` : '';
|
|
182
|
+
const cal = e.calendarName ? ` (${e.calendarName})` : '';
|
|
183
|
+
return `${i + 1}. ${time} — ${e.summary}${location}${link}${cal}`;
|
|
184
|
+
}).join('\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve a time string into a Date. Supports ISO 8601 and relative formats
|
|
189
|
+
* like "in 30 minutes", "in 2 hours".
|
|
190
|
+
*/
|
|
191
|
+
export function resolveTime(timeStr) {
|
|
192
|
+
const direct = new Date(timeStr);
|
|
193
|
+
if (!isNaN(direct.getTime()) && timeStr.includes('T')) return direct;
|
|
194
|
+
|
|
195
|
+
const relMatch = timeStr.match(/in\s+(\d+)\s+(minute|min|hour|hr)s?/i);
|
|
196
|
+
if (relMatch) {
|
|
197
|
+
const amount = parseInt(relMatch[1], 10);
|
|
198
|
+
const unit = relMatch[2].toLowerCase();
|
|
199
|
+
const ms = unit.startsWith('h') ? amount * 3600000 : amount * 60000;
|
|
200
|
+
return new Date(Date.now() + ms);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const today = new Date();
|
|
204
|
+
const timeOnly = new Date(`${today.toISOString().split('T')[0]}T${timeStr}`);
|
|
205
|
+
if (!isNaN(timeOnly.getTime())) return timeOnly;
|
|
206
|
+
|
|
207
|
+
return new Date(Date.now() + 1800000);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a human-readable description of an action (for confirmation prompts).
|
|
212
|
+
*/
|
|
213
|
+
export function describeAction(action, params) {
|
|
214
|
+
switch (action) {
|
|
215
|
+
case 'gmail_send':
|
|
216
|
+
return `Send email to ${params.to} — Subject: "${params.subject}"`;
|
|
217
|
+
case 'gmail_reply':
|
|
218
|
+
return `Reply to message ${params.messageId}`;
|
|
219
|
+
case 'calendar_create':
|
|
220
|
+
return `Create event "${params.summary}" at ${formatTime(params.start)}`;
|
|
221
|
+
case 'calendar_move':
|
|
222
|
+
return `Reschedule event ${params.eventId} to ${formatTime(params.newStart)}`;
|
|
223
|
+
case 'task_done':
|
|
224
|
+
return `Mark task #${params.id} as completed`;
|
|
225
|
+
case 'notify_remind':
|
|
226
|
+
return `Set reminder: "${params.message}" at ${params.atTime}`;
|
|
227
|
+
default:
|
|
228
|
+
return `Execute ${action}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Tool Executor ────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Execute a parsed tool action and return a human-readable result string.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} action — tool name
|
|
238
|
+
* @param {object} params — tool parameters
|
|
239
|
+
* @param {object} config — NHA config
|
|
240
|
+
* @returns {Promise<string>} result description
|
|
241
|
+
*/
|
|
242
|
+
export async function executeTool(action, params, config) {
|
|
243
|
+
switch (action) {
|
|
244
|
+
// ── Gmail ──────────────────────────────────────────────────────────────
|
|
245
|
+
case 'gmail_list': {
|
|
246
|
+
const query = params.query || 'is:unread';
|
|
247
|
+
const max = params.maxResults || 10;
|
|
248
|
+
const refs = await listMessages(config, query, max);
|
|
249
|
+
if (refs.length === 0) return 'No emails found matching that query.';
|
|
250
|
+
|
|
251
|
+
const messages = [];
|
|
252
|
+
for (const ref of refs.slice(0, max)) {
|
|
253
|
+
try {
|
|
254
|
+
const msg = await getMessage(config, ref.id);
|
|
255
|
+
messages.push(msg);
|
|
256
|
+
} catch { /* skip failed */ }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return messages.map((m, i) =>
|
|
260
|
+
`${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject} | Date: ${m.date}\n ${m.snippet.slice(0, 120)}`
|
|
261
|
+
).join('\n');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case 'gmail_read': {
|
|
265
|
+
const msg = await getMessage(config, params.messageId);
|
|
266
|
+
return [
|
|
267
|
+
`From: ${msg.from}`,
|
|
268
|
+
`To: ${msg.to}`,
|
|
269
|
+
`Subject: ${msg.subject}`,
|
|
270
|
+
`Date: ${msg.date}`,
|
|
271
|
+
`---`,
|
|
272
|
+
msg.body.slice(0, 4000),
|
|
273
|
+
].join('\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'gmail_send': {
|
|
277
|
+
await sendEmail(config, params.to, params.subject, params.body);
|
|
278
|
+
return `Email sent to ${params.to} with subject "${params.subject}".`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case 'gmail_draft': {
|
|
282
|
+
await createDraft(config, params.to, params.subject, params.body);
|
|
283
|
+
return `Draft created for ${params.to} with subject "${params.subject}".`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'gmail_reply': {
|
|
287
|
+
const original = await getMessage(config, params.messageId);
|
|
288
|
+
await sendEmail(config, original.from, `Re: ${original.subject}`, params.body, {
|
|
289
|
+
replyToMessageId: original.id,
|
|
290
|
+
threadId: original.threadId,
|
|
291
|
+
});
|
|
292
|
+
return `Reply sent to ${original.from} on thread "${original.subject}".`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Calendar ──────────────────────────────────────────────────────────
|
|
296
|
+
case 'calendar_today': {
|
|
297
|
+
const events = await getTodayEvents(config);
|
|
298
|
+
if (events.length === 0) return 'No events scheduled for today.';
|
|
299
|
+
return formatEvents(events);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case 'calendar_tomorrow': {
|
|
303
|
+
const tomorrow = new Date();
|
|
304
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
305
|
+
const dateStr = tomorrow.toISOString().split('T')[0];
|
|
306
|
+
const events = await getEventsForDate(config, dateStr);
|
|
307
|
+
if (events.length === 0) return 'No events scheduled for tomorrow.';
|
|
308
|
+
return formatEvents(events);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case 'calendar_upcoming': {
|
|
312
|
+
const hours = params.hours || 2;
|
|
313
|
+
const events = await getUpcomingEvents(config, hours);
|
|
314
|
+
if (events.length === 0) return `No events in the next ${hours} hour(s).`;
|
|
315
|
+
return formatEvents(events);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case 'calendar_create': {
|
|
319
|
+
await createEvent(config, {
|
|
320
|
+
summary: params.summary,
|
|
321
|
+
start: params.start,
|
|
322
|
+
end: params.end,
|
|
323
|
+
description: params.description || '',
|
|
324
|
+
attendees: params.attendees || [],
|
|
325
|
+
});
|
|
326
|
+
return `Event "${params.summary}" created for ${formatTime(params.start)} - ${formatTime(params.end)}.`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
case 'calendar_move': {
|
|
330
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
331
|
+
await updateEvent(config, 'primary', params.eventId, {
|
|
332
|
+
start: { dateTime: new Date(params.newStart).toISOString(), timeZone: tz },
|
|
333
|
+
end: { dateTime: new Date(params.newEnd).toISOString(), timeZone: tz },
|
|
334
|
+
});
|
|
335
|
+
return `Event rescheduled to ${formatTime(params.newStart)} - ${formatTime(params.newEnd)}.`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Tasks ─────────────────────────────────────────────────────────────
|
|
339
|
+
case 'task_list': {
|
|
340
|
+
const tasks = getTasks();
|
|
341
|
+
if (tasks.length === 0) return 'No tasks for today.';
|
|
342
|
+
return tasks.map(t =>
|
|
343
|
+
`#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}${t.due ? ' (due: ' + t.due + ')' : ''}`
|
|
344
|
+
).join('\n');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'task_add': {
|
|
348
|
+
const task = addTask({
|
|
349
|
+
description: params.description,
|
|
350
|
+
priority: params.priority || 'medium',
|
|
351
|
+
due: params.due || null,
|
|
352
|
+
source: 'tool',
|
|
353
|
+
});
|
|
354
|
+
return `Task #${task.id} added: "${task.description}" [${task.priority}]`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case 'task_done': {
|
|
358
|
+
const success = completeTask(params.id);
|
|
359
|
+
return success ? `Task #${params.id} marked as done.` : `Task #${params.id} not found.`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
case 'task_move': {
|
|
363
|
+
const todayStr = new Date().toISOString().split('T')[0];
|
|
364
|
+
const toDate = params.toDate === 'tomorrow'
|
|
365
|
+
? new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().split('T')[0]
|
|
366
|
+
: params.toDate;
|
|
367
|
+
const success = moveTask(params.id, todayStr, toDate);
|
|
368
|
+
return success ? `Task #${params.id} moved to ${toDate}.` : `Task #${params.id} not found.`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Notifications ─────────────────────────────────────────────────────
|
|
372
|
+
case 'notify_remind': {
|
|
373
|
+
const atTime = resolveTime(params.atTime);
|
|
374
|
+
const delayMs = atTime.getTime() - Date.now();
|
|
375
|
+
|
|
376
|
+
if (delayMs <= 0) {
|
|
377
|
+
await notify('Reminder', params.message, config);
|
|
378
|
+
return `Reminder sent now: "${params.message}"`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
setTimeout(async () => {
|
|
382
|
+
try { await notify('Reminder', params.message, config); } catch { /* best effort */ }
|
|
383
|
+
}, Math.min(delayMs, 86400000));
|
|
384
|
+
|
|
385
|
+
const minutes = Math.round(delayMs / 60000);
|
|
386
|
+
return `Reminder set for ${formatTime(atTime.toISOString())} (in ~${minutes} min): "${params.message}"`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
default:
|
|
390
|
+
return `Unknown action: ${action}`;
|
|
391
|
+
}
|
|
392
|
+
}
|