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 +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 +101 -22
- package/src/server/routes/config.mjs +18 -0
- package/src/services/llm.mjs +55 -2
- package/src/services/message-responder.mjs +122 -8
- package/src/services/user-memory.mjs +128 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "16.0.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
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, {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|