nothumanallowed 16.0.10 → 16.0.12
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 +1 -1
- package/src/cli.mjs +6 -0
- package/src/commands/memory.mjs +70 -0
- package/src/constants.mjs +1 -1
- package/src/server/routes/chat.mjs +63 -20
- package/src/services/llm.mjs +34 -2
- package/src/services/message-responder.mjs +125 -45
- package/src/services/user-memory.mjs +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "16.0.
|
|
3
|
+
"version": "16.0.12",
|
|
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.
|
|
8
|
+
export const VERSION = '16.0.12';
|
|
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,75 @@ export function register(router) {
|
|
|
219
219
|
enrichedPrompt += `\n\nIMPORTANT: Output language is ${userLang}. Do NOT switch languages mid-response.`;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
//
|
|
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
|
-
|
|
228
|
-
|
|
230
|
+
})).filter(m => m.content);
|
|
231
|
+
|
|
232
|
+
// ── Rolling summary (Fix 2) ──
|
|
233
|
+
// After a threshold of turns we generate (or reuse) a compact summary of
|
|
234
|
+
// everything OLDER than the recent window, persisted in the conversation
|
|
235
|
+
// object. The recent window stays as raw messages[]. This is the
|
|
236
|
+
// "memory like ChatGPT/Claude" pattern.
|
|
237
|
+
const RECENT = 12;
|
|
238
|
+
let conversationSummary = '';
|
|
239
|
+
let recentHistory = rawHistory;
|
|
229
240
|
if (rawHistory.length > RECENT) {
|
|
241
|
+
recentHistory = rawHistory.slice(-RECENT);
|
|
230
242
|
const older = rawHistory.slice(0, -RECENT);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
243
|
+
// Try to reuse a cached summary from the conversation, regenerate if
|
|
244
|
+
// the older slice grew beyond what the cached summary covered.
|
|
245
|
+
let cachedConv = null;
|
|
246
|
+
if (body.conversationId) {
|
|
247
|
+
try { cachedConv = loadConversation(body.conversationId); } catch {}
|
|
248
|
+
}
|
|
249
|
+
const cached = cachedConv?.rollingSummary;
|
|
250
|
+
if (cached && cached.coveredTurns === older.length) {
|
|
251
|
+
conversationSummary = cached.text;
|
|
252
|
+
} else {
|
|
253
|
+
// Generate a fresh summary via the same LLM. Compact, factual.
|
|
254
|
+
const summaryInput = older.map(m =>
|
|
255
|
+
`${m.role === 'user' ? 'Utente' : 'Assistente'}: ${m.content.slice(0, 600)}`
|
|
256
|
+
).join('\n\n');
|
|
257
|
+
try {
|
|
258
|
+
conversationSummary = await callLLM(
|
|
259
|
+
config,
|
|
260
|
+
'Sei un sintetizzatore di conversazione. Riassumi in italiano in 200-400 token TUTTI i fatti, decisioni, preferenze utente, dati specifici (date, ID, nomi, numeri) emersi nella conversazione. Niente abbellimenti, solo informazione utile per ricostruire il contesto.',
|
|
261
|
+
summaryInput,
|
|
262
|
+
{ max_tokens: 600, temperature: 0.2 },
|
|
263
|
+
);
|
|
264
|
+
if (cachedConv) {
|
|
265
|
+
cachedConv.rollingSummary = { text: conversationSummary, coveredTurns: older.length, at: new Date().toISOString() };
|
|
266
|
+
try { saveConversation(cachedConv); } catch {}
|
|
267
|
+
}
|
|
268
|
+
} catch { /* if summary fails, just skip it — recent history is enough */ }
|
|
236
269
|
}
|
|
237
|
-
if (lines.length) parts.push(`[CONVERSATION CONTEXT]\n${lines.join('\n')}\n[END CONTEXT]`);
|
|
238
270
|
}
|
|
239
|
-
|
|
240
|
-
|
|
271
|
+
|
|
272
|
+
// Inject summary into the system prompt — it's the cheapest way for the
|
|
273
|
+
// model to see it AND it benefits from prompt caching on Anthropic.
|
|
274
|
+
if (conversationSummary) {
|
|
275
|
+
effectiveSystemPrompt = `${effectiveSystemPrompt || ''}\n\n[CONTESTO CONVERSAZIONE PRECEDENTE]\n${conversationSummary}\n[FINE CONTESTO]`;
|
|
241
276
|
}
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
277
|
+
|
|
278
|
+
// ── User memory (Fix 3) — persistent across conversations + channels ──
|
|
279
|
+
// Loaded from ~/.nha/user-memory.md, prepended to the system prompt.
|
|
280
|
+
try {
|
|
281
|
+
const { buildMemoryPrefix } = await import('../../services/user-memory.mjs');
|
|
282
|
+
const memPrefix = buildMemoryPrefix();
|
|
283
|
+
if (memPrefix) effectiveSystemPrompt = `${memPrefix}${effectiveSystemPrompt || ''}`;
|
|
284
|
+
} catch {}
|
|
285
|
+
|
|
286
|
+
// The final user message — keep the per-turn language tag close to the
|
|
287
|
+
// model's first generated token.
|
|
288
|
+
const userMessage = `[User · respond in ${userLang}] ${effectiveMsg}`;
|
|
289
|
+
// History passed to the provider as proper messages[] (not concatenated).
|
|
290
|
+
const historyForLLM = recentHistory;
|
|
248
291
|
|
|
249
292
|
// Attachments — handle non-streaming
|
|
250
293
|
if (body.imageBase64 || body.pdfBase64 || body.fileContent) {
|
|
@@ -298,7 +341,7 @@ export function register(router) {
|
|
|
298
341
|
clearInterval(heartbeatInterval);
|
|
299
342
|
heartbeatInterval = null;
|
|
300
343
|
sse('token', { content: chunk });
|
|
301
|
-
});
|
|
344
|
+
}, { history: historyForLLM });
|
|
302
345
|
|
|
303
346
|
const { textParts, actions } = parseActions(fullResponse);
|
|
304
347
|
const toolResults = [];
|
package/src/services/llm.mjs
CHANGED
|
@@ -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
|
|
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, {
|
|
@@ -664,11 +682,18 @@ export async function callNHA(apiKey, model, systemPrompt, userMessage, stream =
|
|
|
664
682
|
.replace(/\|\|\(/g, '||(') // LDAP (cosmetic, non-breaking)
|
|
665
683
|
.replace(/\)\|\|/g, ')||'); // LDAP
|
|
666
684
|
|
|
685
|
+
const historyMsgs = Array.isArray(opts.history)
|
|
686
|
+
? opts.history.filter(m => m && m.role && m.content).map(m => ({
|
|
687
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
688
|
+
content: sanitizeForSentinel(String(m.content)),
|
|
689
|
+
}))
|
|
690
|
+
: [];
|
|
667
691
|
const body = {
|
|
668
692
|
model: model || '/opt/models/qwen3-32b',
|
|
669
693
|
max_tokens: opts.max_tokens || (thinkingEnabled ? 16384 : 8192),
|
|
670
694
|
messages: [
|
|
671
695
|
{ role: 'system', content: sanitizeForSentinel(systemPrompt) },
|
|
696
|
+
...historyMsgs,
|
|
672
697
|
{ role: 'user', content: sanitizeForSentinel(userMessage) },
|
|
673
698
|
],
|
|
674
699
|
stream,
|
|
@@ -1212,11 +1237,18 @@ export async function callLLMStream(config, systemPrompt, userMessage, onToken,
|
|
|
1212
1237
|
// 3. Otherwise default to 8192 (full context for specialist agents)
|
|
1213
1238
|
const effectiveMaxTokens = opts.max_tokens || (thinkingEnabled ? 8192 : 8192);
|
|
1214
1239
|
|
|
1240
|
+
const nhaHistory = Array.isArray(opts.history)
|
|
1241
|
+
? opts.history.filter(m => m && m.role && m.content).map(m => ({
|
|
1242
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
1243
|
+
content: sanitize(String(m.content)),
|
|
1244
|
+
}))
|
|
1245
|
+
: [];
|
|
1215
1246
|
const nhaBody = {
|
|
1216
1247
|
model: model || '/opt/models/qwen3-32b',
|
|
1217
1248
|
max_tokens: effectiveMaxTokens,
|
|
1218
1249
|
messages: [
|
|
1219
1250
|
{ role: 'system', content: sanitize(systemPrompt) },
|
|
1251
|
+
...nhaHistory,
|
|
1220
1252
|
{ role: 'user', content: sanitize(userMessage) },
|
|
1221
1253
|
],
|
|
1222
1254
|
stream: false,
|
|
@@ -18,6 +18,28 @@ 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
|
+
function _appendGlobalAudit(entry) {
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(_GLOBAL_AUDIT_FILE), { recursive: true });
|
|
29
|
+
fs.appendFileSync(_GLOBAL_AUDIT_FILE, JSON.stringify(entry) + '\n');
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
function _readGlobalAudit(limitTail = 100) {
|
|
33
|
+
try {
|
|
34
|
+
if (!fs.existsSync(_GLOBAL_AUDIT_FILE)) return [];
|
|
35
|
+
const text = fs.readFileSync(_GLOBAL_AUDIT_FILE, 'utf-8');
|
|
36
|
+
const lines = text.split('\n').filter(Boolean);
|
|
37
|
+
return lines.slice(-limitTail)
|
|
38
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
} catch { return []; }
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
// ── Agent Routing (keyword-based, zero LLM calls) ───────────────────────────
|
|
22
44
|
|
|
23
45
|
const ROUTING_TABLE = [
|
|
@@ -1449,21 +1471,43 @@ class TelegramResponder {
|
|
|
1449
1471
|
_recordAudit(chatId, entry) {
|
|
1450
1472
|
const ctx = this._lastContextByChatId[chatId] || (this._lastContextByChatId[chatId] = {});
|
|
1451
1473
|
if (!Array.isArray(ctx.auditLog)) ctx.auditLog = [];
|
|
1452
|
-
|
|
1474
|
+
const enriched = { ts: Date.now(), channel: chatId, ...entry };
|
|
1475
|
+
ctx.auditLog.push(enriched);
|
|
1453
1476
|
if (ctx.auditLog.length > 50) ctx.auditLog = ctx.auditLog.slice(-50);
|
|
1454
1477
|
this._persistContext();
|
|
1478
|
+
// ── Global audit log (Fix 4 v16.0.12) ──
|
|
1479
|
+
// Append-only JSONL at ~/.nha/audit-log.jsonl shared across every channel
|
|
1480
|
+
// (telegram / discord / chat web / AWF agent). Lets the user ask
|
|
1481
|
+
// "what have you done today?" from any surface and get the same answer.
|
|
1482
|
+
try {
|
|
1483
|
+
_appendGlobalAudit(enriched);
|
|
1484
|
+
} catch {}
|
|
1455
1485
|
}
|
|
1456
1486
|
|
|
1457
1487
|
_renderAuditForPrompt(chatId, maxEntries = 10) {
|
|
1488
|
+
// Pull from BOTH the per-channel context AND the global log so the model
|
|
1489
|
+
// sees actions made via a different channel too.
|
|
1458
1490
|
const ctx = this._lastContextByChatId[chatId];
|
|
1459
|
-
|
|
1460
|
-
|
|
1491
|
+
const local = ctx?.auditLog || [];
|
|
1492
|
+
let globalEntries = [];
|
|
1493
|
+
try { globalEntries = _readGlobalAudit(100); } catch {}
|
|
1494
|
+
// Merge + de-dupe by (ts, tool, summary), keep most recent.
|
|
1495
|
+
const seen = new Set();
|
|
1496
|
+
const merged = [...local, ...globalEntries].sort((a, b) => a.ts - b.ts).filter(e => {
|
|
1497
|
+
const k = `${e.ts}|${e.tool}|${e.summary || ''}`;
|
|
1498
|
+
if (seen.has(k)) return false;
|
|
1499
|
+
seen.add(k);
|
|
1500
|
+
return true;
|
|
1501
|
+
});
|
|
1502
|
+
if (merged.length === 0) return '';
|
|
1503
|
+
const recent = merged.slice(-maxEntries);
|
|
1461
1504
|
const lines = recent.map(e => {
|
|
1462
1505
|
const time = new Date(e.ts).toLocaleString('it-IT', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
1463
1506
|
const status = e.success === false ? '✗ FALLITA' : '✓ OK';
|
|
1464
|
-
|
|
1507
|
+
const chan = e.channel && e.channel !== chatId ? ` [via ${String(e.channel).slice(0, 20)}]` : '';
|
|
1508
|
+
return `- ${time} · ${e.tool} · ${status} · ${e.summary || ''}${chan}`;
|
|
1465
1509
|
});
|
|
1466
|
-
return `\n\n[AZIONI RECENTI ESEGUITE
|
|
1510
|
+
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
1511
|
}
|
|
1468
1512
|
|
|
1469
1513
|
_formatDateIT(isoDate) {
|
|
@@ -1908,6 +1952,52 @@ class TelegramResponder {
|
|
|
1908
1952
|
async _tryDirectFreshCalendarAction(userMessage, config) {
|
|
1909
1953
|
if (!userMessage || typeof userMessage !== 'string') return null;
|
|
1910
1954
|
const lower = userMessage.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
1955
|
+
const chatId = this._lastDirectAuditChatId;
|
|
1956
|
+
const { executeTool: _executeToolPre } = await import('./tool-executor.mjs');
|
|
1957
|
+
|
|
1958
|
+
// ─── ANAPHORIC delete + CONFIRMATION yes ────────────────────────────────
|
|
1959
|
+
// If the previous turn ran a LIST/LAST-SHOWN and the user now says
|
|
1960
|
+
// "cancellalo / eliminalo / quello / si / conferma / fallo", resolve the
|
|
1961
|
+
// referent from this._lastContextByChatId[chatId].lastCalendarEvents.
|
|
1962
|
+
const isAnaphoric = /\b(cancell|elimin|rimuov)[aeiloy]+(lo|la|li|le|gli)?\b/.test(lower)
|
|
1963
|
+
&& !this._extractCalendarProposal(userMessage).date
|
|
1964
|
+
&& !this._extractCalendarProposal(userMessage).title;
|
|
1965
|
+
const isYesConfirm = /^\s*(s[ìi]\b|si\s|sì\s|ok\b|okay\b|certo\b|certamente\b|d'?accordo\b|fai|fallo|procedi|esegui|conferm[oa]|yes\b|yep\b|confirm\b|do\s*it|go\s*ahead)/i.test(userMessage.trim());
|
|
1966
|
+
if (chatId && (isAnaphoric || isYesConfirm)) {
|
|
1967
|
+
const ctx = this._lastContextByChatId[chatId] || {};
|
|
1968
|
+
const pendingEvents = ctx.lastCalendarEvents || ctx.pendingDeleteEvents || [];
|
|
1969
|
+
// Strict: only auto-execute if the previous turn LIST/proposal had a
|
|
1970
|
+
// single deletable event, or if pendingDelete is explicitly set.
|
|
1971
|
+
const eligible = ctx.pendingDeleteEvents && ctx.pendingDeleteEvents.length > 0
|
|
1972
|
+
? ctx.pendingDeleteEvents
|
|
1973
|
+
: (pendingEvents.length === 1 ? pendingEvents : null);
|
|
1974
|
+
if (eligible && eligible.length > 0) {
|
|
1975
|
+
let ok = 0, ko = 0;
|
|
1976
|
+
const failed = [];
|
|
1977
|
+
for (const ev of eligible) {
|
|
1978
|
+
if (!ev.eventId) { ko++; failed.push(ev.summary || '(no id)'); continue; }
|
|
1979
|
+
try {
|
|
1980
|
+
await _executeToolPre('calendar_delete', { eventId: ev.eventId }, config);
|
|
1981
|
+
ok++;
|
|
1982
|
+
} catch (e) {
|
|
1983
|
+
ko++;
|
|
1984
|
+
failed.push(`${ev.summary || ev.eventId} (${e.message?.slice(0, 60) || 'err'})`);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
// Clear the pending state so we don't double-delete on next yes.
|
|
1988
|
+
delete this._lastContextByChatId[chatId].pendingDeleteEvents;
|
|
1989
|
+
delete this._lastContextByChatId[chatId].lastCalendarEvents;
|
|
1990
|
+
try { (await import('./telegram-context.mjs')).saveTelegramContext(this._lastContextByChatId); } catch {}
|
|
1991
|
+
|
|
1992
|
+
const subject = eligible.length === 1 ? `"${eligible[0].summary}"` : `${eligible.length} appuntamenti`;
|
|
1993
|
+
const lines = [`Ho cancellato ${subject}.`];
|
|
1994
|
+
if (ko > 0) lines.push(`Non sono riuscito a cancellarne ${ko}: ${failed.slice(0, 3).join(', ')}`);
|
|
1995
|
+
this._recordAudit(chatId, { tool: 'calendar_delete', success: ok > 0, summary: `cancellati ${ok}` });
|
|
1996
|
+
return { action: 'calendar_delete', success: ok > 0, message: lines.join('\n') };
|
|
1997
|
+
}
|
|
1998
|
+
// No eligible referent — fall through; the LLM will ask for clarification.
|
|
1999
|
+
}
|
|
2000
|
+
|
|
1911
2001
|
|
|
1912
2002
|
const MONTHS_IT = { gennaio:1, febbraio:2, marzo:3, aprile:4, maggio:5, giugno:6, luglio:7, agosto:8, settembre:9, ottobre:10, novembre:11, dicembre:12 };
|
|
1913
2003
|
const MONTHS_EN = { january:1, february:2, march:3, april:4, may:5, june:6, july:7, august:8, september:9, october:10, november:11, december:12, jan:1, feb:2, mar:3, apr:4, jun:6, jul:7, aug:8, sep:9, oct:10, nov:11, dec:12 };
|
|
@@ -2090,54 +2180,44 @@ class TelegramResponder {
|
|
|
2090
2180
|
|
|
2091
2181
|
// ─── LIST intents ──────────────────────────────────────────────────────
|
|
2092
2182
|
if (isList && !isDelete && !isVerify && !isCreate && !isMove) {
|
|
2093
|
-
//
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
const out = await executeTool('calendar_today', {}, config);
|
|
2097
|
-
return { action: 'calendar_today', success: true, message: String(out) };
|
|
2098
|
-
} catch (e) { return { action: 'calendar_today', success: false, message: `Errore: ${e.message}` }; }
|
|
2099
|
-
}
|
|
2100
|
-
// "appuntamenti di domani"
|
|
2101
|
-
if (/\b(domani|tomorrow)\b/.test(lower)) {
|
|
2183
|
+
// Helper that runs the tool, parses the events and remembers them for
|
|
2184
|
+
// anaphoric resolution in the NEXT turn ("cancellalo", "spostali tutti").
|
|
2185
|
+
const runListAndRemember = async (toolName, args, actionKey) => {
|
|
2102
2186
|
try {
|
|
2103
|
-
const out = await executeTool(
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2187
|
+
const out = await executeTool(toolName, args, config);
|
|
2188
|
+
const events = this._parseEventsFromToolOutput(String(out));
|
|
2189
|
+
if (chatId) {
|
|
2190
|
+
const prev = this._lastContextByChatId[chatId] || {};
|
|
2191
|
+
this._lastContextByChatId[chatId] = {
|
|
2192
|
+
...prev,
|
|
2193
|
+
lastCalendarEvents: events,
|
|
2194
|
+
lastCalendarListAt: Date.now(),
|
|
2195
|
+
lastCalendarSource: { tool: toolName, args },
|
|
2196
|
+
};
|
|
2197
|
+
try { (await import('./telegram-context.mjs')).saveTelegramContext(this._lastContextByChatId); } catch {}
|
|
2198
|
+
}
|
|
2199
|
+
return { action: actionKey, success: true, message: String(out) };
|
|
2200
|
+
} catch (e) { return { action: actionKey, success: false, message: `Errore: ${e.message}` }; }
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
if (/\b(oggi|today)\b/.test(lower))
|
|
2204
|
+
return await runListAndRemember('calendar_today', {}, 'calendar_today');
|
|
2205
|
+
if (/\b(domani|tomorrow)\b/.test(lower))
|
|
2206
|
+
return await runListAndRemember('calendar_tomorrow', {}, 'calendar_tomorrow');
|
|
2207
|
+
if (/\b(settimana|week|questa\s+settimana|this\s+week)\b/.test(lower))
|
|
2208
|
+
return await runListAndRemember('calendar_week', {}, 'calendar_week');
|
|
2115
2209
|
const monthMatch = lower.match(new RegExp(`\\b(${Object.keys(MONTH_MAP).join('|')})(?:\\s+(20\\d{2}))?\\b`));
|
|
2116
2210
|
if (monthMatch) {
|
|
2117
2211
|
const monthNum = MONTH_MAP[monthMatch[1]];
|
|
2118
2212
|
const yearNum = parseInt(monthMatch[2] || String(new Date().getFullYear()), 10);
|
|
2119
|
-
// calendar_month accepts a month string like "2026-05"
|
|
2120
2213
|
const monthStr = `${yearNum}-${String(monthNum).padStart(2, '0')}`;
|
|
2121
|
-
|
|
2122
|
-
const out = await executeTool('calendar_month', { month: monthStr }, config);
|
|
2123
|
-
return { action: 'calendar_month', success: true, message: String(out) };
|
|
2124
|
-
} catch (e) { return { action: 'calendar_month', success: false, message: `Errore: ${e.message}` }; }
|
|
2214
|
+
return await runListAndRemember('calendar_month', { month: monthStr }, 'calendar_month');
|
|
2125
2215
|
}
|
|
2126
|
-
// "appuntamenti del 15 maggio" — specific date
|
|
2127
2216
|
const dateExtracted = this._extractCalendarProposal(userMessage);
|
|
2128
|
-
if (dateExtracted.date)
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
} catch (e) { return { action: 'calendar_date', success: false, message: `Errore: ${e.message}` }; }
|
|
2133
|
-
}
|
|
2134
|
-
// Generic "appuntamenti prossimi" → upcoming 48h
|
|
2135
|
-
if (/\b(prossim|next|upcoming|in\s+arrivo)/.test(lower)) {
|
|
2136
|
-
try {
|
|
2137
|
-
const out = await executeTool('calendar_upcoming', { hours: 48 }, config);
|
|
2138
|
-
return { action: 'calendar_upcoming', success: true, message: String(out) };
|
|
2139
|
-
} catch (e) { return { action: 'calendar_upcoming', success: false, message: `Errore: ${e.message}` }; }
|
|
2140
|
-
}
|
|
2217
|
+
if (dateExtracted.date)
|
|
2218
|
+
return await runListAndRemember('calendar_date', { date: dateExtracted.date }, 'calendar_date');
|
|
2219
|
+
if (/\b(prossim|next|upcoming|in\s+arrivo)/.test(lower))
|
|
2220
|
+
return await runListAndRemember('calendar_upcoming', { hours: 48 }, 'calendar_upcoming');
|
|
2141
2221
|
// Fall through — let the LLM handle ambiguous list requests.
|
|
2142
2222
|
}
|
|
2143
2223
|
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
}
|