nothumanallowed 4.1.0 → 6.0.0

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.
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Per-agent episodic memory with TF-IDF keyword retrieval.
3
+ *
4
+ * Zero dependencies — pure local keyword matching.
5
+ * Each agent gets its own memory file at ~/.nha/memory/<agent-name>.json
6
+ *
7
+ * Memory entries are created AFTER each agent interaction by extracting
8
+ * key facts from the response using heuristic rules (no LLM calls).
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import crypto from 'crypto';
14
+ import { MEMORY_DIR } from '../constants.mjs';
15
+
16
+ // ── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ const MAX_ENTRIES_PER_AGENT = 100;
19
+ const MAX_CHAT_HISTORY_TURNS = 200;
20
+ const MAX_MEMORY_CONTEXT_CHARS = 500;
21
+ const CHAT_HISTORY_FILE = path.join(MEMORY_DIR, 'chat-history.json');
22
+ const GLOBAL_PREFS_FILE = path.join(MEMORY_DIR, '_global-preferences.json');
23
+
24
+ /** Stopwords excluded from keyword extraction and TF-IDF scoring. */
25
+ const STOPWORDS = new Set([
26
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
27
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
28
+ 'should', 'may', 'might', 'shall', 'can', 'need', 'must', 'to', 'of',
29
+ 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through',
30
+ 'during', 'before', 'after', 'above', 'below', 'between', 'under', 'and',
31
+ 'but', 'or', 'nor', 'not', 'so', 'yet', 'both', 'either', 'neither',
32
+ 'each', 'every', 'all', 'any', 'few', 'more', 'most', 'other', 'some',
33
+ 'such', 'no', 'only', 'same', 'than', 'too', 'very', 'just', 'because',
34
+ 'if', 'when', 'where', 'how', 'what', 'which', 'who', 'whom', 'this',
35
+ 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our', 'you', 'your',
36
+ 'he', 'him', 'his', 'she', 'her', 'it', 'its', 'they', 'them', 'their',
37
+ 'also', 'about', 'up', 'out', 'then', 'here', 'there', 'now', 'one',
38
+ 'two', 'like', 'over', 'well', 'even', 'back', 'much', 'get', 'go',
39
+ 'make', 'think', 'know', 'take', 'see', 'come', 'look', 'use', 'find',
40
+ 'give', 'tell', 'say', 'said', 'want', 'way', 'thing', 'things', 'let',
41
+ 'still', 'try', 'ask', 'work', 'call', 'first', 'last', 'long', 'great',
42
+ 'little', 'own', 'old', 'right', 'big', 'high', 'different', 'small',
43
+ 'large', 'next', 'early', 'young', 'important', 'public', 'bad', 'new',
44
+ 'good', 'sure', 'yes', 'no', 'dont', 'dont', 'ive', 'its', 'thats',
45
+ 'really', 'using', 'used', 'based', 'note', 'please', 'help', 'thanks',
46
+ ]);
47
+
48
+ // ── Agent-specific extraction patterns ───────────────────────────────────────
49
+
50
+ const AGENT_PATTERNS = {
51
+ saber: {
52
+ keywords: ['vulnerability', 'cve', 'exploit', 'injection', 'xss', 'csrf',
53
+ 'sqli', 'rce', 'ssrf', 'auth', 'bypass', 'privilege', 'escalation',
54
+ 'misconfiguration', 'exposure', 'leak', 'breach', 'scan', 'pentest',
55
+ 'hardening', 'firewall', 'tls', 'certificate', 'encryption', 'hash',
56
+ 'secret', 'token', 'owasp', 'severity', 'critical', 'high', 'medium',
57
+ 'low', 'patch', 'remediation', 'mitigation'],
58
+ importanceBoost: 1,
59
+ },
60
+ oracle: {
61
+ keywords: ['metric', 'pattern', 'trend', 'analysis', 'data', 'insight',
62
+ 'correlation', 'regression', 'anomaly', 'outlier', 'forecast',
63
+ 'prediction', 'accuracy', 'precision', 'recall', 'f1', 'score',
64
+ 'performance', 'latency', 'throughput', 'error-rate', 'uptime',
65
+ 'degradation', 'improvement', 'baseline', 'benchmark'],
66
+ importanceBoost: 0,
67
+ },
68
+ herald: {
69
+ keywords: ['colleague', 'team', 'meeting', 'standup', 'retro', 'sprint',
70
+ 'communication', 'email', 'slack', 'mention', 'feedback', 'review',
71
+ 'decision', 'action-item', 'follow-up', 'deadline', 'blocker',
72
+ 'dependency', 'stakeholder', 'manager', 'report'],
73
+ importanceBoost: 0,
74
+ },
75
+ conductor: {
76
+ keywords: ['workflow', 'pipeline', 'task', 'automation', 'schedule',
77
+ 'cron', 'trigger', 'step', 'stage', 'deployment', 'build', 'test',
78
+ 'ci', 'cd', 'release', 'rollback', 'retry', 'timeout', 'parallel',
79
+ 'sequential', 'dependency', 'orchestration', 'completion', 'failure'],
80
+ importanceBoost: 0,
81
+ },
82
+ };
83
+
84
+ // ── Universal extraction patterns ────────────────────────────────────────────
85
+
86
+ /** Patterns that indicate a user preference or correction. */
87
+ const PREFERENCE_PATTERNS = [
88
+ /(?:i\s+prefer|i\s+like|i\s+want|i\s+always|i\s+never|i\s+usually|my\s+preference|please\s+always|please\s+never|don'?t\s+(?:ever|always))\s+(.{10,120})/gi,
89
+ /(?:remember\s+that|keep\s+in\s+mind|note\s+that|fyi|for\s+future\s+reference)\s+(.{10,150})/gi,
90
+ /(?:actually|no,?\s+i\s+meant|correction:|i\s+meant|that'?s\s+wrong|not\s+(?:that|what))\s+(.{10,120})/gi,
91
+ ];
92
+
93
+ /** Patterns that indicate a factual finding worth remembering. */
94
+ const FINDING_PATTERNS = [
95
+ /(?:found|discovered|detected|identified|noticed|observed)\s+(.{15,200})/gi,
96
+ /(?:the\s+(?:issue|problem|bug|error|cause|root\s+cause|solution|fix)\s+(?:is|was))\s+(.{10,200})/gi,
97
+ /(?:recommend(?:ation)?|suggest(?:ion)?|advise|should)\s*:?\s+(.{15,200})/gi,
98
+ /(?:result|conclusion|summary|finding)\s*:?\s+(.{15,200})/gi,
99
+ ];
100
+
101
+ // ── File I/O ─────────────────────────────────────────────────────────────────
102
+
103
+ function ensureMemoryDir() {
104
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
105
+ }
106
+
107
+ function getAgentMemoryPath(agentName) {
108
+ const sanitized = agentName.replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
109
+ return path.join(MEMORY_DIR, `${sanitized}.json`);
110
+ }
111
+
112
+ function readMemoryFile(filePath) {
113
+ try {
114
+ if (fs.existsSync(filePath)) {
115
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
116
+ }
117
+ } catch { /* corrupt file — start fresh */ }
118
+ return [];
119
+ }
120
+
121
+ function writeMemoryFile(filePath, entries) {
122
+ ensureMemoryDir();
123
+ fs.writeFileSync(filePath, JSON.stringify(entries, null, 2) + '\n', 'utf-8');
124
+ }
125
+
126
+ // ── Text Processing (TF-IDF) ────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Tokenize text into lowercase alphanumeric terms, remove stopwords.
130
+ * @param {string} text
131
+ * @returns {string[]}
132
+ */
133
+ function tokenize(text) {
134
+ if (!text || typeof text !== 'string') return [];
135
+ return text
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9\s-]/g, ' ')
138
+ .split(/\s+/)
139
+ .filter(t => t.length > 2 && !STOPWORDS.has(t));
140
+ }
141
+
142
+ /**
143
+ * Compute term frequency map for a token array.
144
+ * @param {string[]} tokens
145
+ * @returns {Map<string, number>}
146
+ */
147
+ function termFrequency(tokens) {
148
+ const tf = new Map();
149
+ for (const t of tokens) {
150
+ tf.set(t, (tf.get(t) || 0) + 1);
151
+ }
152
+ // Normalize by total tokens
153
+ const total = tokens.length || 1;
154
+ for (const [k, v] of tf) {
155
+ tf.set(k, v / total);
156
+ }
157
+ return tf;
158
+ }
159
+
160
+ /**
161
+ * Compute inverse document frequency for a term across a corpus of entries.
162
+ * @param {string} term
163
+ * @param {Array<{keywords: string[]}>} corpus
164
+ * @returns {number}
165
+ */
166
+ function inverseDocFreq(term, corpus) {
167
+ if (corpus.length === 0) return 1;
168
+ let count = 0;
169
+ for (const doc of corpus) {
170
+ if (doc.keywords && doc.keywords.includes(term)) count++;
171
+ }
172
+ // Smooth IDF: log(N / (1 + df)) + 1
173
+ return Math.log(corpus.length / (1 + count)) + 1;
174
+ }
175
+
176
+ /**
177
+ * Score a memory entry against a query using TF-IDF keyword overlap.
178
+ * Higher = more relevant.
179
+ *
180
+ * @param {object} entry — memory entry with { keywords, summary, context }
181
+ * @param {string[]} queryTokens — tokenized query
182
+ * @param {Array<object>} corpus — all entries for IDF computation
183
+ * @returns {number}
184
+ */
185
+ function scoreEntry(entry, queryTokens, corpus) {
186
+ if (!entry.keywords || entry.keywords.length === 0) return 0;
187
+
188
+ const queryTf = termFrequency(queryTokens);
189
+ let score = 0;
190
+
191
+ for (const [term, tf] of queryTf) {
192
+ if (entry.keywords.includes(term)) {
193
+ const idf = inverseDocFreq(term, corpus);
194
+ score += tf * idf;
195
+ }
196
+ }
197
+
198
+ // Bonus for importance (slight boost for high-importance memories)
199
+ score *= 1 + (entry.importance - 3) * 0.1;
200
+
201
+ // Recency bonus: memories from last 24h get a small boost
202
+ const ageHours = (Date.now() - new Date(entry.timestamp).getTime()) / 3600000;
203
+ if (ageHours < 24) score *= 1.15;
204
+ else if (ageHours < 168) score *= 1.05; // last 7 days
205
+
206
+ return score;
207
+ }
208
+
209
+ // ── Keyword Extraction ───────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Extract the top-N most distinctive keywords from text.
213
+ * Uses term frequency ranking — no external embedding needed.
214
+ *
215
+ * @param {string} text
216
+ * @param {number} maxKeywords
217
+ * @returns {string[]}
218
+ */
219
+ function extractKeywords(text, maxKeywords = 15) {
220
+ const tokens = tokenize(text);
221
+ const freq = new Map();
222
+ for (const t of tokens) {
223
+ freq.set(t, (freq.get(t) || 0) + 1);
224
+ }
225
+
226
+ // Sort by frequency descending, then alphabetically for stability
227
+ return Array.from(freq.entries())
228
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
229
+ .slice(0, maxKeywords)
230
+ .map(([term]) => term);
231
+ }
232
+
233
+ // ── Heuristic Memory Extraction ──────────────────────────────────────────────
234
+
235
+ /**
236
+ * Extract a concise summary from the agent's response using heuristics.
237
+ * Returns the first meaningful sentence or pattern match.
238
+ *
239
+ * @param {string} response
240
+ * @returns {string}
241
+ */
242
+ function extractSummary(response) {
243
+ if (!response) return '';
244
+
245
+ // Try to find a key finding or recommendation
246
+ for (const pattern of FINDING_PATTERNS) {
247
+ pattern.lastIndex = 0;
248
+ const match = pattern.exec(response);
249
+ if (match && match[1]) {
250
+ return match[1].trim().slice(0, 200);
251
+ }
252
+ }
253
+
254
+ // Fallback: first non-trivial sentence (>30 chars, not a greeting)
255
+ const sentences = response
256
+ .replace(/```[\s\S]*?```/g, '') // strip code blocks
257
+ .replace(/\n+/g, '. ')
258
+ .split(/[.!?]+/)
259
+ .map(s => s.trim())
260
+ .filter(s => s.length > 30 && !/^(hi|hello|hey|sure|ok|great|thanks)/i.test(s));
261
+
262
+ if (sentences.length > 0) {
263
+ return sentences[0].slice(0, 200);
264
+ }
265
+
266
+ // Last resort: first 200 chars
267
+ return response.replace(/\s+/g, ' ').trim().slice(0, 200);
268
+ }
269
+
270
+ /**
271
+ * Determine importance (1-5) based on content analysis.
272
+ *
273
+ * @param {string} agentName
274
+ * @param {string} userQuery
275
+ * @param {string} agentResponse
276
+ * @returns {number}
277
+ */
278
+ function computeImportance(agentName, userQuery, agentResponse) {
279
+ const combined = (userQuery + ' ' + agentResponse).toLowerCase();
280
+ let importance = 3; // default: medium
281
+
282
+ // Check for agent-specific high-value keywords
283
+ const agentConfig = AGENT_PATTERNS[agentName];
284
+ if (agentConfig) {
285
+ let domainHits = 0;
286
+ for (const kw of agentConfig.keywords) {
287
+ if (combined.includes(kw)) domainHits++;
288
+ }
289
+ if (domainHits >= 5) importance += 1;
290
+ if (domainHits >= 10) importance += 1;
291
+ importance += agentConfig.importanceBoost;
292
+ }
293
+
294
+ // User corrections/preferences are always high importance
295
+ for (const pattern of PREFERENCE_PATTERNS) {
296
+ pattern.lastIndex = 0;
297
+ if (pattern.test(userQuery)) {
298
+ importance = Math.max(importance, 4);
299
+ break;
300
+ }
301
+ }
302
+
303
+ // Errors/vulnerabilities are high importance
304
+ if (/critical|severe|urgent|emergency|breach|exploit|vulnerab/i.test(combined)) {
305
+ importance = Math.max(importance, 4);
306
+ }
307
+
308
+ // Clamp to [1, 5]
309
+ return Math.max(1, Math.min(5, importance));
310
+ }
311
+
312
+ /**
313
+ * Detect if the user query contains a preference or correction.
314
+ * @param {string} query
315
+ * @returns {string|null} — the preference text, or null
316
+ */
317
+ function detectPreference(query) {
318
+ for (const pattern of PREFERENCE_PATTERNS) {
319
+ pattern.lastIndex = 0;
320
+ const match = pattern.exec(query);
321
+ if (match && match[1]) {
322
+ return match[1].trim().slice(0, 150);
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+
328
+ // ── Public API ───────────────────────────────────────────────────────────────
329
+
330
+ /**
331
+ * Save a new memory entry for an agent.
332
+ *
333
+ * @param {string} agentName
334
+ * @param {{summary: string, keywords: string[], context: string, importance: number}} entry
335
+ * @returns {object} — the saved entry (with id and timestamp added)
336
+ */
337
+ export function saveMemory(agentName, entry) {
338
+ const filePath = getAgentMemoryPath(agentName);
339
+ const entries = readMemoryFile(filePath);
340
+
341
+ const record = {
342
+ id: crypto.randomUUID(),
343
+ timestamp: new Date().toISOString(),
344
+ summary: entry.summary || '',
345
+ keywords: entry.keywords || [],
346
+ context: entry.context || '',
347
+ importance: Math.max(1, Math.min(5, entry.importance || 3)),
348
+ };
349
+
350
+ entries.push(record);
351
+ writeMemoryFile(filePath, entries);
352
+
353
+ // Auto-prune if over limit
354
+ if (entries.length > MAX_ENTRIES_PER_AGENT) {
355
+ pruneMemories(agentName);
356
+ }
357
+
358
+ return record;
359
+ }
360
+
361
+ /**
362
+ * Retrieve the most relevant memories for a query, ranked by TF-IDF score.
363
+ *
364
+ * @param {string} agentName
365
+ * @param {string} query
366
+ * @param {number} limit — max results (default 5)
367
+ * @returns {object[]} — ranked memory entries
368
+ */
369
+ export function getRelevantMemories(agentName, query, limit = 5) {
370
+ const filePath = getAgentMemoryPath(agentName);
371
+ const entries = readMemoryFile(filePath);
372
+ if (entries.length === 0) return [];
373
+
374
+ const queryTokens = tokenize(query);
375
+ if (queryTokens.length === 0) {
376
+ // No meaningful query tokens — return most recent high-importance entries
377
+ return entries
378
+ .filter(e => e.importance >= 3)
379
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
380
+ .slice(0, limit);
381
+ }
382
+
383
+ // Score all entries
384
+ const scored = entries.map(entry => ({
385
+ entry,
386
+ score: scoreEntry(entry, queryTokens, entries),
387
+ }));
388
+
389
+ // Filter zero-score entries, sort by score descending
390
+ return scored
391
+ .filter(s => s.score > 0)
392
+ .sort((a, b) => b.score - a.score)
393
+ .slice(0, limit)
394
+ .map(s => s.entry);
395
+ }
396
+
397
+ /**
398
+ * Get all memories for an agent, sorted by timestamp (newest first).
399
+ *
400
+ * @param {string} agentName
401
+ * @returns {object[]}
402
+ */
403
+ export function getAllMemories(agentName) {
404
+ const filePath = getAgentMemoryPath(agentName);
405
+ return readMemoryFile(filePath)
406
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
407
+ }
408
+
409
+ /**
410
+ * Prune memories: keep at most MAX_ENTRIES_PER_AGENT.
411
+ * Strategy: remove oldest entries with importance <= 2 first,
412
+ * then oldest importance 3, preserving importance 4-5 as long as possible.
413
+ *
414
+ * @param {string} agentName
415
+ * @returns {number} — number of entries removed
416
+ */
417
+ export function pruneMemories(agentName) {
418
+ const filePath = getAgentMemoryPath(agentName);
419
+ const entries = readMemoryFile(filePath);
420
+
421
+ if (entries.length <= MAX_ENTRIES_PER_AGENT) return 0;
422
+
423
+ const before = entries.length;
424
+
425
+ // Sort: high importance first, then recent first
426
+ entries.sort((a, b) => {
427
+ if (b.importance !== a.importance) return b.importance - a.importance;
428
+ return new Date(b.timestamp) - new Date(a.timestamp);
429
+ });
430
+
431
+ // Keep only the top MAX_ENTRIES_PER_AGENT
432
+ const pruned = entries.slice(0, MAX_ENTRIES_PER_AGENT);
433
+
434
+ // Re-sort by timestamp for storage
435
+ pruned.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
436
+
437
+ writeMemoryFile(filePath, pruned);
438
+ return before - pruned.length;
439
+ }
440
+
441
+ /**
442
+ * Auto-extract key facts from an agent interaction and save as a memory.
443
+ * Uses heuristic patterns — zero LLM calls.
444
+ *
445
+ * @param {string} agentName
446
+ * @param {string} userQuery
447
+ * @param {string} agentResponse
448
+ * @returns {object|null} — the saved entry, or null if nothing worth remembering
449
+ */
450
+ export function extractMemory(agentName, userQuery, agentResponse) {
451
+ if (!agentResponse || agentResponse.length < 50) return null;
452
+
453
+ const summary = extractSummary(agentResponse);
454
+ if (!summary || summary.length < 20) return null;
455
+
456
+ const combined = userQuery + ' ' + agentResponse;
457
+ const keywords = extractKeywords(combined);
458
+ if (keywords.length < 3) return null;
459
+
460
+ const importance = computeImportance(agentName, userQuery, agentResponse);
461
+
462
+ // Build concise context: what was asked + key finding
463
+ const querySnippet = userQuery.slice(0, 100);
464
+ const context = `Q: ${querySnippet} | A: ${summary}`;
465
+
466
+ const entry = saveMemory(agentName, {
467
+ summary,
468
+ keywords,
469
+ context,
470
+ importance,
471
+ });
472
+
473
+ // Check for user preferences to save globally
474
+ const preference = detectPreference(userQuery);
475
+ if (preference) {
476
+ saveGlobalPreference(agentName, preference);
477
+ }
478
+
479
+ return entry;
480
+ }
481
+
482
+ /**
483
+ * Build a concise memory context string to inject into an agent's prompt.
484
+ * Capped at MAX_MEMORY_CONTEXT_CHARS.
485
+ *
486
+ * @param {string} agentName
487
+ * @param {string} query
488
+ * @returns {string} — memory context block, or empty string if no relevant memories
489
+ */
490
+ export function buildMemoryContext(agentName, query) {
491
+ const memories = getRelevantMemories(agentName, query, 5);
492
+ const globalCtx = getGlobalContext();
493
+
494
+ if (memories.length === 0 && !globalCtx) return '';
495
+
496
+ const parts = [];
497
+
498
+ if (globalCtx) {
499
+ parts.push(`[User prefs] ${globalCtx}`);
500
+ }
501
+
502
+ for (const mem of memories) {
503
+ const age = formatAge(mem.timestamp);
504
+ parts.push(`[${age}] ${mem.summary}`);
505
+ }
506
+
507
+ let context = parts.join(' | ');
508
+
509
+ // Enforce character limit
510
+ if (context.length > MAX_MEMORY_CONTEXT_CHARS) {
511
+ context = context.slice(0, MAX_MEMORY_CONTEXT_CHARS - 3) + '...';
512
+ }
513
+
514
+ return `\n\n[EPISODIC MEMORY — relevant past interactions]\n${context}`;
515
+ }
516
+
517
+ // ── Global Preferences ───────────────────────────────────────────────────────
518
+
519
+ /**
520
+ * Save a user preference detected across any agent interaction.
521
+ *
522
+ * @param {string} sourceAgent
523
+ * @param {string} preference
524
+ */
525
+ function saveGlobalPreference(sourceAgent, preference) {
526
+ ensureMemoryDir();
527
+ let prefs = [];
528
+ try {
529
+ if (fs.existsSync(GLOBAL_PREFS_FILE)) {
530
+ prefs = JSON.parse(fs.readFileSync(GLOBAL_PREFS_FILE, 'utf-8'));
531
+ }
532
+ } catch { prefs = []; }
533
+
534
+ // Deduplicate: skip if a very similar preference already exists
535
+ const normalized = preference.toLowerCase().replace(/\s+/g, ' ');
536
+ for (const existing of prefs) {
537
+ const existingNorm = existing.text.toLowerCase().replace(/\s+/g, ' ');
538
+ if (existingNorm === normalized) return;
539
+ // Jaccard similarity on tokens — skip if >70% overlap
540
+ const a = new Set(tokenize(normalized));
541
+ const b = new Set(tokenize(existingNorm));
542
+ const intersection = [...a].filter(x => b.has(x)).length;
543
+ const union = new Set([...a, ...b]).size;
544
+ if (union > 0 && intersection / union > 0.7) return;
545
+ }
546
+
547
+ prefs.push({
548
+ text: preference,
549
+ source: sourceAgent,
550
+ timestamp: new Date().toISOString(),
551
+ });
552
+
553
+ // Cap at 50 preferences
554
+ if (prefs.length > 50) {
555
+ prefs = prefs.slice(-50);
556
+ }
557
+
558
+ fs.writeFileSync(GLOBAL_PREFS_FILE, JSON.stringify(prefs, null, 2) + '\n', 'utf-8');
559
+ }
560
+
561
+ /**
562
+ * Get a cross-agent summary of user preferences.
563
+ * Returns a concise string (max 200 chars) of the most recent preferences.
564
+ *
565
+ * @returns {string} — preferences summary, or empty string
566
+ */
567
+ export function getGlobalContext() {
568
+ try {
569
+ if (!fs.existsSync(GLOBAL_PREFS_FILE)) return '';
570
+ const prefs = JSON.parse(fs.readFileSync(GLOBAL_PREFS_FILE, 'utf-8'));
571
+ if (!prefs || prefs.length === 0) return '';
572
+
573
+ // Take the 5 most recent preferences
574
+ const recent = prefs.slice(-5);
575
+ const summary = recent.map(p => p.text).join('; ');
576
+ return summary.length > 200 ? summary.slice(0, 197) + '...' : summary;
577
+ } catch {
578
+ return '';
579
+ }
580
+ }
581
+
582
+ // ── Chat History Persistence ─────────────────────────────────────────────────
583
+
584
+ /**
585
+ * Load persisted chat history (last N turns).
586
+ *
587
+ * @returns {Array<{role: string, content: string}>}
588
+ */
589
+ export function loadChatHistory() {
590
+ try {
591
+ if (fs.existsSync(CHAT_HISTORY_FILE)) {
592
+ const data = JSON.parse(fs.readFileSync(CHAT_HISTORY_FILE, 'utf-8'));
593
+ if (Array.isArray(data)) return data.slice(-MAX_CHAT_HISTORY_TURNS * 2);
594
+ }
595
+ } catch { /* corrupt — start fresh */ }
596
+ return [];
597
+ }
598
+
599
+ /**
600
+ * Persist chat history to disk.
601
+ * Keeps only the last MAX_CHAT_HISTORY_TURNS pairs.
602
+ *
603
+ * @param {Array<{role: string, content: string}>} history
604
+ */
605
+ export function saveChatHistory(history) {
606
+ ensureMemoryDir();
607
+ const trimmed = history.slice(-MAX_CHAT_HISTORY_TURNS * 2);
608
+ fs.writeFileSync(CHAT_HISTORY_FILE, JSON.stringify(trimmed, null, 2) + '\n', 'utf-8');
609
+ }
610
+
611
+ // ── Helpers ──────────────────────────────────────────────────────────────────
612
+
613
+ /**
614
+ * Format a timestamp into a human-readable age string.
615
+ * @param {string} isoTimestamp
616
+ * @returns {string}
617
+ */
618
+ function formatAge(isoTimestamp) {
619
+ const ms = Date.now() - new Date(isoTimestamp).getTime();
620
+ const hours = Math.floor(ms / 3600000);
621
+ if (hours < 1) return 'just now';
622
+ if (hours < 24) return `${hours}h ago`;
623
+ const days = Math.floor(hours / 24);
624
+ if (days < 7) return `${days}d ago`;
625
+ const weeks = Math.floor(days / 7);
626
+ return `${weeks}w ago`;
627
+ }