nothumanallowed 16.0.11 → 16.0.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "16.0.11",
3
+ "version": "16.0.13",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -18,6 +18,7 @@ import { cmdUI } from './commands/ui.mjs';
18
18
  import { cmdGoogle } from './commands/google-auth.mjs';
19
19
  import { cmdMicrosoft } from './commands/microsoft-auth.mjs';
20
20
  import { cmdScan } from './commands/scan.mjs';
21
+ import { runMemory } from './commands/memory.mjs';
21
22
  import { cmdVoice } from './commands/voice.mjs';
22
23
  import { cmdPlugin, findPluginForCommand } from './commands/plugin.mjs';
23
24
  import { banner, info, ok, warn, fail, C, G, Y, D, W, BOLD, NC, M, B, R } from './ui.mjs';
@@ -86,6 +87,11 @@ export async function main(argv) {
86
87
  case 'task':
87
88
  return cmdTasks(args);
88
89
 
90
+ case 'memory':
91
+ case 'memorize':
92
+ case 'remember':
93
+ return runMemory(args);
94
+
89
95
  case 'ops':
90
96
  return cmdOps(args);
91
97
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `nha memory` — manage persistent user memory.
3
+ *
4
+ * Subcommands:
5
+ * nha memory add "..." append a fact
6
+ * nha memory list print current memory
7
+ * nha memory edit open the memory file in $EDITOR
8
+ * nha memory clear wipe everything (with confirmation)
9
+ * nha memory path print the memory file path
10
+ */
11
+
12
+ import {
13
+ addUserMemory,
14
+ loadUserMemory,
15
+ clearUserMemory,
16
+ getMemoryPath,
17
+ } from '../services/user-memory.mjs';
18
+ import { spawn } from 'child_process';
19
+
20
+ export async function runMemory(args) {
21
+ const sub = args[0];
22
+
23
+ if (!sub || sub === 'list' || sub === 'show') {
24
+ const text = loadUserMemory();
25
+ if (!text.trim()) {
26
+ console.log('Memory empty. Add something with: nha memory add "..."');
27
+ } else {
28
+ console.log(text);
29
+ }
30
+ return;
31
+ }
32
+
33
+ if (sub === 'add') {
34
+ const entry = args.slice(1).join(' ').trim();
35
+ if (!entry) {
36
+ console.error('Usage: nha memory add "Fact to remember"');
37
+ process.exit(1);
38
+ }
39
+ addUserMemory(entry);
40
+ console.log(`✓ Added to memory: ${entry}`);
41
+ return;
42
+ }
43
+
44
+ if (sub === 'edit') {
45
+ const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
46
+ const proc = spawn(editor, [getMemoryPath()], { stdio: 'inherit' });
47
+ proc.on('close', (code) => process.exit(code || 0));
48
+ return;
49
+ }
50
+
51
+ if (sub === 'clear') {
52
+ const confirm = args.includes('--yes') || args.includes('-y');
53
+ if (!confirm) {
54
+ console.error('This will wipe ALL stored memories. Re-run with --yes to confirm.');
55
+ process.exit(1);
56
+ }
57
+ clearUserMemory();
58
+ console.log('✓ Memory cleared.');
59
+ return;
60
+ }
61
+
62
+ if (sub === 'path') {
63
+ console.log(getMemoryPath());
64
+ return;
65
+ }
66
+
67
+ console.error(`Unknown subcommand: ${sub}`);
68
+ console.error('Usage: nha memory <add|list|edit|clear|path>');
69
+ process.exit(1);
70
+ }
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 = '16.0.11';
8
+ export const VERSION = '16.0.13';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -219,32 +219,111 @@ export function register(router) {
219
219
  enrichedPrompt += `\n\nIMPORTANT: Output language is ${userLang}. Do NOT switch languages mid-response.`;
220
220
  }
221
221
 
222
- // Rolling context window
222
+ // ── Conversation history → structured messages[] (Fix 1, v16.0.12) ──
223
+ // ChatGPT/Claude pass full history as messages[]. We do the same: each
224
+ // turn keeps its own {role, content} so the model sees a real
225
+ // conversation, not a concatenated string. The rolling SUMMARY of older
226
+ // turns (Fix 2) is injected as a context prefix in the system prompt.
223
227
  const rawHistory = (body.history || []).map(h => ({
224
- role: h.role,
228
+ role: h.role === 'assistant' ? 'assistant' : 'user',
225
229
  content: (h.content || '').replace(/!\[Screenshot\]\(data:image\/[^)]+\)/g, '[Screenshot taken]'),
226
- }));
227
- const RECENT = 6;
228
- const parts = [];
229
- if (rawHistory.length > RECENT) {
230
- const older = rawHistory.slice(0, -RECENT);
231
- const lines = [];
232
- for (let i = 0; i < older.length; i += 2) {
233
- const u = older[i]?.content?.slice(0, 150)?.replace(/\n/g, ' ') || '';
234
- const a = older[i+1]?.content?.slice(0, 200)?.replace(/\n/g, ' ') || '';
235
- if (u) lines.push(`- User: "${u.trim()}${u.length >= 150 ? '...' : ''}" → ${a.trim()}${a.length >= 200 ? '...' : ''}`);
230
+ })).filter(m => m.content);
231
+
232
+ // ── Rolling summary (Fix 2) — TOKEN-based threshold ──
233
+ // Industry pattern (Claude context compaction, ChatGPT memory): trigger
234
+ // summary when the OLDER messages would consume more than a budget,
235
+ // measured in tokens (~chars/4). Provider-aware budget:
236
+ // - anthropic / openai / gemini 24k tokens raw before summary
237
+ // - nha (Liara/Qwen 32B 32k ctx) 8k tokens raw before summary
238
+ // - others 8k as safe default
239
+ // Plus per-turn cap of MAX_RECENT turns so latency stays bounded.
240
+ const provider = config.llm?.provider || (config.llm?.apiKey ? 'anthropic' : 'nha');
241
+ const TOKEN_BUDGET_BY_PROVIDER = {
242
+ anthropic: 24000, openai: 24000, gemini: 24000,
243
+ nha: 8000, deepseek: 16000, grok: 16000, mistral: 16000, cohere: 8000,
244
+ };
245
+ const tokenBudget = TOKEN_BUDGET_BY_PROVIDER[provider] || 8000;
246
+ const MAX_RECENT_TURNS = 30; // hard cap (latency safeguard)
247
+ const approxTokens = (s) => Math.ceil((s || '').length / 4);
248
+
249
+ let conversationSummary = '';
250
+ let recentHistory = rawHistory;
251
+ if (rawHistory.length > 0) {
252
+ // Walk backwards accumulating tokens until we exceed the budget OR
253
+ // hit MAX_RECENT_TURNS. Everything BEFORE that index goes into summary.
254
+ let recentTokens = 0;
255
+ let splitIdx = 0;
256
+ for (let i = rawHistory.length - 1; i >= 0; i--) {
257
+ const t = approxTokens(rawHistory[i].content);
258
+ if (recentTokens + t > tokenBudget) { splitIdx = i + 1; break; }
259
+ if (rawHistory.length - i > MAX_RECENT_TURNS) { splitIdx = i + 1; break; }
260
+ recentTokens += t;
261
+ splitIdx = i;
262
+ }
263
+ recentHistory = rawHistory.slice(splitIdx);
264
+ const older = rawHistory.slice(0, splitIdx);
265
+
266
+ if (older.length > 0) {
267
+ // Reuse cached summary when the older slice hasn't grown.
268
+ let cachedConv = null;
269
+ if (body.conversationId) {
270
+ try { cachedConv = loadConversation(body.conversationId); } catch {}
271
+ }
272
+ const cached = cachedConv?.rollingSummary;
273
+ if (cached && cached.coveredTurns === older.length) {
274
+ conversationSummary = cached.text;
275
+ } else {
276
+ // Build summary input in user language. Trim individual turns to
277
+ // 1200 chars each (older context loses fine-grained details).
278
+ const summaryInput = older.map(m =>
279
+ `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 1200)}`
280
+ ).join('\n\n');
281
+ const langLabel = userLang === 'it' ? 'in italiano' : `in ${userLang}`;
282
+ try {
283
+ conversationSummary = await callLLM(
284
+ config,
285
+ `You are a conversation summarizer. Summarize ${langLabel} in 200-500 tokens ALL facts, decisions, user preferences, specific data (dates, IDs, names, numbers, file paths, URLs) that emerged. No fluff, only information useful to reconstruct context. Preserve the language the user spoke in.`,
286
+ summaryInput,
287
+ { max_tokens: 700, temperature: 0.2 },
288
+ );
289
+ // Meta-compress: if the previous cached summary exists AND together
290
+ // with new content the result would balloon, replace fully with the
291
+ // new compact one (we just generated it from full older slice).
292
+ if (cachedConv) {
293
+ cachedConv.rollingSummary = {
294
+ text: conversationSummary,
295
+ coveredTurns: older.length,
296
+ coveredTokens: older.reduce((a, m) => a + approxTokens(m.content), 0),
297
+ at: new Date().toISOString(),
298
+ };
299
+ try { saveConversation(cachedConv); } catch {}
300
+ }
301
+ } catch { /* if summary fails, just skip it — recent history is enough */ }
302
+ }
236
303
  }
237
- if (lines.length) parts.push(`[CONVERSATION CONTEXT]\n${lines.join('\n')}\n[END CONTEXT]`);
238
304
  }
239
- for (const t of rawHistory.slice(-RECENT)) {
240
- parts.push(`${t.role === 'user' ? '[User]' : '[Assistant]'} ${t.content.slice(0, 2000)}`);
305
+
306
+ // Inject summary into the system prompt — it's the cheapest way for the
307
+ // model to see it AND it benefits from prompt caching on Anthropic.
308
+ if (conversationSummary) {
309
+ effectiveSystemPrompt = `${effectiveSystemPrompt || ''}\n\n[CONTESTO CONVERSAZIONE PRECEDENTE]\n${conversationSummary}\n[FINE CONTESTO]`;
241
310
  }
242
- // Prefix the last user turn with an explicit per-message language tag.
243
- // System prompts can lose effectiveness over long conversations; the
244
- // per-turn tag is the closest hint to the model's first generated token
245
- // and is the most reliable trigger for the right language.
246
- parts.push(`[User · respond in ${userLang}] ${effectiveMsg}`);
247
- const userMessage = parts.join('\n\n');
311
+
312
+ // ── User memory (Fix 3) persistent across conversations + channels ──
313
+ // Loaded from ~/.nha/user-memory.md, prepended to the system prompt.
314
+ try {
315
+ const { buildMemoryPrefix, autoLearnFromTurn } = await import('../../services/user-memory.mjs');
316
+ const memPrefix = buildMemoryPrefix();
317
+ if (memPrefix) effectiveSystemPrompt = `${memPrefix}${effectiveSystemPrompt || ''}`;
318
+ // Auto-learn — fire and forget, doesn't block the response.
319
+ autoLearnFromTurn(msg, config).catch(() => null);
320
+ } catch {}
321
+
322
+ // The final user message — keep the per-turn language tag close to the
323
+ // model's first generated token.
324
+ const userMessage = `[User · respond in ${userLang}] ${effectiveMsg}`;
325
+ // History passed to the provider as proper messages[] (not concatenated).
326
+ const historyForLLM = recentHistory;
248
327
 
249
328
  // Attachments — handle non-streaming
250
329
  if (body.imageBase64 || body.pdfBase64 || body.fileContent) {
@@ -298,7 +377,7 @@ export function register(router) {
298
377
  clearInterval(heartbeatInterval);
299
378
  heartbeatInterval = null;
300
379
  sse('token', { content: chunk });
301
- });
380
+ }, { history: historyForLLM });
302
381
 
303
382
  const { textParts, actions } = parseActions(fullResponse);
304
383
  const toolResults = [];
@@ -32,6 +32,24 @@ export function register(router) {
32
32
  sendJSON(res, 200, { ok: true, version: VERSION, ts: Date.now() });
33
33
  });
34
34
 
35
+ // GET /api/audit/query — query the cross-channel audit log.
36
+ // Optional query params: tool, channel, since (ms timestamp), limit.
37
+ router.get('/api/audit/query', async (req, res) => {
38
+ try {
39
+ const { queryAuditLog } = await import('../../services/message-responder.mjs');
40
+ const url = new URL(req.url, 'http://localhost');
41
+ const entries = queryAuditLog({
42
+ tool: url.searchParams.get('tool') || undefined,
43
+ channel: url.searchParams.get('channel') || undefined,
44
+ since: url.searchParams.get('since') ? parseInt(url.searchParams.get('since'), 10) : undefined,
45
+ limit: parseInt(url.searchParams.get('limit') || '100', 10),
46
+ });
47
+ sendJSON(res, 200, { entries });
48
+ } catch (e) {
49
+ sendJSON(res, 500, { error: e.message });
50
+ }
51
+ });
52
+
35
53
  // GET /api/version/check
36
54
  //
37
55
  // Returns three version signals so the UI can distinguish three states:
@@ -399,11 +399,18 @@ export async function callAnthropic(apiKey, model, systemPrompt, userMessage, st
399
399
  const systemBlocks = systemPrompt
400
400
  ? [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }]
401
401
  : [];
402
+ // Build conversation messages: optional history[] then the current turn.
403
+ // history must alternate role: user/assistant/user/... ending with assistant
404
+ // (or be empty). The current userMessage is appended as the final user turn.
405
+ const historyMsgs = Array.isArray(opts.history)
406
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({ role: m.role, content: String(m.content) }))
407
+ : [];
408
+ const messages = [...historyMsgs, { role: 'user', content: userMessage }];
402
409
  const body = {
403
410
  model: model || 'claude-sonnet-4-20250514',
404
411
  max_tokens: opts.max_tokens || 8192,
405
412
  system: systemBlocks,
406
- messages: [{ role: 'user', content: userMessage }],
413
+ messages,
407
414
  stream,
408
415
  };
409
416
  if (opts.temperature !== undefined) body.temperature = opts.temperature;
@@ -427,11 +434,15 @@ export async function callAnthropic(apiKey, model, systemPrompt, userMessage, st
427
434
  }
428
435
 
429
436
  export async function callOpenAI(apiKey, model, systemPrompt, userMessage, stream = false, opts = {}) {
437
+ const historyMsgs = Array.isArray(opts.history)
438
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({ role: m.role, content: String(m.content) }))
439
+ : [];
430
440
  const body = {
431
441
  model: model || 'gpt-4o',
432
442
  max_tokens: opts.max_tokens || 8192,
433
443
  messages: [
434
444
  { role: 'system', content: systemPrompt },
445
+ ...historyMsgs,
435
446
  { role: 'user', content: userMessage },
436
447
  ],
437
448
  stream,
@@ -459,9 +470,16 @@ export async function callGemini(apiKey, model, systemPrompt, userMessage, _stre
459
470
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${apiKey}`;
460
471
  const generationConfig = { maxOutputTokens: opts.max_tokens || 8192 };
461
472
  if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature;
473
+ // Gemini uses 'contents' with role 'user'/'model'. Convert history.
474
+ const historyContents = Array.isArray(opts.history)
475
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({
476
+ role: m.role === 'assistant' ? 'model' : 'user',
477
+ parts: [{ text: String(m.content) }],
478
+ }))
479
+ : [];
462
480
  const body = {
463
481
  system_instruction: { parts: [{ text: systemPrompt }] },
464
- contents: [{ parts: [{ text: userMessage }] }],
482
+ contents: [...historyContents, { role: 'user', parts: [{ text: userMessage }] }],
465
483
  generationConfig,
466
484
  };
467
485
  const res = await fetch(url, {
@@ -477,12 +495,23 @@ export async function callGemini(apiKey, model, systemPrompt, userMessage, _stre
477
495
  return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
478
496
  }
479
497
 
498
+ // OpenAI-compatible history mapper — used by DeepSeek/Grok/Mistral/Cohere.
499
+ function _openaiHistory(opts) {
500
+ return Array.isArray(opts?.history)
501
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({
502
+ role: m.role === 'assistant' ? 'assistant' : 'user',
503
+ content: String(m.content),
504
+ }))
505
+ : [];
506
+ }
507
+
480
508
  export async function callDeepSeek(apiKey, model, systemPrompt, userMessage, stream = false, opts = {}) {
481
509
  const body = {
482
510
  model: model || 'deepseek-chat',
483
511
  max_tokens: opts.max_tokens || 8192,
484
512
  messages: [
485
513
  { role: 'system', content: systemPrompt },
514
+ ..._openaiHistory(opts),
486
515
  { role: 'user', content: userMessage },
487
516
  ],
488
517
  stream,
@@ -511,6 +540,7 @@ export async function callGrok(apiKey, model, systemPrompt, userMessage, stream
511
540
  max_tokens: opts.max_tokens || 8192,
512
541
  messages: [
513
542
  { role: 'system', content: systemPrompt },
543
+ ..._openaiHistory(opts),
514
544
  { role: 'user', content: userMessage },
515
545
  ],
516
546
  stream,
@@ -539,6 +569,7 @@ export async function callMistral(apiKey, model, systemPrompt, userMessage, stre
539
569
  max_tokens: opts.max_tokens || 8192,
540
570
  messages: [
541
571
  { role: 'system', content: systemPrompt },
572
+ ..._openaiHistory(opts),
542
573
  { role: 'user', content: userMessage },
543
574
  ],
544
575
  stream,
@@ -562,10 +593,18 @@ export async function callMistral(apiKey, model, systemPrompt, userMessage, stre
562
593
  }
563
594
 
564
595
  export async function callCohere(apiKey, model, systemPrompt, userMessage, _stream = false, opts = {}) {
596
+ // Cohere uses a 'chat_history' array with role: USER/CHATBOT (uppercase).
597
+ const cohereHistory = Array.isArray(opts.history)
598
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({
599
+ role: m.role === 'assistant' ? 'CHATBOT' : 'USER',
600
+ message: String(m.content),
601
+ }))
602
+ : [];
565
603
  const body = {
566
604
  model: model || 'command-r-plus',
567
605
  max_tokens: opts.max_tokens || 8192,
568
606
  preamble: systemPrompt,
607
+ chat_history: cohereHistory,
569
608
  message: userMessage,
570
609
  };
571
610
  if (opts.temperature !== undefined) body.temperature = opts.temperature;
@@ -664,11 +703,18 @@ export async function callNHA(apiKey, model, systemPrompt, userMessage, stream =
664
703
  .replace(/\|\|\(/g, '||(') // LDAP (cosmetic, non-breaking)
665
704
  .replace(/\)\|\|/g, ')||'); // LDAP
666
705
 
706
+ const historyMsgs = Array.isArray(opts.history)
707
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({
708
+ role: m.role === 'assistant' ? 'assistant' : 'user',
709
+ content: sanitizeForSentinel(String(m.content)),
710
+ }))
711
+ : [];
667
712
  const body = {
668
713
  model: model || '/opt/models/qwen3-32b',
669
714
  max_tokens: opts.max_tokens || (thinkingEnabled ? 16384 : 8192),
670
715
  messages: [
671
716
  { role: 'system', content: sanitizeForSentinel(systemPrompt) },
717
+ ...historyMsgs,
672
718
  { role: 'user', content: sanitizeForSentinel(userMessage) },
673
719
  ],
674
720
  stream,
@@ -1212,11 +1258,18 @@ export async function callLLMStream(config, systemPrompt, userMessage, onToken,
1212
1258
  // 3. Otherwise default to 8192 (full context for specialist agents)
1213
1259
  const effectiveMaxTokens = opts.max_tokens || (thinkingEnabled ? 8192 : 8192);
1214
1260
 
1261
+ const nhaHistory = Array.isArray(opts.history)
1262
+ ? opts.history.filter(m => m && m.role && m.content).map(m => ({
1263
+ role: m.role === 'assistant' ? 'assistant' : 'user',
1264
+ content: sanitize(String(m.content)),
1265
+ }))
1266
+ : [];
1215
1267
  const nhaBody = {
1216
1268
  model: model || '/opt/models/qwen3-32b',
1217
1269
  max_tokens: effectiveMaxTokens,
1218
1270
  messages: [
1219
1271
  { role: 'system', content: sanitize(systemPrompt) },
1272
+ ...nhaHistory,
1220
1273
  { role: 'user', content: sanitize(userMessage) },
1221
1274
  ],
1222
1275
  stream: false,
@@ -18,6 +18,67 @@ import path from 'path';
18
18
  import os from 'os';
19
19
  import { VERSION } from '../constants.mjs';
20
20
 
21
+ // ── Global audit log helpers (Fix 4 v16.0.12) ──
22
+ // Append-only JSONL at ~/.nha/audit-log.jsonl, shared across every channel
23
+ // (telegram, discord, chat web, AWF agents). Lets the user ask "what have you
24
+ // done today?" from any surface and get a consistent answer.
25
+ const _GLOBAL_AUDIT_FILE = path.join(os.homedir(), '.nha', 'audit-log.jsonl');
26
+ const _AUDIT_MAX_LINES = 10000; // rotate at 10k lines (~1MB JSONL)
27
+ const _AUDIT_ARCHIVE_PREFIX = 'audit-log-';
28
+
29
+ function _rotateAuditIfNeeded() {
30
+ try {
31
+ if (!fs.existsSync(_GLOBAL_AUDIT_FILE)) return;
32
+ const stat = fs.statSync(_GLOBAL_AUDIT_FILE);
33
+ // Quick check: skip the line count unless file is bigger than ~1.5MB
34
+ if (stat.size < 1_500_000) return;
35
+ const text = fs.readFileSync(_GLOBAL_AUDIT_FILE, 'utf-8');
36
+ const lines = text.split('\n').filter(Boolean);
37
+ if (lines.length <= _AUDIT_MAX_LINES) return;
38
+ // Archive older half, keep most recent _AUDIT_MAX_LINES.
39
+ const tail = lines.slice(-_AUDIT_MAX_LINES);
40
+ const archived = lines.slice(0, lines.length - _AUDIT_MAX_LINES);
41
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
42
+ const archiveFile = path.join(path.dirname(_GLOBAL_AUDIT_FILE), `${_AUDIT_ARCHIVE_PREFIX}${ts}.jsonl`);
43
+ fs.writeFileSync(archiveFile, archived.join('\n') + '\n');
44
+ fs.writeFileSync(_GLOBAL_AUDIT_FILE, tail.join('\n') + '\n');
45
+ } catch {}
46
+ }
47
+
48
+ function _appendGlobalAudit(entry) {
49
+ try {
50
+ fs.mkdirSync(path.dirname(_GLOBAL_AUDIT_FILE), { recursive: true });
51
+ fs.appendFileSync(_GLOBAL_AUDIT_FILE, JSON.stringify(entry) + '\n');
52
+ // Rotate occasionally (cheap stat-check; full scan only if size > 1.5MB).
53
+ if (Math.random() < 0.01) _rotateAuditIfNeeded();
54
+ } catch {}
55
+ }
56
+
57
+ function _readGlobalAudit(limitTail = 100) {
58
+ try {
59
+ if (!fs.existsSync(_GLOBAL_AUDIT_FILE)) return [];
60
+ const text = fs.readFileSync(_GLOBAL_AUDIT_FILE, 'utf-8');
61
+ const lines = text.split('\n').filter(Boolean);
62
+ return lines.slice(-limitTail)
63
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
64
+ .filter(Boolean);
65
+ } catch { return []; }
66
+ }
67
+
68
+ /**
69
+ * Query the audit log with filters. Exported for the HTTP /api/audit/query
70
+ * endpoint. Supports filtering by tool, channel, since timestamp.
71
+ */
72
+ export function queryAuditLog({ tool, channel, since, limit = 100 } = {}) {
73
+ const all = _readGlobalAudit(10000);
74
+ return all.filter(e => {
75
+ if (tool && e.tool !== tool) return false;
76
+ if (channel && e.channel !== channel) return false;
77
+ if (since && e.ts < since) return false;
78
+ return true;
79
+ }).slice(-limit);
80
+ }
81
+
21
82
  // ── Agent Routing (keyword-based, zero LLM calls) ───────────────────────────
22
83
 
23
84
  const ROUTING_TABLE = [
@@ -1178,6 +1239,18 @@ class TelegramResponder {
1178
1239
  const auditNote = this._renderAuditForPrompt(chatId);
1179
1240
  if (auditNote) enrichedMessage = auditNote + enrichedMessage;
1180
1241
 
1242
+ // ── User memory (Fix 3+D v16.0.13) — cross-channel persistent context.
1243
+ // Same memory file that's used by the chat web UI. The user can
1244
+ // `nha memory add "I prefer concise answers"` once and EVERY channel
1245
+ // honors it.
1246
+ try {
1247
+ const { buildMemoryPrefix, autoLearnFromTurn } = await import('./user-memory.mjs');
1248
+ const memPrefix = buildMemoryPrefix();
1249
+ if (memPrefix) enrichedMessage = memPrefix + enrichedMessage;
1250
+ // Auto-learn — fire and forget, doesn't block the response.
1251
+ autoLearnFromTurn(cleanText, this.config).catch(() => null);
1252
+ } catch {}
1253
+
1181
1254
  if (TOOL_AGENTS.has(agent)) {
1182
1255
  const result = await callAgentWithTools(this.config, agent, enrichedMessage, detectedLang, preHistory);
1183
1256
  responseText = result.text;
@@ -1449,21 +1522,43 @@ class TelegramResponder {
1449
1522
  _recordAudit(chatId, entry) {
1450
1523
  const ctx = this._lastContextByChatId[chatId] || (this._lastContextByChatId[chatId] = {});
1451
1524
  if (!Array.isArray(ctx.auditLog)) ctx.auditLog = [];
1452
- ctx.auditLog.push({ ts: Date.now(), ...entry });
1525
+ const enriched = { ts: Date.now(), channel: chatId, ...entry };
1526
+ ctx.auditLog.push(enriched);
1453
1527
  if (ctx.auditLog.length > 50) ctx.auditLog = ctx.auditLog.slice(-50);
1454
1528
  this._persistContext();
1529
+ // ── Global audit log (Fix 4 v16.0.12) ──
1530
+ // Append-only JSONL at ~/.nha/audit-log.jsonl shared across every channel
1531
+ // (telegram / discord / chat web / AWF agent). Lets the user ask
1532
+ // "what have you done today?" from any surface and get the same answer.
1533
+ try {
1534
+ _appendGlobalAudit(enriched);
1535
+ } catch {}
1455
1536
  }
1456
1537
 
1457
1538
  _renderAuditForPrompt(chatId, maxEntries = 10) {
1539
+ // Pull from BOTH the per-channel context AND the global log so the model
1540
+ // sees actions made via a different channel too.
1458
1541
  const ctx = this._lastContextByChatId[chatId];
1459
- if (!ctx || !Array.isArray(ctx.auditLog) || ctx.auditLog.length === 0) return '';
1460
- const recent = ctx.auditLog.slice(-maxEntries);
1542
+ const local = ctx?.auditLog || [];
1543
+ let globalEntries = [];
1544
+ try { globalEntries = _readGlobalAudit(100); } catch {}
1545
+ // Merge + de-dupe by (ts, tool, summary), keep most recent.
1546
+ const seen = new Set();
1547
+ const merged = [...local, ...globalEntries].sort((a, b) => a.ts - b.ts).filter(e => {
1548
+ const k = `${e.ts}|${e.tool}|${e.summary || ''}`;
1549
+ if (seen.has(k)) return false;
1550
+ seen.add(k);
1551
+ return true;
1552
+ });
1553
+ if (merged.length === 0) return '';
1554
+ const recent = merged.slice(-maxEntries);
1461
1555
  const lines = recent.map(e => {
1462
1556
  const time = new Date(e.ts).toLocaleString('it-IT', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
1463
1557
  const status = e.success === false ? '✗ FALLITA' : '✓ OK';
1464
- return `- ${time} · ${e.tool} · ${status} · ${e.summary || ''}`;
1558
+ const chan = e.channel && e.channel !== chatId ? ` [via ${String(e.channel).slice(0, 20)}]` : '';
1559
+ return `- ${time} · ${e.tool} · ${status} · ${e.summary || ''}${chan}`;
1465
1560
  });
1466
- return `\n\n[AZIONI RECENTI ESEGUITE IN QUESTA CONVERSAZIONE — fonte di verità sui fatti già accaduti]\n${lines.join('\n')}\n[FINE AZIONI RECENTI]\n`;
1561
+ return `\n\n[AZIONI RECENTI ESEGUITE — fonte di verità sui fatti già accaduti su QUALSIASI canale (Chat, Telegram, Discord, AWF)]\n${lines.join('\n')}\n[FINE AZIONI RECENTI]\n`;
1467
1562
  }
1468
1563
 
1469
1564
  _formatDateIT(isoDate) {
@@ -1943,7 +2038,7 @@ class TelegramResponder {
1943
2038
  // Clear the pending state so we don't double-delete on next yes.
1944
2039
  delete this._lastContextByChatId[chatId].pendingDeleteEvents;
1945
2040
  delete this._lastContextByChatId[chatId].lastCalendarEvents;
1946
- try { (await import('./telegram-context.mjs')).saveTelegramContext(this._lastContextByChatId); } catch {}
2041
+ try { saveTelegramContext(this._lastContextByChatId); } catch {}
1947
2042
 
1948
2043
  const subject = eligible.length === 1 ? `"${eligible[0].summary}"` : `${eligible.length} appuntamenti`;
1949
2044
  const lines = [`Ho cancellato ${subject}.`];
@@ -2150,7 +2245,7 @@ class TelegramResponder {
2150
2245
  lastCalendarListAt: Date.now(),
2151
2246
  lastCalendarSource: { tool: toolName, args },
2152
2247
  };
2153
- try { (await import('./telegram-context.mjs')).saveTelegramContext(this._lastContextByChatId); } catch {}
2248
+ try { saveTelegramContext(this._lastContextByChatId); } catch {}
2154
2249
  }
2155
2250
  return { action: actionKey, success: true, message: String(out) };
2156
2251
  } catch (e) { return { action: actionKey, success: false, message: `Errore: ${e.message}` }; }
@@ -2742,7 +2837,26 @@ class DiscordResponder {
2742
2837
  // Tool-capable agents use the full tool execution loop
2743
2838
  const TOOL_AGENTS = new Set(['herald', 'hermes', 'edi', 'jarvis', 'flux', 'echo', 'mercury', 'pipe', 'navi', 'link', 'prometheus', 'tempest']);
2744
2839
  const callFn = TOOL_AGENTS.has(agent) ? callAgentWithTools : callAgent;
2745
- const response = await callFn(this.config, agent, cleanText);
2840
+ // Cross-channel user memory + audit log + auto-learn (v16.0.13)
2841
+ let discordMsg = cleanText;
2842
+ try {
2843
+ const { buildMemoryPrefix, autoLearnFromTurn } = await import('./user-memory.mjs');
2844
+ const memPrefix = buildMemoryPrefix();
2845
+ if (memPrefix) discordMsg = memPrefix + discordMsg;
2846
+ autoLearnFromTurn(cleanText, this.config).catch(() => null);
2847
+ } catch {}
2848
+ try {
2849
+ const auditNote = _readGlobalAudit(15);
2850
+ if (auditNote.length > 0) {
2851
+ const lines = auditNote.slice(-10).map(e => {
2852
+ const t = new Date(e.ts).toLocaleString('it-IT', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
2853
+ const st = e.success === false ? '✗' : '✓';
2854
+ return `- ${t} · ${e.tool} ${st} · ${e.summary || ''}`;
2855
+ }).join('\n');
2856
+ discordMsg = `[AZIONI RECENTI da altri canali]\n${lines}\n[FINE]\n\n${discordMsg}`;
2857
+ }
2858
+ } catch {}
2859
+ const response = await callFn(this.config, agent, discordMsg);
2746
2860
 
2747
2861
  // Discord message limit is 2000 chars
2748
2862
  const truncated = response.length > 1900
@@ -0,0 +1,128 @@
1
+ /**
2
+ * User memory — persistent across conversations and channels.
3
+ *
4
+ * Same idea as ChatGPT's "Memory" feature: a small Markdown file at
5
+ * ~/.nha/user-memory.md is loaded and prepended to the system prompt of
6
+ * every chat / Telegram / Discord / AWF agent call.
7
+ *
8
+ * The file is fully owned by the user — no telemetry, never uploaded.
9
+ * Stored as plain Markdown so it stays human-readable and editable.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { NHA_DIR } from '../constants.mjs';
15
+
16
+ const MEMORY_FILE = path.join(NHA_DIR, 'user-memory.md');
17
+ const MAX_MEMORY_SIZE = 8000; // chars — prevents prompt explosion
18
+
19
+ function ensureFile() {
20
+ if (!fs.existsSync(NHA_DIR)) fs.mkdirSync(NHA_DIR, { recursive: true });
21
+ if (!fs.existsSync(MEMORY_FILE)) {
22
+ const header = `# User Memory\n\n` +
23
+ `Things NHA should remember about you, across all conversations and channels.\n` +
24
+ `Edit this file freely, or use \`nha memory add "..."\` to append.\n\n`;
25
+ fs.writeFileSync(MEMORY_FILE, header);
26
+ }
27
+ }
28
+
29
+ /** Load the full memory file content (trimmed to MAX size). */
30
+ export function loadUserMemory() {
31
+ try {
32
+ if (!fs.existsSync(MEMORY_FILE)) return '';
33
+ const text = fs.readFileSync(MEMORY_FILE, 'utf-8');
34
+ if (text.length <= MAX_MEMORY_SIZE) return text;
35
+ // Keep the most recent entries (tail) if it grows too large.
36
+ return text.slice(-MAX_MEMORY_SIZE);
37
+ } catch { return ''; }
38
+ }
39
+
40
+ /** Append a single fact/preference to the memory file. */
41
+ export function addUserMemory(entry) {
42
+ if (!entry || typeof entry !== 'string') return false;
43
+ ensureFile();
44
+ const trimmed = entry.trim();
45
+ if (!trimmed) return false;
46
+ const timestamp = new Date().toISOString().slice(0, 10);
47
+ const line = `- [${timestamp}] ${trimmed}\n`;
48
+ fs.appendFileSync(MEMORY_FILE, line);
49
+ return true;
50
+ }
51
+
52
+ /** Replace the entire memory content (used by `nha memory edit`). */
53
+ export function setUserMemory(text) {
54
+ ensureFile();
55
+ fs.writeFileSync(MEMORY_FILE, text);
56
+ }
57
+
58
+ /** Wipe all memories. */
59
+ export function clearUserMemory() {
60
+ if (fs.existsSync(MEMORY_FILE)) fs.unlinkSync(MEMORY_FILE);
61
+ }
62
+
63
+ /** Get the memory file path (for the `nha memory edit` command to open). */
64
+ export function getMemoryPath() {
65
+ return MEMORY_FILE;
66
+ }
67
+
68
+ /**
69
+ * Build a system-prompt prefix block for the user memory. Returns empty
70
+ * string when there's nothing to inject (no file, or only the header).
71
+ * The prefix is wrapped in delimited markers so it doesn't bleed into
72
+ * the rest of the prompt and the model knows it's persistent context.
73
+ */
74
+ export function buildMemoryPrefix() {
75
+ const raw = loadUserMemory().trim();
76
+ if (!raw || raw.replace(/^#.*$/gm, '').replace(/^Things NHA.*$/gm, '').trim().length === 0) {
77
+ return '';
78
+ }
79
+ return `[USER MEMORY — persistent across all conversations]\n${raw}\n[END USER MEMORY]\n\n`;
80
+ }
81
+
82
+ /**
83
+ * Auto-extract memorable facts from a user turn and append them to memory.
84
+ * Mirrors ChatGPT's "Memory" auto-learn: scans the message for explicit
85
+ * "remember that..." / "ricorda che..." instructions AND for implicit
86
+ * personal facts (name, location, role, preferences, deadlines, contacts).
87
+ *
88
+ * Designed to be CHEAP: runs ONLY when the user message contains a likely
89
+ * signal ("ricord", "remember", "preferisco", "mi chiamo", "lavoro come",
90
+ * "ho un appuntamento", "uso sempre", etc.). Skips noise.
91
+ *
92
+ * @param {string} userText
93
+ * @param {object} config — NHA config (needs llm provider)
94
+ * @returns {Promise<string|null>} the new memory line if learned, else null
95
+ */
96
+ export async function autoLearnFromTurn(userText, config) {
97
+ if (!userText || typeof userText !== 'string' || userText.length < 8) return null;
98
+ // Cheap pre-filter — only call the LLM if the text plausibly contains a fact.
99
+ const trigger = /\b(ricord[aiy]|memorizz[aiy]|salv[aiy]\s+che|tieni\s+a\s+mente|prefer(isco|isci)|mi\s+chiamo|sono\s+(un|una)\b|lavoro\s+(come|presso|in)\b|abito\s+(a|in)\b|vivo\s+(a|in)\b|uso\s+sempre|preferenza|impostazione|deadline|scadenza|ho\s+un\s+(appuntament|incontro)|il\s+mio\s+(nome|email|telefon|indirizz)|api\s+key|password|remember\s+that|please\s+remember|note\s+that|my\s+name\s+is|i\s+work\s+as|i\s+live\s+in|i\s+prefer|i\s+use\s+always)\b/i;
100
+ if (!trigger.test(userText)) return null;
101
+
102
+ try {
103
+ const { callLLM } = await import('./llm.mjs');
104
+ const systemPrompt =
105
+ 'You are a memory extractor. Read the user message and decide if there is ONE durable fact, preference, or piece of personal context worth remembering across future conversations. ' +
106
+ 'Return STRICT JSON: {"memorable": true|false, "fact": "concise fact in the user language, max 140 chars"} or {"memorable": false}. ' +
107
+ 'Memorable: name, role, location, language preference, communication style, recurring contacts, long-term projects, API keys/IDs (only id, NOT secrets), tools they use, hard preferences. ' +
108
+ 'NOT memorable: greetings, transient questions, one-off tasks, weather, news, anything that expires within a day. ' +
109
+ 'NEVER fabricate facts that the user did not explicitly state.';
110
+ const raw = await callLLM(config, systemPrompt, userText, { max_tokens: 150, temperature: 0.1 });
111
+ const m = raw.match(/\{[\s\S]*\}/);
112
+ if (!m) return null;
113
+ const parsed = JSON.parse(m[0]);
114
+ if (!parsed.memorable || !parsed.fact || typeof parsed.fact !== 'string') return null;
115
+ const fact = parsed.fact.trim().slice(0, 140);
116
+ if (!fact) return null;
117
+ // Deduplicate: skip if a near-identical fact is already in memory.
118
+ const existing = loadUserMemory().toLowerCase();
119
+ const factLow = fact.toLowerCase();
120
+ // Very rough dedup: if the first 30 chars of the new fact appear in
121
+ // memory already, skip. Avoid LLM-driven dedup loop (would be expensive).
122
+ if (factLow.length > 20 && existing.includes(factLow.slice(0, Math.min(30, factLow.length)))) {
123
+ return null;
124
+ }
125
+ addUserMemory(`(auto) ${fact}`);
126
+ return fact;
127
+ } catch { return null; }
128
+ }