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.
- package/package.json +10 -3
- package/src/cli.mjs +181 -5
- package/src/commands/autostart.mjs +342 -0
- package/src/commands/chat.mjs +14 -8
- package/src/commands/microsoft-auth.mjs +29 -0
- package/src/commands/ops.mjs +37 -0
- package/src/commands/plugin.mjs +481 -0
- package/src/commands/ui.mjs +28 -7
- package/src/commands/voice.mjs +845 -0
- package/src/config.mjs +61 -0
- package/src/constants.mjs +9 -1
- package/src/services/llm.mjs +22 -1
- package/src/services/mail-router.mjs +298 -0
- package/src/services/memory.mjs +627 -0
- package/src/services/message-responder.mjs +778 -0
- package/src/services/microsoft-calendar.mjs +319 -0
- package/src/services/microsoft-mail.mjs +308 -0
- package/src/services/microsoft-oauth.mjs +345 -0
- package/src/services/ops-daemon.mjs +620 -11
- package/src/services/ops-pipeline.mjs +7 -8
- package/src/services/token-store.mjs +41 -14
- package/src/services/tool-executor.mjs +392 -0
- package/src/services/web-ui.mjs +187 -1
|
@@ -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
|
+
}
|