nothumanallowed 16.0.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "16.0.11",
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.11';
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
- // 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 = [];
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
- 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 ? '...' : ''}`);
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
- for (const t of rawHistory.slice(-RECENT)) {
240
- parts.push(`${t.role === 'user' ? '[User]' : '[Assistant]'} ${t.content.slice(0, 2000)}`);
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
- // 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');
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 = [];
@@ -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, {
@@ -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
- ctx.auditLog.push({ ts: Date.now(), ...entry });
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
- if (!ctx || !Array.isArray(ctx.auditLog) || ctx.auditLog.length === 0) return '';
1460
- const recent = ctx.auditLog.slice(-maxEntries);
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
- return `- ${time} · ${e.tool} · ${status} · ${e.summary || ''}`;
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 IN QUESTA CONVERSAZIONE — fonte di verità sui fatti già accaduti]\n${lines.join('\n')}\n[FINE AZIONI RECENTI]\n`;
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) {
@@ -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
+ }