nothumanallowed 2.0.0 → 2.1.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/README.md +40 -0
- package/package.json +1 -1
- package/src/cli.mjs +5 -0
- package/src/commands/chat.mjs +730 -0
- package/src/constants.mjs +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,46 @@ nha ask oracle "Analyze this dataset" --file data.csv
|
|
|
20
20
|
nha run "Design a Kubernetes deployment for a 10K RPS API"
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Daily Operations (PAO)
|
|
24
|
+
|
|
25
|
+
Connect Gmail + Calendar. 5 specialist agents analyze your day.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Connect Google (one-time)
|
|
29
|
+
nha config set google-client-id YOUR_ID
|
|
30
|
+
nha config set google-client-secret YOUR_SECRET
|
|
31
|
+
nha google auth
|
|
32
|
+
|
|
33
|
+
# Generate your daily plan
|
|
34
|
+
nha plan
|
|
35
|
+
|
|
36
|
+
# Manage tasks
|
|
37
|
+
nha tasks add "Review PR #42" --priority high
|
|
38
|
+
nha tasks done 1
|
|
39
|
+
nha tasks week
|
|
40
|
+
|
|
41
|
+
# Background daemon (auto-alerts before meetings, email security scans)
|
|
42
|
+
nha ops start
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**What `nha plan` does:**
|
|
46
|
+
1. **Fetches** your emails + calendar events + tasks
|
|
47
|
+
2. **SABER** scans emails for phishing and security threats
|
|
48
|
+
3. **HERALD** generates intelligence briefs for each meeting
|
|
49
|
+
4. **ORACLE** analyzes schedule patterns and productivity
|
|
50
|
+
5. **SCHEHERAZADE** prepares talking points for meetings
|
|
51
|
+
6. **CONDUCTOR** synthesizes everything into a structured daily plan
|
|
52
|
+
|
|
53
|
+
OpenClaw reads your email with 1 generic agent. NHA sends it through 5 specialists.
|
|
54
|
+
|
|
55
|
+
### Privacy
|
|
56
|
+
|
|
57
|
+
**Zero data touches NHA servers.** The only network calls are:
|
|
58
|
+
- Google APIs (your OAuth token, direct from your machine)
|
|
59
|
+
- Your LLM provider (your API key, direct from your machine)
|
|
60
|
+
|
|
61
|
+
All data stored locally in `~/.nha/ops/`. Tokens encrypted with AES-256-GCM. You own everything. Inspect it, delete it, export it anytime.
|
|
62
|
+
|
|
23
63
|
## The Agents
|
|
24
64
|
|
|
25
65
|
38 agents across 11 domains. Each agent is a standalone `.mjs` file you own locally — inspect it, modify it, run it offline.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Ask agents directly, plan your day with 5 specialist agents, manage tasks, connect Gmail + Calendar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { cmdAsk } from './commands/ask.mjs';
|
|
|
12
12
|
import { cmdPlan } from './commands/plan.mjs';
|
|
13
13
|
import { cmdTasks } from './commands/tasks.mjs';
|
|
14
14
|
import { cmdOps } from './commands/ops.mjs';
|
|
15
|
+
import { cmdChat } from './commands/chat.mjs';
|
|
15
16
|
import { cmdGoogle } from './commands/google-auth.mjs';
|
|
16
17
|
import { banner, info, ok, warn, fail, C, G, Y, D, W, BOLD, NC, M, B, R } from './ui.mjs';
|
|
17
18
|
|
|
@@ -54,6 +55,9 @@ export async function main(argv) {
|
|
|
54
55
|
case 'ops':
|
|
55
56
|
return cmdOps(args);
|
|
56
57
|
|
|
58
|
+
case 'chat':
|
|
59
|
+
return cmdChat(args);
|
|
60
|
+
|
|
57
61
|
case 'google':
|
|
58
62
|
return cmdGoogle(args);
|
|
59
63
|
|
|
@@ -351,6 +355,7 @@ function cmdHelp() {
|
|
|
351
355
|
console.log(` run "prompt" ${D}--agents saber,zero${NC} Collaborate with specific agents\n`);
|
|
352
356
|
|
|
353
357
|
console.log(` ${C}Daily Operations${NC} ${D}(Gmail + Calendar + Tasks)${NC}`);
|
|
358
|
+
console.log(` chat Interactive chat — manage email/calendar/tasks naturally`);
|
|
354
359
|
console.log(` plan Generate daily plan (5 agents analyze your day)`);
|
|
355
360
|
console.log(` plan --refresh Regenerate today's plan`);
|
|
356
361
|
console.log(` tasks List today's tasks`);
|
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nha chat — Interactive conversational REPL for PAO (Personal Agent Ops).
|
|
3
|
+
*
|
|
4
|
+
* The user types natural language; an LLM interprets intent, optionally
|
|
5
|
+
* invokes Gmail / Calendar / Tasks APIs via a structured JSON action protocol,
|
|
6
|
+
* and responds conversationally.
|
|
7
|
+
*
|
|
8
|
+
* Zero npm dependencies — Node.js 22 built-in readline only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import readline from 'readline';
|
|
12
|
+
import { loadConfig } from '../config.mjs';
|
|
13
|
+
import { callLLM } from '../services/llm.mjs';
|
|
14
|
+
import { fail, info, ok, warn, C, G, Y, D, W, BOLD, NC, M, R, B } from '../ui.mjs';
|
|
15
|
+
|
|
16
|
+
// ── Gmail imports ────────────────────────────────────────────────────────────
|
|
17
|
+
import {
|
|
18
|
+
listMessages,
|
|
19
|
+
getMessage,
|
|
20
|
+
getUnreadImportant,
|
|
21
|
+
sendEmail,
|
|
22
|
+
createDraft,
|
|
23
|
+
} from '../services/google-gmail.mjs';
|
|
24
|
+
|
|
25
|
+
// ── Calendar imports ─────────────────────────────────────────────────────────
|
|
26
|
+
import {
|
|
27
|
+
getTodayEvents,
|
|
28
|
+
getUpcomingEvents,
|
|
29
|
+
getEventsForDate,
|
|
30
|
+
createEvent,
|
|
31
|
+
updateEvent,
|
|
32
|
+
} from '../services/google-calendar.mjs';
|
|
33
|
+
|
|
34
|
+
// ── Task imports ─────────────────────────────────────────────────────────────
|
|
35
|
+
import {
|
|
36
|
+
getTasks,
|
|
37
|
+
addTask,
|
|
38
|
+
completeTask,
|
|
39
|
+
moveTask,
|
|
40
|
+
} from '../services/task-store.mjs';
|
|
41
|
+
|
|
42
|
+
// ── Notification imports ─────────────────────────────────────────────────────
|
|
43
|
+
import { notify } from '../services/notification.mjs';
|
|
44
|
+
|
|
45
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const MAX_HISTORY = 20;
|
|
48
|
+
|
|
49
|
+
/** Actions that mutate external state and require user confirmation. */
|
|
50
|
+
const DESTRUCTIVE_ACTIONS = new Set([
|
|
51
|
+
'gmail_send',
|
|
52
|
+
'gmail_reply',
|
|
53
|
+
'calendar_create',
|
|
54
|
+
'calendar_move',
|
|
55
|
+
'task_done',
|
|
56
|
+
'notify_remind',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// ── Tool Definitions (for system prompt) ─────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const TOOL_DEFINITIONS = `
|
|
62
|
+
You have access to the following tools. When the user's message requires an action,
|
|
63
|
+
output EXACTLY ONE fenced JSON block per action:
|
|
64
|
+
|
|
65
|
+
\`\`\`json
|
|
66
|
+
{"action": "<tool_name>", "params": { ... }}
|
|
67
|
+
\`\`\`
|
|
68
|
+
|
|
69
|
+
You may include conversational text BEFORE or AFTER the JSON block. If no action
|
|
70
|
+
is needed, respond normally without any JSON block.
|
|
71
|
+
|
|
72
|
+
TOOLS:
|
|
73
|
+
|
|
74
|
+
1. gmail_list(query: string, maxResults?: number)
|
|
75
|
+
Search emails. query uses Gmail search syntax (e.g. "from:boss@co.com", "is:unread subject:invoice").
|
|
76
|
+
Default maxResults = 10.
|
|
77
|
+
|
|
78
|
+
2. gmail_read(messageId: string)
|
|
79
|
+
Read the full body of an email by its ID (returned by gmail_list).
|
|
80
|
+
|
|
81
|
+
3. gmail_send(to: string, subject: string, body: string)
|
|
82
|
+
Send an email. ALWAYS confirm with the user before sending.
|
|
83
|
+
|
|
84
|
+
4. gmail_draft(to: string, subject: string, body: string)
|
|
85
|
+
Create a draft email (safe — does not send).
|
|
86
|
+
|
|
87
|
+
5. gmail_reply(messageId: string, body: string)
|
|
88
|
+
Reply to an existing email thread. ALWAYS confirm before sending.
|
|
89
|
+
|
|
90
|
+
6. calendar_today()
|
|
91
|
+
List all events for today.
|
|
92
|
+
|
|
93
|
+
7. calendar_tomorrow()
|
|
94
|
+
List all events for tomorrow.
|
|
95
|
+
|
|
96
|
+
8. calendar_upcoming(hours?: number)
|
|
97
|
+
List upcoming events in the next N hours (default 2).
|
|
98
|
+
|
|
99
|
+
9. calendar_create(summary: string, start: string, end: string, attendees?: string[], description?: string)
|
|
100
|
+
Create a calendar event. start/end are ISO 8601 datetime strings.
|
|
101
|
+
ALWAYS confirm with the user before creating.
|
|
102
|
+
|
|
103
|
+
10. calendar_move(eventId: string, newStart: string, newEnd: string)
|
|
104
|
+
Reschedule an event. ALWAYS confirm before moving.
|
|
105
|
+
|
|
106
|
+
11. task_list()
|
|
107
|
+
List today's tasks.
|
|
108
|
+
|
|
109
|
+
12. task_add(description: string, priority?: "low"|"medium"|"high"|"critical", due?: string)
|
|
110
|
+
Add a new task for today.
|
|
111
|
+
|
|
112
|
+
13. task_done(id: number)
|
|
113
|
+
Mark a task as completed. Confirm before completing.
|
|
114
|
+
|
|
115
|
+
14. task_move(id: number, toDate: string)
|
|
116
|
+
Move a task to another date (YYYY-MM-DD).
|
|
117
|
+
|
|
118
|
+
15. notify_remind(message: string, atTime: string)
|
|
119
|
+
Set a desktop reminder. atTime is ISO 8601 or relative like "in 30 minutes".
|
|
120
|
+
|
|
121
|
+
RULES:
|
|
122
|
+
- For search/read operations, execute immediately and present results conversationally.
|
|
123
|
+
- 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.
|
|
124
|
+
- When presenting email results, show From, Subject, Date, and a brief snippet. Never dump raw JSON.
|
|
125
|
+
- When presenting calendar events, show Time, Title, Location/Link. Format times in a human-readable way.
|
|
126
|
+
- When presenting tasks, show ID, Description, Priority, Status.
|
|
127
|
+
- 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.
|
|
128
|
+
- Dates: today is {{TODAY}}. Infer relative dates from this.
|
|
129
|
+
- The user's timezone is {{TIMEZONE}}.
|
|
130
|
+
`.trim();
|
|
131
|
+
|
|
132
|
+
// ── Tool Executor ────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Execute a parsed tool action and return a human-readable result string.
|
|
136
|
+
* @param {string} action — tool name
|
|
137
|
+
* @param {object} params — tool parameters
|
|
138
|
+
* @param {object} config — NHA config
|
|
139
|
+
* @returns {Promise<string>} result description
|
|
140
|
+
*/
|
|
141
|
+
async function executeTool(action, params, config) {
|
|
142
|
+
switch (action) {
|
|
143
|
+
// ── Gmail ──────────────────────────────────────────────────────────────
|
|
144
|
+
case 'gmail_list': {
|
|
145
|
+
const query = params.query || 'is:unread';
|
|
146
|
+
const max = params.maxResults || 10;
|
|
147
|
+
const refs = await listMessages(config, query, max);
|
|
148
|
+
if (refs.length === 0) return 'No emails found matching that query.';
|
|
149
|
+
|
|
150
|
+
const messages = [];
|
|
151
|
+
for (const ref of refs.slice(0, max)) {
|
|
152
|
+
try {
|
|
153
|
+
const msg = await getMessage(config, ref.id);
|
|
154
|
+
messages.push(msg);
|
|
155
|
+
} catch { /* skip failed */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return messages.map((m, i) =>
|
|
159
|
+
`${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject} | Date: ${m.date}\n ${m.snippet.slice(0, 120)}`
|
|
160
|
+
).join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'gmail_read': {
|
|
164
|
+
const msg = await getMessage(config, params.messageId);
|
|
165
|
+
return [
|
|
166
|
+
`From: ${msg.from}`,
|
|
167
|
+
`To: ${msg.to}`,
|
|
168
|
+
`Subject: ${msg.subject}`,
|
|
169
|
+
`Date: ${msg.date}`,
|
|
170
|
+
`---`,
|
|
171
|
+
msg.body.slice(0, 4000),
|
|
172
|
+
].join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'gmail_send': {
|
|
176
|
+
await sendEmail(config, params.to, params.subject, params.body);
|
|
177
|
+
return `Email sent to ${params.to} with subject "${params.subject}".`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'gmail_draft': {
|
|
181
|
+
await createDraft(config, params.to, params.subject, params.body);
|
|
182
|
+
return `Draft created for ${params.to} with subject "${params.subject}".`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case 'gmail_reply': {
|
|
186
|
+
const original = await getMessage(config, params.messageId);
|
|
187
|
+
await sendEmail(config, original.from, `Re: ${original.subject}`, params.body, {
|
|
188
|
+
replyToMessageId: original.id,
|
|
189
|
+
threadId: original.threadId,
|
|
190
|
+
});
|
|
191
|
+
return `Reply sent to ${original.from} on thread "${original.subject}".`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Calendar ──────────────────────────────────────────────────────────
|
|
195
|
+
case 'calendar_today': {
|
|
196
|
+
const events = await getTodayEvents(config);
|
|
197
|
+
if (events.length === 0) return 'No events scheduled for today.';
|
|
198
|
+
return formatEvents(events);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'calendar_tomorrow': {
|
|
202
|
+
const tomorrow = new Date();
|
|
203
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
204
|
+
const dateStr = tomorrow.toISOString().split('T')[0];
|
|
205
|
+
const events = await getEventsForDate(config, dateStr);
|
|
206
|
+
if (events.length === 0) return 'No events scheduled for tomorrow.';
|
|
207
|
+
return formatEvents(events);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case 'calendar_upcoming': {
|
|
211
|
+
const hours = params.hours || 2;
|
|
212
|
+
const events = await getUpcomingEvents(config, hours);
|
|
213
|
+
if (events.length === 0) return `No events in the next ${hours} hour(s).`;
|
|
214
|
+
return formatEvents(events);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'calendar_create': {
|
|
218
|
+
const result = await createEvent(config, {
|
|
219
|
+
summary: params.summary,
|
|
220
|
+
start: params.start,
|
|
221
|
+
end: params.end,
|
|
222
|
+
description: params.description || '',
|
|
223
|
+
attendees: params.attendees || [],
|
|
224
|
+
});
|
|
225
|
+
return `Event "${params.summary}" created for ${formatTime(params.start)} - ${formatTime(params.end)}.`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case 'calendar_move': {
|
|
229
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
230
|
+
await updateEvent(config, 'primary', params.eventId, {
|
|
231
|
+
start: { dateTime: new Date(params.newStart).toISOString(), timeZone: tz },
|
|
232
|
+
end: { dateTime: new Date(params.newEnd).toISOString(), timeZone: tz },
|
|
233
|
+
});
|
|
234
|
+
return `Event rescheduled to ${formatTime(params.newStart)} - ${formatTime(params.newEnd)}.`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Tasks ─────────────────────────────────────────────────────────────
|
|
238
|
+
case 'task_list': {
|
|
239
|
+
const tasks = getTasks();
|
|
240
|
+
if (tasks.length === 0) return 'No tasks for today.';
|
|
241
|
+
return tasks.map(t =>
|
|
242
|
+
`#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}${t.due ? ' (due: ' + t.due + ')' : ''}`
|
|
243
|
+
).join('\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'task_add': {
|
|
247
|
+
const task = addTask({
|
|
248
|
+
description: params.description,
|
|
249
|
+
priority: params.priority || 'medium',
|
|
250
|
+
due: params.due || null,
|
|
251
|
+
source: 'chat',
|
|
252
|
+
});
|
|
253
|
+
return `Task #${task.id} added: "${task.description}" [${task.priority}]`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case 'task_done': {
|
|
257
|
+
const success = completeTask(params.id);
|
|
258
|
+
return success ? `Task #${params.id} marked as done.` : `Task #${params.id} not found.`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case 'task_move': {
|
|
262
|
+
const todayStr = new Date().toISOString().split('T')[0];
|
|
263
|
+
const success = moveTask(params.id, todayStr, params.toDate);
|
|
264
|
+
return success ? `Task #${params.id} moved to ${params.toDate}.` : `Task #${params.id} not found.`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Notifications ─────────────────────────────────────────────────────
|
|
268
|
+
case 'notify_remind': {
|
|
269
|
+
const atTime = resolveTime(params.atTime);
|
|
270
|
+
const delayMs = atTime.getTime() - Date.now();
|
|
271
|
+
|
|
272
|
+
if (delayMs <= 0) {
|
|
273
|
+
await notify('Reminder', params.message, config);
|
|
274
|
+
return `Reminder sent now: "${params.message}"`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Schedule in-process timer (will only persist while the REPL is running)
|
|
278
|
+
setTimeout(async () => {
|
|
279
|
+
try {
|
|
280
|
+
await notify('Reminder', params.message, config);
|
|
281
|
+
} catch { /* best effort */ }
|
|
282
|
+
}, Math.min(delayMs, 86400000)); // cap at 24h
|
|
283
|
+
|
|
284
|
+
const minutes = Math.round(delayMs / 60000);
|
|
285
|
+
return `Reminder set for ${formatTime(atTime.toISOString())} (in ~${minutes} min): "${params.message}"`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
default:
|
|
289
|
+
return `Unknown action: ${action}`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Formatting Helpers ───────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
function formatEvents(events) {
|
|
296
|
+
return events.map((e, i) => {
|
|
297
|
+
const time = e.isAllDay ? 'All day' : `${formatTime(e.start)} - ${formatTime(e.end)}`;
|
|
298
|
+
const location = e.location ? ` | ${e.location}` : '';
|
|
299
|
+
const link = e.hangoutLink ? ` | ${e.hangoutLink}` : '';
|
|
300
|
+
const cal = e.calendarName ? ` (${e.calendarName})` : '';
|
|
301
|
+
return `${i + 1}. ${time} — ${e.summary}${location}${link}${cal}`;
|
|
302
|
+
}).join('\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatTime(isoStr) {
|
|
306
|
+
try {
|
|
307
|
+
const d = new Date(isoStr);
|
|
308
|
+
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
309
|
+
} catch {
|
|
310
|
+
return isoStr;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Resolve a time string into a Date. Supports ISO 8601 and relative formats
|
|
316
|
+
* like "in 30 minutes", "in 2 hours".
|
|
317
|
+
*/
|
|
318
|
+
function resolveTime(timeStr) {
|
|
319
|
+
// Try ISO parse first
|
|
320
|
+
const direct = new Date(timeStr);
|
|
321
|
+
if (!isNaN(direct.getTime()) && timeStr.includes('T')) return direct;
|
|
322
|
+
|
|
323
|
+
// Relative: "in X minutes/hours"
|
|
324
|
+
const relMatch = timeStr.match(/in\s+(\d+)\s+(minute|min|hour|hr)s?/i);
|
|
325
|
+
if (relMatch) {
|
|
326
|
+
const amount = parseInt(relMatch[1], 10);
|
|
327
|
+
const unit = relMatch[2].toLowerCase();
|
|
328
|
+
const ms = unit.startsWith('h') ? amount * 3600000 : amount * 60000;
|
|
329
|
+
return new Date(Date.now() + ms);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Try time-only like "14:30" or "2:30 PM" — assume today
|
|
333
|
+
const today = new Date();
|
|
334
|
+
const timeOnly = new Date(`${today.toISOString().split('T')[0]}T${timeStr}`);
|
|
335
|
+
if (!isNaN(timeOnly.getTime())) return timeOnly;
|
|
336
|
+
|
|
337
|
+
// Fallback: now + 30 min
|
|
338
|
+
return new Date(Date.now() + 1800000);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Action Parser ────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Extract JSON action blocks from LLM response text.
|
|
345
|
+
* Supports ```json ... ``` fenced blocks.
|
|
346
|
+
* @param {string} text
|
|
347
|
+
* @returns {{ textParts: string[], actions: Array<{action: string, params: object}> }}
|
|
348
|
+
*/
|
|
349
|
+
function parseActions(text) {
|
|
350
|
+
const actions = [];
|
|
351
|
+
const textParts = [];
|
|
352
|
+
let remaining = text;
|
|
353
|
+
|
|
354
|
+
const fenceRegex = /```json\s*\n?([\s\S]*?)```/g;
|
|
355
|
+
let lastIndex = 0;
|
|
356
|
+
let match;
|
|
357
|
+
|
|
358
|
+
while ((match = fenceRegex.exec(remaining)) !== null) {
|
|
359
|
+
// Capture text before this block
|
|
360
|
+
const before = remaining.slice(lastIndex, match.index).trim();
|
|
361
|
+
if (before) textParts.push(before);
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(match[1].trim());
|
|
365
|
+
if (parsed.action && typeof parsed.action === 'string') {
|
|
366
|
+
actions.push({ action: parsed.action, params: parsed.params || {} });
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Not valid JSON — treat as regular text
|
|
370
|
+
textParts.push(match[0]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
lastIndex = match.index + match[0].length;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Capture trailing text
|
|
377
|
+
const trailing = remaining.slice(lastIndex).trim();
|
|
378
|
+
if (trailing) textParts.push(trailing);
|
|
379
|
+
|
|
380
|
+
return { textParts, actions };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── System Prompt Builder ────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function buildSystemPrompt(initialContext) {
|
|
386
|
+
const today = new Date().toISOString().split('T')[0];
|
|
387
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
388
|
+
|
|
389
|
+
let prompt = TOOL_DEFINITIONS
|
|
390
|
+
.replace('{{TODAY}}', today)
|
|
391
|
+
.replace('{{TIMEZONE}}', tz);
|
|
392
|
+
|
|
393
|
+
prompt += `\n\nYou are NHA Chat, a personal operations assistant inside the NotHumanAllowed CLI. ` +
|
|
394
|
+
`You help the user manage their emails, calendar, and tasks through natural conversation. ` +
|
|
395
|
+
`Be concise, helpful, and proactive. When presenting data, format it clearly. ` +
|
|
396
|
+
`Never output raw JSON to the user — always wrap results in natural language.`;
|
|
397
|
+
|
|
398
|
+
if (initialContext) {
|
|
399
|
+
prompt += `\n\n--- CURRENT CONTEXT (fetched at session start) ---\n${initialContext}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return prompt;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Serialize conversation history into a single user message string.
|
|
407
|
+
* Each turn is prefixed with [User] or [Assistant] to maintain role clarity.
|
|
408
|
+
*/
|
|
409
|
+
function serializeHistory(history, currentMessage) {
|
|
410
|
+
const parts = [];
|
|
411
|
+
|
|
412
|
+
for (const turn of history) {
|
|
413
|
+
const prefix = turn.role === 'user' ? '[User]' : '[Assistant]';
|
|
414
|
+
parts.push(`${prefix} ${turn.content}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
parts.push(`[User] ${currentMessage}`);
|
|
418
|
+
return parts.join('\n\n');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Confirmation Prompt ──────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Ask the user for y/n confirmation before executing a destructive action.
|
|
425
|
+
* @param {readline.Interface} rl
|
|
426
|
+
* @param {string} description
|
|
427
|
+
* @returns {Promise<boolean>}
|
|
428
|
+
*/
|
|
429
|
+
function askConfirmation(rl, description) {
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
rl.question(` ${Y}?${NC} ${description} ${D}[y/n]${NC} `, (answer) => {
|
|
432
|
+
resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── Initial Context Fetcher ──────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
async function fetchInitialContext(config) {
|
|
440
|
+
const parts = [];
|
|
441
|
+
|
|
442
|
+
// Today's events (best effort)
|
|
443
|
+
try {
|
|
444
|
+
const events = await getTodayEvents(config);
|
|
445
|
+
if (events.length > 0) {
|
|
446
|
+
parts.push(`TODAY'S CALENDAR (${events.length} events):\n` + formatEvents(events));
|
|
447
|
+
} else {
|
|
448
|
+
parts.push('TODAY\'S CALENDAR: No events scheduled.');
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
parts.push('CALENDAR: Could not fetch (Google not connected?).');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Unread emails (best effort)
|
|
455
|
+
try {
|
|
456
|
+
const emails = await getUnreadImportant(config, 10);
|
|
457
|
+
if (emails.length > 0) {
|
|
458
|
+
parts.push(`UNREAD EMAILS (${emails.length}):\n` +
|
|
459
|
+
emails.map((m, i) =>
|
|
460
|
+
`${i + 1}. [${m.id}] From: ${m.from} | Subject: ${m.subject}\n ${m.snippet.slice(0, 100)}`
|
|
461
|
+
).join('\n'));
|
|
462
|
+
} else {
|
|
463
|
+
parts.push('UNREAD EMAILS: Inbox zero!');
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
parts.push('EMAIL: Could not fetch (Google not connected?).');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Today's tasks
|
|
470
|
+
try {
|
|
471
|
+
const tasks = getTasks();
|
|
472
|
+
if (tasks.length > 0) {
|
|
473
|
+
parts.push(`TODAY'S TASKS (${tasks.length}):\n` +
|
|
474
|
+
tasks.map(t => `#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}`).join('\n'));
|
|
475
|
+
} else {
|
|
476
|
+
parts.push('TODAY\'S TASKS: None yet.');
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
parts.push('TASKS: Could not load.');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return parts.join('\n\n');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Slash Command Handlers ───────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Handle REPL-local slash commands. Returns true if handled, false to pass through.
|
|
489
|
+
*/
|
|
490
|
+
async function handleSlashCommand(input, config, history) {
|
|
491
|
+
const trimmed = input.trim();
|
|
492
|
+
|
|
493
|
+
if (trimmed === '/quit' || trimmed === '/exit' || trimmed === '/q') {
|
|
494
|
+
console.log(`\n ${D}Session ended. Goodbye.${NC}\n`);
|
|
495
|
+
process.exit(0);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (trimmed === '/clear') {
|
|
499
|
+
history.length = 0;
|
|
500
|
+
console.clear();
|
|
501
|
+
console.log(` ${G}Conversation cleared.${NC}`);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (trimmed === '/tasks') {
|
|
506
|
+
try {
|
|
507
|
+
const tasks = getTasks();
|
|
508
|
+
if (tasks.length === 0) {
|
|
509
|
+
console.log(` ${D}No tasks for today.${NC}`);
|
|
510
|
+
} else {
|
|
511
|
+
for (const t of tasks) {
|
|
512
|
+
const icon = t.status === 'done' ? `${G}[done]${NC}` : `${Y}[${t.priority}]${NC}`;
|
|
513
|
+
console.log(` ${icon} #${t.id} ${t.description}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
} catch (err) {
|
|
517
|
+
console.log(` ${R}Could not load tasks: ${err.message}${NC}`);
|
|
518
|
+
}
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (trimmed === '/plan') {
|
|
523
|
+
try {
|
|
524
|
+
const { cmdPlan } = await import('./plan.mjs');
|
|
525
|
+
await cmdPlan([]);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
console.log(` ${R}Plan error: ${err.message}${NC}`);
|
|
528
|
+
}
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (trimmed === '/help') {
|
|
533
|
+
console.log(`
|
|
534
|
+
${BOLD}Chat Commands${NC}
|
|
535
|
+
|
|
536
|
+
${C}/tasks${NC} Show today's tasks
|
|
537
|
+
${C}/plan${NC} Run daily planner
|
|
538
|
+
${C}/clear${NC} Clear conversation history
|
|
539
|
+
${C}/help${NC} Show this help
|
|
540
|
+
${C}/quit${NC} Exit chat
|
|
541
|
+
|
|
542
|
+
${D}Otherwise, just type naturally — the AI understands
|
|
543
|
+
requests like "show my unread emails", "add a task to review PR #42",
|
|
544
|
+
"what's on my calendar tomorrow?", etc.${NC}
|
|
545
|
+
`);
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── Main REPL ────────────────────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
export async function cmdChat(args) {
|
|
555
|
+
const config = loadConfig();
|
|
556
|
+
|
|
557
|
+
if (!config.llm.apiKey) {
|
|
558
|
+
fail('No API key configured. Run: nha config set key YOUR_KEY');
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── Greeting ────────────────────────────────────────────────────────────
|
|
563
|
+
console.log(`
|
|
564
|
+
${BOLD}${C}NHA Chat${NC} ${D}— Personal Operations Assistant${NC}
|
|
565
|
+
${D}Type naturally to manage emails, calendar, and tasks.${NC}
|
|
566
|
+
${D}Commands: /tasks /plan /clear /help /quit${NC}
|
|
567
|
+
`);
|
|
568
|
+
|
|
569
|
+
// ── Fetch initial context (non-blocking display) ────────────────────────
|
|
570
|
+
info('Loading today\'s context...');
|
|
571
|
+
let initialContext = '';
|
|
572
|
+
try {
|
|
573
|
+
initialContext = await fetchInitialContext(config);
|
|
574
|
+
ok('Context loaded (calendar, email, tasks).');
|
|
575
|
+
} catch {
|
|
576
|
+
warn('Could not load full context. Google may not be connected.');
|
|
577
|
+
warn('Run "nha google auth" to connect Gmail + Calendar.');
|
|
578
|
+
}
|
|
579
|
+
console.log('');
|
|
580
|
+
|
|
581
|
+
// ── Readline setup ──────────────────────────────────────────────────────
|
|
582
|
+
const rl = readline.createInterface({
|
|
583
|
+
input: process.stdin,
|
|
584
|
+
output: process.stdout,
|
|
585
|
+
prompt: `${C}NHA>${NC} `,
|
|
586
|
+
terminal: true,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const history = [];
|
|
590
|
+
const systemPrompt = buildSystemPrompt(initialContext);
|
|
591
|
+
|
|
592
|
+
// ── Graceful exit ───────────────────────────────────────────────────────
|
|
593
|
+
rl.on('close', () => {
|
|
594
|
+
console.log(`\n ${D}Session ended. Goodbye.${NC}\n`);
|
|
595
|
+
process.exit(0);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Handle SIGINT (Ctrl+C) gracefully — first press clears line, second exits
|
|
599
|
+
let pendingExit = false;
|
|
600
|
+
process.on('SIGINT', () => {
|
|
601
|
+
if (pendingExit) {
|
|
602
|
+
console.log(`\n ${D}Goodbye.${NC}\n`);
|
|
603
|
+
process.exit(0);
|
|
604
|
+
}
|
|
605
|
+
pendingExit = true;
|
|
606
|
+
console.log(`\n ${D}Press Ctrl+C again to exit, or type to continue.${NC}`);
|
|
607
|
+
rl.prompt();
|
|
608
|
+
setTimeout(() => { pendingExit = false; }, 3000);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// ── REPL Loop ───────────────────────────────────────────────────────────
|
|
612
|
+
rl.prompt();
|
|
613
|
+
|
|
614
|
+
for await (const rawLine of rl) {
|
|
615
|
+
const input = rawLine.trim();
|
|
616
|
+
|
|
617
|
+
// Skip empty lines
|
|
618
|
+
if (!input) {
|
|
619
|
+
rl.prompt();
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Handle slash commands
|
|
624
|
+
if (input.startsWith('/')) {
|
|
625
|
+
const handled = await handleSlashCommand(input, config, history);
|
|
626
|
+
if (handled) {
|
|
627
|
+
rl.prompt();
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ── LLM call ────────────────────────────────────────────────────────
|
|
633
|
+
try {
|
|
634
|
+
const userMessage = serializeHistory(history, input);
|
|
635
|
+
|
|
636
|
+
process.stdout.write(`\n ${D}Thinking...${NC}`);
|
|
637
|
+
const response = await callLLM(config, systemPrompt, userMessage);
|
|
638
|
+
// Clear the "Thinking..." indicator
|
|
639
|
+
process.stdout.write('\r' + ' '.repeat(40) + '\r');
|
|
640
|
+
|
|
641
|
+
// Parse response for actions
|
|
642
|
+
const { textParts, actions } = parseActions(response);
|
|
643
|
+
|
|
644
|
+
// Display conversational text
|
|
645
|
+
if (textParts.length > 0) {
|
|
646
|
+
const text = textParts.join('\n\n');
|
|
647
|
+
console.log(`\n ${W}${text}${NC}\n`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Execute actions
|
|
651
|
+
for (const { action, params } of actions) {
|
|
652
|
+
const isDestructive = DESTRUCTIVE_ACTIONS.has(action);
|
|
653
|
+
|
|
654
|
+
if (isDestructive) {
|
|
655
|
+
// Build a human-readable description of what will happen
|
|
656
|
+
const desc = describeAction(action, params);
|
|
657
|
+
const confirmed = await askConfirmation(rl, desc);
|
|
658
|
+
|
|
659
|
+
if (!confirmed) {
|
|
660
|
+
console.log(` ${D}Cancelled.${NC}\n`);
|
|
661
|
+
history.push({ role: 'user', content: input });
|
|
662
|
+
history.push({ role: 'assistant', content: response + '\n[User cancelled this action]' });
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Execute the tool
|
|
668
|
+
try {
|
|
669
|
+
process.stdout.write(` ${D}Executing ${action}...${NC}`);
|
|
670
|
+
const result = await executeTool(action, params, config);
|
|
671
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
672
|
+
console.log(` ${G}Result:${NC}\n ${result.split('\n').join('\n ')}\n`);
|
|
673
|
+
|
|
674
|
+
// Feed result back into history so the LLM knows what happened
|
|
675
|
+
history.push({ role: 'user', content: input });
|
|
676
|
+
history.push({
|
|
677
|
+
role: 'assistant',
|
|
678
|
+
content: response + `\n\n[Tool ${action} executed. Result: ${result}]`,
|
|
679
|
+
});
|
|
680
|
+
} catch (err) {
|
|
681
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
682
|
+
console.log(` ${R}Error executing ${action}: ${err.message}${NC}\n`);
|
|
683
|
+
history.push({ role: 'user', content: input });
|
|
684
|
+
history.push({
|
|
685
|
+
role: 'assistant',
|
|
686
|
+
content: response + `\n\n[Tool ${action} failed: ${err.message}]`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// If no actions, just record the conversation turn
|
|
692
|
+
if (actions.length === 0) {
|
|
693
|
+
history.push({ role: 'user', content: input });
|
|
694
|
+
history.push({ role: 'assistant', content: response });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Trim history to MAX_HISTORY turns (pairs of user+assistant)
|
|
698
|
+
while (history.length > MAX_HISTORY * 2) {
|
|
699
|
+
history.shift();
|
|
700
|
+
history.shift();
|
|
701
|
+
}
|
|
702
|
+
} catch (err) {
|
|
703
|
+
process.stdout.write('\r' + ' '.repeat(40) + '\r');
|
|
704
|
+
console.log(`\n ${R}LLM error: ${err.message}${NC}\n`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
rl.prompt();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── Action Descriptor (for confirmation prompts) ─────────────────────────────
|
|
712
|
+
|
|
713
|
+
function describeAction(action, params) {
|
|
714
|
+
switch (action) {
|
|
715
|
+
case 'gmail_send':
|
|
716
|
+
return `Send email to ${params.to} — Subject: "${params.subject}"`;
|
|
717
|
+
case 'gmail_reply':
|
|
718
|
+
return `Reply to message ${params.messageId}`;
|
|
719
|
+
case 'calendar_create':
|
|
720
|
+
return `Create event "${params.summary}" at ${formatTime(params.start)}`;
|
|
721
|
+
case 'calendar_move':
|
|
722
|
+
return `Reschedule event ${params.eventId} to ${formatTime(params.newStart)}`;
|
|
723
|
+
case 'task_done':
|
|
724
|
+
return `Mark task #${params.id} as completed`;
|
|
725
|
+
case 'notify_remind':
|
|
726
|
+
return `Set reminder: "${params.message}" at ${params.atTime}`;
|
|
727
|
+
default:
|
|
728
|
+
return `Execute ${action}`;
|
|
729
|
+
}
|
|
730
|
+
}
|
package/src/constants.mjs
CHANGED