nothumanallowed 9.0.5 → 9.1.1

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/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '9.0.5';
8
+ export const VERSION = '9.1.1';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Multi-conversation manager for NHA Chat.
3
+ *
4
+ * Stores conversations in ~/.nha/conversations/ as JSON files.
5
+ * Each conversation has an ID, auto-generated title, and message history.
6
+ *
7
+ * Zero npm dependencies.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import crypto from 'crypto';
13
+ import { NHA_DIR } from '../constants.mjs';
14
+
15
+ const CONVERSATIONS_DIR = path.join(NHA_DIR, 'conversations');
16
+ const ACTIVE_FILE = path.join(CONVERSATIONS_DIR, '.active');
17
+ const MAX_TITLE_LENGTH = 60;
18
+ const MAX_CONVERSATIONS = 100;
19
+
20
+ // ── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ function ensureDir() {
23
+ fs.mkdirSync(CONVERSATIONS_DIR, { recursive: true });
24
+ }
25
+
26
+ function convPath(id) {
27
+ return path.join(CONVERSATIONS_DIR, `${id}.json`);
28
+ }
29
+
30
+ function generateId() {
31
+ return crypto.randomUUID().slice(0, 8);
32
+ }
33
+
34
+ /**
35
+ * Auto-generate a title from the first user message.
36
+ * Takes the first ~50 chars, trims to last word boundary.
37
+ */
38
+ function autoTitle(firstMessage) {
39
+ if (!firstMessage) return 'New Chat';
40
+ let title = firstMessage.replace(/\s+/g, ' ').trim();
41
+ if (title.length > MAX_TITLE_LENGTH) {
42
+ title = title.slice(0, MAX_TITLE_LENGTH);
43
+ const lastSpace = title.lastIndexOf(' ');
44
+ if (lastSpace > 20) title = title.slice(0, lastSpace);
45
+ title += '...';
46
+ }
47
+ return title;
48
+ }
49
+
50
+ // ── CRUD ─────────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Create a new conversation and set it as active.
54
+ * @returns {{ id: string, title: string, messages: Array, createdAt: string, updatedAt: string }}
55
+ */
56
+ export function createConversation(title = '') {
57
+ ensureDir();
58
+ const id = generateId();
59
+ const now = new Date().toISOString();
60
+ const conv = {
61
+ id,
62
+ title: title || 'New Chat',
63
+ messages: [],
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ };
67
+ fs.writeFileSync(convPath(id), JSON.stringify(conv, null, 2) + '\n', 'utf-8');
68
+ setActiveId(id);
69
+ return conv;
70
+ }
71
+
72
+ /**
73
+ * Load a conversation by ID.
74
+ * @returns {object|null}
75
+ */
76
+ export function loadConversation(id) {
77
+ try {
78
+ const data = fs.readFileSync(convPath(id), 'utf-8');
79
+ return JSON.parse(data);
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Save a conversation (full overwrite).
87
+ */
88
+ export function saveConversation(conv) {
89
+ ensureDir();
90
+ conv.updatedAt = new Date().toISOString();
91
+ fs.writeFileSync(convPath(conv.id), JSON.stringify(conv, null, 2) + '\n', 'utf-8');
92
+ }
93
+
94
+ /**
95
+ * Delete a conversation by ID.
96
+ * If it was active, clears active state.
97
+ * @returns {boolean} true if deleted
98
+ */
99
+ export function deleteConversation(id) {
100
+ const filePath = convPath(id);
101
+ if (!fs.existsSync(filePath)) return false;
102
+ fs.unlinkSync(filePath);
103
+ if (getActiveId() === id) {
104
+ clearActiveId();
105
+ }
106
+ return true;
107
+ }
108
+
109
+ /**
110
+ * List all conversations, sorted by updatedAt (newest first).
111
+ * Returns summary objects (no messages).
112
+ * @returns {Array<{ id: string, title: string, messageCount: number, createdAt: string, updatedAt: string }>}
113
+ */
114
+ export function listConversations() {
115
+ ensureDir();
116
+ const files = fs.readdirSync(CONVERSATIONS_DIR)
117
+ .filter(f => f.endsWith('.json') && !f.startsWith('.'));
118
+
119
+ const convs = [];
120
+ for (const f of files) {
121
+ try {
122
+ const data = JSON.parse(fs.readFileSync(path.join(CONVERSATIONS_DIR, f), 'utf-8'));
123
+ convs.push({
124
+ id: data.id,
125
+ title: data.title || 'Untitled',
126
+ messageCount: (data.messages || []).length,
127
+ createdAt: data.createdAt,
128
+ updatedAt: data.updatedAt,
129
+ });
130
+ } catch { /* skip corrupt files */ }
131
+ }
132
+
133
+ convs.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
134
+
135
+ // Auto-prune old conversations beyond limit
136
+ if (convs.length > MAX_CONVERSATIONS) {
137
+ for (const old of convs.slice(MAX_CONVERSATIONS)) {
138
+ try { fs.unlinkSync(convPath(old.id)); } catch {}
139
+ }
140
+ return convs.slice(0, MAX_CONVERSATIONS);
141
+ }
142
+
143
+ return convs;
144
+ }
145
+
146
+ // ── Active Conversation ──────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Get the active conversation ID.
150
+ * @returns {string|null}
151
+ */
152
+ export function getActiveId() {
153
+ try {
154
+ return fs.readFileSync(ACTIVE_FILE, 'utf-8').trim() || null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Set the active conversation ID.
162
+ */
163
+ export function setActiveId(id) {
164
+ ensureDir();
165
+ fs.writeFileSync(ACTIVE_FILE, id, 'utf-8');
166
+ }
167
+
168
+ /**
169
+ * Clear the active conversation.
170
+ */
171
+ export function clearActiveId() {
172
+ try { fs.unlinkSync(ACTIVE_FILE); } catch {}
173
+ }
174
+
175
+ /**
176
+ * Get or create the active conversation.
177
+ * If none exists, creates a new one.
178
+ * @returns {object} conversation object
179
+ */
180
+ export function getOrCreateActive() {
181
+ const activeId = getActiveId();
182
+ if (activeId) {
183
+ const conv = loadConversation(activeId);
184
+ if (conv) return conv;
185
+ }
186
+ return createConversation();
187
+ }
188
+
189
+ /**
190
+ * Add a message pair (user + assistant) to a conversation.
191
+ * Auto-titles the conversation from the first user message.
192
+ */
193
+ export function addMessages(conv, userContent, assistantContent) {
194
+ conv.messages.push({ role: 'user', content: userContent });
195
+ conv.messages.push({ role: 'assistant', content: assistantContent });
196
+
197
+ // Auto-title from first user message
198
+ if (conv.title === 'New Chat' && conv.messages.length === 2) {
199
+ conv.title = autoTitle(userContent);
200
+ }
201
+
202
+ saveConversation(conv);
203
+ }
204
+
205
+ /**
206
+ * Get the message history from a conversation, capped at maxTurns pairs.
207
+ * @returns {Array<{role: string, content: string}>}
208
+ */
209
+ export function getHistory(conv, maxTurns = 20) {
210
+ const messages = conv.messages || [];
211
+ return messages.slice(-(maxTurns * 2));
212
+ }
213
+
214
+ // ── Export ────────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Export conversation as Markdown.
218
+ */
219
+ export function exportAsMarkdown(conv) {
220
+ const lines = [
221
+ `# ${conv.title}`,
222
+ `*Created: ${new Date(conv.createdAt).toLocaleString()}*`,
223
+ `*Messages: ${conv.messages.length}*`,
224
+ '',
225
+ '---',
226
+ '',
227
+ ];
228
+
229
+ for (const msg of conv.messages) {
230
+ if (msg.role === 'user') {
231
+ lines.push(`### You`);
232
+ lines.push(msg.content);
233
+ } else {
234
+ lines.push(`### NHA`);
235
+ lines.push(msg.content);
236
+ }
237
+ lines.push('');
238
+ }
239
+
240
+ return lines.join('\n');
241
+ }
242
+
243
+ /**
244
+ * Export conversation as JSON.
245
+ */
246
+ export function exportAsJson(conv) {
247
+ return JSON.stringify({
248
+ id: conv.id,
249
+ title: conv.title,
250
+ createdAt: conv.createdAt,
251
+ updatedAt: conv.updatedAt,
252
+ messages: conv.messages,
253
+ }, null, 2);
254
+ }
255
+
256
+ // ── Migration ────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Migrate old single-file chat history to multi-conversation format.
260
+ * Called once on first run if old history exists.
261
+ */
262
+ export function migrateOldHistory() {
263
+ const oldFile = path.join(NHA_DIR, 'memory', 'chat-history.json');
264
+ if (!fs.existsSync(oldFile)) return;
265
+
266
+ try {
267
+ const messages = JSON.parse(fs.readFileSync(oldFile, 'utf-8'));
268
+ if (!Array.isArray(messages) || messages.length === 0) return;
269
+
270
+ const conv = createConversation('Previous Chat');
271
+ conv.messages = messages;
272
+ saveConversation(conv);
273
+
274
+ // Rename old file to avoid re-migration
275
+ fs.renameSync(oldFile, oldFile + '.migrated');
276
+ } catch { /* migration failed — non-critical */ }
277
+ }
@@ -275,6 +275,144 @@ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
275
275
  return callFn(apiKey, model, systemPrompt, userMessage, false);
276
276
  }
277
277
 
278
+ /**
279
+ * Call an LLM provider with streaming enabled.
280
+ * Calls onToken(chunk) for each token, returns full text at the end.
281
+ * @returns {Promise<string>} The full LLM response text.
282
+ */
283
+ export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
284
+ const provider = opts.provider || config.llm.provider || 'anthropic';
285
+ const model = opts.model || config.llm.model || null;
286
+ const apiKey = getApiKey(config, provider);
287
+ if (!apiKey) throw new Error(`No API key for ${provider}`);
288
+
289
+ const callFn = getProviderCall(provider);
290
+ if (!callFn) throw new Error(`Unknown provider: ${provider}`);
291
+
292
+ // Gemini and Cohere don't support streaming — fall back to non-streaming
293
+ if (provider === 'gemini' || provider === 'cohere') {
294
+ const text = await callFn(apiKey, model, systemPrompt, userMessage, false);
295
+ if (onToken) onToken(text);
296
+ return text;
297
+ }
298
+
299
+ const format = provider === 'anthropic' ? 'anthropic' : 'openai';
300
+ const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
301
+ const url = getProviderUrl(provider, model, apiKey);
302
+ const headers = getProviderHeaders(provider, apiKey);
303
+
304
+ const res = await fetch(url, {
305
+ method: 'POST',
306
+ headers,
307
+ body: JSON.stringify(body),
308
+ });
309
+ if (!res.ok) {
310
+ const err = await res.text();
311
+ throw new Error(`${provider} ${res.status}: ${err}`);
312
+ }
313
+
314
+ return streamSSEWithCallback(res, format, onToken);
315
+ }
316
+
317
+ /** Build request body for a provider */
318
+ function buildRequestBody(provider, model, systemPrompt, userMessage, stream) {
319
+ if (provider === 'anthropic') {
320
+ return {
321
+ model: model || 'claude-sonnet-4-20250514',
322
+ max_tokens: 8192,
323
+ system: systemPrompt,
324
+ messages: [{ role: 'user', content: userMessage }],
325
+ stream,
326
+ };
327
+ }
328
+ // OpenAI-compatible format (OpenAI, DeepSeek, Grok, Mistral)
329
+ const modelDefaults = {
330
+ openai: 'gpt-4o',
331
+ deepseek: 'deepseek-chat',
332
+ grok: 'grok-3-latest',
333
+ mistral: 'mistral-large-latest',
334
+ };
335
+ return {
336
+ model: model || modelDefaults[provider] || 'gpt-4o',
337
+ max_tokens: 8192,
338
+ messages: [
339
+ { role: 'system', content: systemPrompt },
340
+ { role: 'user', content: userMessage },
341
+ ],
342
+ stream,
343
+ };
344
+ }
345
+
346
+ /** Get provider API URL */
347
+ function getProviderUrl(provider, model, apiKey) {
348
+ const urls = {
349
+ anthropic: 'https://api.anthropic.com/v1/messages',
350
+ openai: 'https://api.openai.com/v1/chat/completions',
351
+ deepseek: 'https://api.deepseek.com/v1/chat/completions',
352
+ grok: 'https://api.x.ai/v1/chat/completions',
353
+ mistral: 'https://api.mistral.ai/v1/chat/completions',
354
+ };
355
+ return urls[provider] || urls.openai;
356
+ }
357
+
358
+ /** Get provider request headers */
359
+ function getProviderHeaders(provider, apiKey) {
360
+ if (provider === 'anthropic') {
361
+ return {
362
+ 'Content-Type': 'application/json',
363
+ 'x-api-key': apiKey,
364
+ 'anthropic-version': '2023-06-01',
365
+ };
366
+ }
367
+ return {
368
+ 'Content-Type': 'application/json',
369
+ 'Authorization': `Bearer ${apiKey}`,
370
+ };
371
+ }
372
+
373
+ /** SSE stream parser with onToken callback (does NOT write to stdout directly) */
374
+ async function streamSSEWithCallback(res, format, onToken) {
375
+ const reader = res.body.getReader();
376
+ const decoder = new TextDecoder();
377
+ let buffer = '';
378
+ let fullText = '';
379
+
380
+ while (true) {
381
+ const { done, value } = await reader.read();
382
+ if (done) break;
383
+
384
+ buffer += decoder.decode(value, { stream: true });
385
+ const lines = buffer.split('\n');
386
+ buffer = lines.pop() || '';
387
+
388
+ for (const line of lines) {
389
+ if (!line.startsWith('data: ')) continue;
390
+ const data = line.slice(6).trim();
391
+ if (data === '[DONE]') continue;
392
+
393
+ try {
394
+ const json = JSON.parse(data);
395
+ let chunk = '';
396
+
397
+ if (format === 'anthropic') {
398
+ if (json.type === 'content_block_delta') {
399
+ chunk = json.delta?.text || '';
400
+ }
401
+ } else {
402
+ chunk = json.choices?.[0]?.delta?.content || '';
403
+ }
404
+
405
+ if (chunk) {
406
+ fullText += chunk;
407
+ if (onToken) onToken(chunk);
408
+ }
409
+ } catch {}
410
+ }
411
+ }
412
+
413
+ return fullText;
414
+ }
415
+
278
416
  /**
279
417
  * Call an agent by name — loads the agent file, calls LLM, returns response.
280
418
  * No streaming. Used by PAO pipeline for batch agent calls.
@@ -263,6 +263,19 @@ TOOLS:
263
263
  46. birthday_add(name: string, date: string)
264
264
  Add or update a birthday for a contact. Name is the contact name (must exist in Google Contacts — creates one if not found). Date is MM-DD (e.g. "04-06" for April 6) or YYYY-MM-DD.
265
265
 
266
+ --- WEB SEARCH & FETCH ---
267
+
268
+ 47. web_search(query: string, deep?: boolean)
269
+ Search the web using DuckDuckGo. Returns titles, URLs, and snippets.
270
+ Set deep=true to also fetch and extract the top 3 pages' full content (slower but more detailed).
271
+ Use this when the user asks about current events, recent news, or needs up-to-date information
272
+ that you don't have in your training data.
273
+
274
+ 48. fetch_url(url: string)
275
+ Fetch a web page and extract its text content. SSRF-protected (blocks private IPs, localhost).
276
+ Returns: title, excerpt, and body text (max 8000 chars). Only fetches text/html/json/xml.
277
+ Use this when the user provides a specific URL to read, summarize, or analyze.
278
+
266
279
  RULES:
267
280
  - For search/read operations, execute immediately and present results conversationally.
268
281
  - For write/send/delete operations (gmail_send, gmail_reply, gmail_delete, calendar_create, calendar_move, calendar_update, contact_delete, 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.
@@ -1022,6 +1035,67 @@ export async function executeTool(action, params, config) {
1022
1035
  return `Birthday set for ${contact.name}: ${monthName} ${day}. It will appear in the Birthdays tab.`;
1023
1036
  }
1024
1037
 
1038
+ // ── Web Search & Fetch ──────────────────────────────────────────────
1039
+ case 'web_search': {
1040
+ const wt = await import('./web-tools.mjs');
1041
+ const query = params.query;
1042
+ if (!query) return 'A search query is required.';
1043
+
1044
+ if (params.deep) {
1045
+ const result = await wt.webSearchDeep(query, 3);
1046
+ if (result.error) return `Search error: ${result.message}`;
1047
+ if (result.results.length === 0) return `No results found for "${query}".`;
1048
+
1049
+ const lines = [`Web search: "${query}" — ${result.resultCount} results, ${result.deepFetched} pages fetched\n`];
1050
+
1051
+ // Deep results first (with content)
1052
+ for (const dr of result.deepResults) {
1053
+ lines.push(`--- ${dr.title} ---`);
1054
+ lines.push(`URL: ${dr.url}`);
1055
+ lines.push(dr.content.slice(0, 2000));
1056
+ lines.push('');
1057
+ }
1058
+
1059
+ // Remaining results (snippets only)
1060
+ const deepUrls = new Set(result.deepResults.map(d => d.url));
1061
+ for (const r of result.results.filter(r => !deepUrls.has(r.url))) {
1062
+ lines.push(`${r.title}`);
1063
+ lines.push(` ${r.url}`);
1064
+ if (r.snippet) lines.push(` ${r.snippet}`);
1065
+ }
1066
+
1067
+ return lines.join('\n');
1068
+ }
1069
+
1070
+ const result = await wt.webSearch(query);
1071
+ if (result.error) return `Search error: ${result.message}`;
1072
+ if (result.results.length === 0) return `No results found for "${query}".`;
1073
+
1074
+ return `Web search: "${query}" — ${result.resultCount} results\n\n` +
1075
+ result.results.map((r, i) =>
1076
+ `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`
1077
+ ).join('\n\n');
1078
+ }
1079
+
1080
+ case 'fetch_url': {
1081
+ const wt = await import('./web-tools.mjs');
1082
+ const url = params.url;
1083
+ if (!url) return 'A URL is required.';
1084
+
1085
+ const result = await wt.fetchUrl(url);
1086
+ if (result.error) return `Fetch error: ${result.message}`;
1087
+
1088
+ const lines = [];
1089
+ if (result.title) lines.push(`Title: ${result.title}`);
1090
+ lines.push(`URL: ${result.url || url}`);
1091
+ lines.push(`Status: ${result.status}`);
1092
+ if (result.truncated) lines.push('[Content was truncated due to size limits]');
1093
+ lines.push('');
1094
+ lines.push(result.body);
1095
+
1096
+ return lines.join('\n');
1097
+ }
1098
+
1025
1099
  default:
1026
1100
  return `Unknown action: ${action}`;
1027
1101
  }