kiku-agent 1.0.0 → 1.0.4

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.
Files changed (40) hide show
  1. package/README.md +293 -122
  2. package/package.json +60 -61
  3. package/server/agent/agentCore.js +892 -782
  4. package/server/agent/appPaths.js +21 -0
  5. package/server/agent/audioPlayer.js +739 -673
  6. package/server/agent/cli.js +1414 -1111
  7. package/server/agent/commandRegistry.js +74 -58
  8. package/server/agent/commands.js +1694 -788
  9. package/server/agent/config.js +264 -90
  10. package/server/agent/contextCompactor.js +79 -0
  11. package/server/agent/doctor.js +69 -0
  12. package/server/agent/eventBus.js +107 -103
  13. package/server/agent/gpuMetrics.js +189 -0
  14. package/server/agent/llmClient.js +369 -200
  15. package/server/agent/mcpClient.js +364 -0
  16. package/server/agent/memory.js +670 -566
  17. package/server/agent/modelManager.js +77 -0
  18. package/server/agent/research.js +202 -0
  19. package/server/agent/session.js +244 -239
  20. package/server/agent/subagent.js +116 -0
  21. package/server/agent/theme.js +125 -124
  22. package/server/agent/toolRouter.js +201 -0
  23. package/server/agent/tools.js +1679 -1528
  24. package/server/agent/ui.js +760 -469
  25. package/server/agent/youtubeAuth.js +144 -65
  26. package/server/mcp/claude-desktop-config.example.json +1 -1
  27. package/server/mcp/data/discography.js +36 -36
  28. package/server/mcp/dataAdapter.js +102 -0
  29. package/server/mcp/examples/claude-code.mcp.example.json +19 -0
  30. package/server/mcp/examples/claude-desktop.example.json +11 -0
  31. package/server/mcp/examples/cline.example.json +13 -0
  32. package/server/mcp/examples/cursor.example.json +14 -0
  33. package/server/mcp/examples/gemini-cli.example.json +17 -0
  34. package/server/mcp/examples/opencode.example.json +21 -0
  35. package/server/mcp/examples/vscode.example.json +19 -0
  36. package/server/mcp/examples/windsurf.example.json +14 -0
  37. package/server/mcp/handler.js +85 -159
  38. package/server/mcp/security.js +140 -138
  39. package/server/mcp/server.js +129 -100
  40. package/server/mcp/stdio.js +4 -2
@@ -1,566 +1,670 @@
1
- import { promises as fs } from 'node:fs';
2
- import path from 'node:path';
3
-
4
- const MEMORY_DIR = path.resolve(process.cwd(), '.kiku_sessions');
5
- const MEMORY_FILE = path.join(MEMORY_DIR, 'long_term_memory.json');
6
- const SESSIONS_DIR = path.join(MEMORY_DIR, 'sessions');
7
-
8
-
9
- export class AgentMemory {
10
- constructor() {
11
- this.facts = [];
12
- this.preferences = {
13
- language: null,
14
- topicsOfInterest: [],
15
- responseStyle: 'default',
16
- };
17
- this.sessions = [];
18
- this.summaries = [];
19
- this.stats = {
20
- totalFacts: 0,
21
- totalSessions: 0,
22
- totalConsolidations: 0,
23
- lastConsolidationAt: null,
24
- createdAt: new Date()
25
- };
26
- this._loaded = false;
27
- }
28
-
29
-
30
- async init() {
31
- try {
32
- await fs.mkdir(MEMORY_DIR, { recursive: true });
33
- await fs.mkdir(SESSIONS_DIR, { recursive: true });
34
- } catch {}
35
- }
36
-
37
-
38
- async load() {
39
- try {
40
- await this.init();
41
- const data = await fs.readFile(MEMORY_FILE, 'utf-8');
42
- const parsed = JSON.parse(data);
43
-
44
-
45
- this.facts = (parsed.facts || []).map(f => ({
46
- ...f,
47
- createdAt: f.createdAt ? new Date(f.createdAt) : new Date(),
48
- lastAccessedAt: f.lastAccessedAt ? new Date(f.lastAccessedAt) : new Date()
49
- }));
50
-
51
- const loadedPrefs = parsed.preferences || {};
52
- this.preferences = {
53
- ...this.preferences,
54
- ...loadedPrefs,
55
- topicsOfInterest: (loadedPrefs.topicsOfInterest || []).map(t => ({
56
- ...t,
57
- lastSeen: t.lastSeen ? new Date(t.lastSeen) : new Date()
58
- }))
59
- };
60
-
61
- this.sessions = parsed.sessions || [];
62
- this.summaries = parsed.summaries || [];
63
- this.stats = { ...this.stats, ...(parsed.stats || {}) };
64
- this._loaded = true;
65
- return true;
66
- } catch {
67
- this._loaded = true;
68
- return false;
69
- }
70
- }
71
-
72
-
73
- async save() {
74
- try {
75
- await this.init();
76
-
77
- const serializablePrefs = {
78
- ...this.preferences,
79
- topicsOfInterest: (this.preferences.topicsOfInterest || []).map(t => ({
80
- ...t,
81
- lastSeen: t.lastSeen ? t.lastSeen.toISOString() : null
82
- }))
83
- };
84
- const data = {
85
- facts: this.facts.map(f => ({
86
- ...f,
87
- createdAt: f.createdAt ? f.createdAt.toISOString() : f.createdAt,
88
- lastAccessedAt: f.lastAccessedAt ? f.lastAccessedAt.toISOString() : f.lastAccessedAt
89
- })),
90
- preferences: serializablePrefs,
91
- sessions: this.sessions,
92
- summaries: this.summaries,
93
- stats: this.stats,
94
- lastSavedAt: new Date().toISOString()
95
- };
96
- await fs.writeFile(MEMORY_FILE, JSON.stringify(data, null, 2), 'utf-8');
97
- return true;
98
- } catch (error) {
99
- console.error('Failed to save long-term memory:', error);
100
- return false;
101
- }
102
- }
103
-
104
- addFact(fact, meta = {}) {
105
- const entry = {
106
- id: `fact_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
107
- content: fact,
108
- category: meta.category || 'general',
109
- source: meta.source || 'conversation',
110
- confidence: meta.confidence || 'high',
111
- createdAt: new Date(),
112
- lastAccessedAt: new Date(),
113
- accessCount: 0,
114
- tags: meta.tags || []
115
- };
116
- this.facts.push(entry);
117
- this.stats.totalFacts = this.facts.length;
118
- this.save().catch(() => {});
119
- return entry;
120
- }
121
-
122
-
123
- searchFacts(query, options = {}) {
124
- const { category, limit = 10 } = options;
125
- const q = (query || '').toLowerCase().trim();
126
-
127
- let results = this.facts;
128
-
129
- if (category) {
130
- results = results.filter(f => f.category === category);
131
- }
132
-
133
- if (q) {
134
- results = results.filter(f =>
135
- f.content.toLowerCase().includes(q) ||
136
- f.tags.some(t => t.toLowerCase().includes(q))
137
- );
138
- }
139
-
140
-
141
- results.sort((a, b) => {
142
- const scoreA = (a.accessCount || 0) + (new Date(a.createdAt).getTime() / 1e12);
143
- const scoreB = (b.accessCount || 0) + (new Date(b.createdAt).getTime() / 1e12);
144
- return scoreB - scoreA;
145
- });
146
-
147
-
148
- for (const fact of results.slice(0, limit)) {
149
- fact.lastAccessedAt = new Date();
150
- fact.accessCount = (fact.accessCount || 0) + 1;
151
- }
152
-
153
- return results.slice(0, limit);
154
- }
155
-
156
- removeFact(factId) {
157
- const idx = this.facts.findIndex(f => f.id === factId);
158
- if (idx === -1) return false;
159
- this.facts.splice(idx, 1);
160
- this.stats.totalFacts = this.facts.length;
161
- this.save().catch(() => {});
162
- return true;
163
- }
164
-
165
-
166
- getFactsByCategory() {
167
- const grouped = {};
168
- for (const fact of this.facts) {
169
- const cat = fact.category || 'general';
170
- if (!grouped[cat]) grouped[cat] = [];
171
- grouped[cat].push(fact);
172
- }
173
- return grouped;
174
- }
175
-
176
- setPreference(key, value) {
177
- this.preferences[key] = value;
178
- this.save().catch(() => {});
179
- }
180
-
181
-
182
- async savePreferences() {
183
- await this.save();
184
- }
185
-
186
-
187
- trackTopicInterest(topic) {
188
- const existing = this.preferences.topicsOfInterest.find(
189
- t => t.topic.toLowerCase() === topic.toLowerCase()
190
- );
191
- if (existing) {
192
- existing.count++;
193
- existing.lastSeen = new Date();
194
- } else {
195
- this.preferences.topicsOfInterest.push({
196
- topic,
197
- count: 1,
198
- lastSeen: new Date()
199
- });
200
- }
201
-
202
- this.preferences.topicsOfInterest.sort((a, b) => b.count - a.count);
203
- this.preferences.topicsOfInterest = this.preferences.topicsOfInterest.slice(0, 20);
204
- this.save().catch(() => {});
205
- }
206
-
207
-
208
- getTopTopics(limit = 5) {
209
- return this.preferences.topicsOfInterest.slice(0, limit);
210
- }
211
-
212
-
213
- async archiveSession(sessionData) {
214
- const archive = {
215
- id: sessionData.id,
216
- createdAt: sessionData.createdAt,
217
- endedAt: new Date(),
218
- messageCount: sessionData.messages?.length || 0,
219
- stats: { ...sessionData.stats },
220
- thinkingMode: sessionData.thinkingMode,
221
- summary: null
222
- };
223
-
224
-
225
- try {
226
- const sessionFile = path.join(SESSIONS_DIR, `${sessionData.id}.json`);
227
- await fs.writeFile(sessionFile, JSON.stringify(sessionData, null, 2), 'utf-8');
228
- } catch (err) {
229
- console.error('Failed to archive session file:', err);
230
- }
231
-
232
- this.sessions.push(archive);
233
- this.stats.totalSessions = this.sessions.length;
234
- await this.save();
235
- return archive;
236
- }
237
-
238
- getArchivedSessions(limit = 20) {
239
- return this.sessions
240
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
241
- .slice(0, limit);
242
- }
243
-
244
- async loadArchivedSession(sessionId) {
245
- try {
246
- const sessionFile = path.join(SESSIONS_DIR, `${sessionId}.json`);
247
- const data = await fs.readFile(sessionFile, 'utf-8');
248
- return JSON.parse(data);
249
- } catch {
250
- return null;
251
- }
252
- }
253
-
254
-
255
- async consolidateSession(sessionData) {
256
- if (!sessionData?.messages?.length) return null;
257
-
258
- const userMessages = sessionData.messages
259
- .filter(m => m.role === 'user')
260
- .map(m => {
261
- const textPart = m.parts?.find(p => p.type === 'text');
262
- return textPart?.content || '';
263
- })
264
- .filter(Boolean);
265
-
266
- const assistantMessages = sessionData.messages
267
- .filter(m => m.role === 'assistant')
268
- .map(m => {
269
- const textPart = m.parts?.find(p => p.type === 'text');
270
- return textPart?.content || '';
271
- })
272
- .filter(Boolean);
273
-
274
- const topics = this._extractTopics(userMessages);
275
-
276
- for (const topic of topics) {
277
- this.trackTopicInterest(topic);
278
- }
279
-
280
- const summary = {
281
- id: `sum_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
282
- sessionId: sessionData.id,
283
- createdAt: new Date(),
284
- topics,
285
- questionCount: userMessages.length,
286
- keyExchanges: userMessages.slice(0, 5).map((q, i) => ({
287
- question: q.slice(0, 200),
288
- answerPreview: (assistantMessages[i] || '').slice(0, 200)
289
- })),
290
- durationMs: sessionData.stats?.totalDurationMs || 0,
291
- toolCalls: sessionData.stats?.totalToolCalls || 0
292
- };
293
-
294
- this.summaries.push(summary);
295
- this.stats.totalConsolidations = this.summaries.length;
296
- this.stats.lastConsolidationAt = new Date();
297
-
298
- const archived = this.sessions.find(s => s.id === sessionData.id);
299
- if (archived) {
300
- archived.summary = summary;
301
- }
302
-
303
- await this.save();
304
- return summary;
305
- }
306
-
307
- _extractTopics(messages) {
308
- const topicKeywords = {
309
- 'drama': ['drama', 'series', 'ละคร', '剧', '电视', 'tv series', 'film', 'movie', 'work'],
310
- 'music': ['song', 'music', 'mv', 'single', 'ost', 'เพลง', '歌', 'album', 'discography'],
311
- 'brand': ['brand', 'ambassador', 'endorsement', 'แบรนด์', '品牌', 'commercial'],
312
- 'awards': ['award', 'honor', 'ceremony', 'รางวัล', '奖', 'winner'],
313
- 'profile': ['profile', 'bio', 'birthday', 'age', 'ประวัติ', '资料', '个人信息'],
314
- 'timeline': ['career', 'timeline', 'history', 'era', 'เส้นทาง', '经历', 'ปี'],
315
- 'news': ['news', 'latest', 'recent', 'update', 'ข่าว', '最新', 'ล่าสุด'],
316
- 'video': ['video', 'stage', 'performance', 'live', 'คลิป', '视频', '舞台'],
317
- 'social': ['weibo', 'douyin', 'instagram', 'social media', 'โซเชียล', '微博'],
318
- 'gallery': ['photo', 'picture', 'gallery', 'fashion', 'photoshoot', 'ภาพ', '照片']
319
- };
320
-
321
- const topicCounts = {};
322
- const allText = messages.join(' ').toLowerCase();
323
-
324
- for (const [topic, keywords] of Object.entries(topicKeywords)) {
325
- const count = keywords.reduce((acc, kw) => {
326
- const regex = new RegExp(`\\b${kw}\\b`, 'gi');
327
- return acc + (allText.match(regex) || []).length;
328
- }, 0);
329
- if (count > 0) topicCounts[topic] = count;
330
- }
331
-
332
- return Object.entries(topicCounts)
333
- .sort(([, a], [, b]) => b - a)
334
- .slice(0, 5)
335
- .map(([topic]) => topic);
336
- }
337
-
338
-
339
- buildMemoryContext() {
340
- const parts = [];
341
-
342
- if (this.preferences.language) {
343
- parts.push(`User's preferred language: ${this.preferences.language}`);
344
- }
345
-
346
- const topTopics = this.getTopTopics(5);
347
- if (topTopics.length > 0) {
348
- const topicList = topTopics.map(t => `${t.topic} (${t.count}x)`).join(', ');
349
- parts.push(`User frequently asks about: ${topicList}`);
350
- }
351
-
352
- const recentFacts = this.facts
353
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
354
- .slice(0, 8);
355
- if (recentFacts.length > 0) {
356
- const factList = recentFacts.map(f => `- ${f.content}`).join('\n');
357
- parts.push(`Known facts from previous conversations:\n${factList}`);
358
- }
359
-
360
- const recentSummaries = this.summaries
361
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
362
- .slice(0, 3);
363
- if (recentSummaries.length > 0) {
364
- const sumList = recentSummaries.map(s => {
365
- const date = new Date(s.createdAt).toLocaleDateString();
366
- const topics = s.topics.join(', ');
367
- return `- [${date}] Topics: ${topics} (${s.questionCount} questions)`;
368
- }).join('\n');
369
- parts.push(`Recent conversation themes:\n${sumList}`);
370
- }
371
-
372
- if (parts.length === 0) return '';
373
-
374
- return `\n\n=== LONG-TERM MEMORY CONTEXT ===\n${parts.join('\n\n')}\n=== END MEMORY CONTEXT ===`;
375
- }
376
-
377
- getStatus() {
378
- return {
379
- factsCount: this.facts.length,
380
- factsByCategory: this.getFactsByCategory(),
381
- preferences: this.preferences,
382
- sessionsCount: this.sessions.length,
383
- summariesCount: this.summaries.length,
384
- topTopics: this.getTopTopics(10),
385
- stats: this.stats,
386
- lastConsolidationAt: this.stats.lastConsolidationAt,
387
- memoryFileSize: null
388
- };
389
- }
390
-
391
- async importFromJSON(filePath) {
392
- try {
393
- const raw = await fs.readFile(filePath, 'utf-8');
394
- const data = JSON.parse(raw);
395
-
396
- if (!data.facts && !data.preferences && !data.sessions) {
397
- return { success: false, message: 'Invalid memory file format — missing facts, preferences, or sessions.' };
398
- }
399
-
400
- let factsImported = 0;
401
- let factsSkipped = 0;
402
- let topicsImported = 0;
403
-
404
- if (Array.isArray(data.facts)) {
405
- for (const fact of data.facts) {
406
- const isDuplicate = this.facts.some(
407
- existing => existing.content === fact.content
408
- );
409
- if (!isDuplicate && fact.content) {
410
- this.facts.push({
411
- id: fact.id || `fact_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
412
- content: fact.content,
413
- category: fact.category || 'imported',
414
- source: fact.source || 'import',
415
- confidence: fact.confidence || 'high',
416
- createdAt: fact.createdAt ? new Date(fact.createdAt) : new Date(),
417
- lastAccessedAt: new Date(),
418
- accessCount: 0,
419
- tags: fact.tags || []
420
- });
421
- factsImported++;
422
- } else {
423
- factsSkipped++;
424
- }
425
- }
426
- }
427
-
428
- if (data.preferences) {
429
- if (data.preferences.language) {
430
- this.preferences.language = data.preferences.language;
431
- }
432
- if (data.preferences.responseStyle) {
433
- this.preferences.responseStyle = data.preferences.responseStyle;
434
- }
435
- if (Array.isArray(data.preferences.topicsOfInterest)) {
436
- for (const topic of data.preferences.topicsOfInterest) {
437
- const existing = this.preferences.topicsOfInterest.find(
438
- t => t.topic.toLowerCase() === topic.topic.toLowerCase()
439
- );
440
- if (existing) {
441
- existing.count += topic.count || 1;
442
- existing.lastSeen = new Date();
443
- } else {
444
- this.preferences.topicsOfInterest.push({
445
- topic: topic.topic,
446
- count: topic.count || 1,
447
- lastSeen: topic.lastSeen ? new Date(topic.lastSeen) : new Date()
448
- });
449
- topicsImported++;
450
- }
451
- }
452
- this.preferences.topicsOfInterest.sort((a, b) => b.count - a.count);
453
- this.preferences.topicsOfInterest = this.preferences.topicsOfInterest.slice(0, 20);
454
- }
455
- }
456
-
457
- if (Array.isArray(data.sessions)) {
458
- for (const session of data.sessions) {
459
- const isDuplicate = this.sessions.some(s => s.id === session.id);
460
- if (!isDuplicate) {
461
- this.sessions.push(session);
462
- }
463
- }
464
- }
465
-
466
- if (Array.isArray(data.summaries)) {
467
- for (const summary of data.summaries) {
468
- const isDuplicate = this.summaries.some(s => s.id === summary.id);
469
- if (!isDuplicate) {
470
- this.summaries.push(summary);
471
- }
472
- }
473
- }
474
-
475
- this.stats.totalFacts = this.facts.length;
476
- this.stats.totalSessions = this.sessions.length;
477
- this.stats.totalConsolidations = this.summaries.length;
478
-
479
- await this.save();
480
-
481
- return {
482
- success: true,
483
- factsImported,
484
- factsSkipped,
485
- topicsImported,
486
- totalFacts: this.facts.length,
487
- totalSessions: this.sessions.length,
488
- totalSummaries: this.summaries.length
489
- };
490
- } catch (error) {
491
- return { success: false, message: error.message };
492
- }
493
- }
494
-
495
- async exportToJSON() {
496
- const exportDir = path.resolve(process.cwd(), 'exports');
497
- try {
498
- await fs.mkdir(exportDir, { recursive: true });
499
- } catch {}
500
-
501
- const now = new Date();
502
- const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
503
- const filename = `kiku-memory-${timestamp}.json`;
504
- const filePath = path.join(exportDir, filename);
505
-
506
- const exportData = {
507
- exportedAt: now.toISOString(),
508
- stats: this.stats,
509
- preferences: {
510
- ...this.preferences,
511
- topicsOfInterest: (this.preferences.topicsOfInterest || []).map(t => ({
512
- ...t,
513
- lastSeen: t.lastSeen ? t.lastSeen.toISOString() : null
514
- }))
515
- },
516
- facts: this.facts.map(f => ({
517
- ...f,
518
- createdAt: f.createdAt ? f.createdAt.toISOString() : f.createdAt,
519
- lastAccessedAt: f.lastAccessedAt ? f.lastAccessedAt.toISOString() : f.lastAccessedAt
520
- })),
521
- factsByCategory: this.getFactsByCategory(),
522
- sessions: this.sessions,
523
- summaries: this.summaries
524
- };
525
-
526
- await fs.writeFile(filePath, JSON.stringify(exportData, null, 2), 'utf-8');
527
- return { filePath, filename, size: (await fs.stat(filePath)).size };
528
- }
529
-
530
- async clearAll() {
531
- this.facts = [];
532
- this.preferences = {
533
- language: null,
534
- topicsOfInterest: [],
535
- responseStyle: 'default'
536
- };
537
- this.sessions = [];
538
- this.summaries = [];
539
- this.stats = {
540
- totalFacts: 0,
541
- totalSessions: 0,
542
- totalConsolidations: 0,
543
- lastConsolidationAt: null,
544
- createdAt: new Date()
545
- };
546
- await this.save();
547
- return true;
548
- }
549
-
550
- async getStorageSize() {
551
- try {
552
- const stat = await fs.stat(MEMORY_FILE);
553
- return stat.size;
554
- } catch {
555
- return 0;
556
- }
557
- }
558
-
559
- static formatBytes(bytes) {
560
- if (bytes === 0) return '0 B';
561
- const k = 1024;
562
- const sizes = ['B', 'KB', 'MB', 'GB'];
563
- const i = Math.floor(Math.log(bytes) / Math.log(k));
564
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
565
- }
566
- }
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { EXPORT_DIR, KIKU_DATA_DIR, SESSION_DIR } from './appPaths.js';
4
+
5
+ const MEMORY_DIR = KIKU_DATA_DIR;
6
+ const MEMORY_FILE = path.join(MEMORY_DIR, 'long_term_memory.json');
7
+ const SESSIONS_DIR = SESSION_DIR;
8
+
9
+
10
+ export class AgentMemory {
11
+ constructor() {
12
+ this.facts = [];
13
+ this.preferences = {
14
+ language: null,
15
+ topicsOfInterest: [],
16
+ responseStyle: 'default',
17
+ userSkills: [],
18
+ skillsEnabled: true,
19
+ };
20
+ this.sessions = [];
21
+ this.summaries = [];
22
+ this.stats = {
23
+ totalFacts: 0,
24
+ totalSessions: 0,
25
+ totalConsolidations: 0,
26
+ lastConsolidationAt: null,
27
+ createdAt: new Date()
28
+ };
29
+ this._loaded = false;
30
+ this._storageWarningShown = false;
31
+ this._storageError = null;
32
+ }
33
+
34
+
35
+ async init() {
36
+ try {
37
+ await fs.mkdir(MEMORY_DIR, { recursive: true, mode: 0o700 });
38
+ await fs.mkdir(SESSIONS_DIR, { recursive: true, mode: 0o700 });
39
+ this._storageError = null;
40
+ return true;
41
+ } catch (error) {
42
+ this._storageError = error;
43
+ this._warnStorage(error);
44
+ return false;
45
+ }
46
+ }
47
+
48
+ _warnStorage(error) {
49
+ if (this._storageWarningShown) return;
50
+ this._storageWarningShown = true;
51
+ console.error(`Kiku memory is running without persistence (${error?.code || 'unknown'}) in ${MEMORY_DIR}. Set KIKU_DATA_DIR to a writable user folder; Administrator privileges are not required.`);
52
+ }
53
+
54
+
55
+ async load() {
56
+ try {
57
+ await this.init();
58
+ const data = await fs.readFile(MEMORY_FILE, 'utf-8');
59
+ const parsed = JSON.parse(data);
60
+
61
+
62
+ this.facts = (parsed.facts || []).map(f => ({
63
+ ...f,
64
+ createdAt: f.createdAt ? new Date(f.createdAt) : new Date(),
65
+ lastAccessedAt: f.lastAccessedAt ? new Date(f.lastAccessedAt) : new Date()
66
+ }));
67
+
68
+ const loadedPrefs = parsed.preferences || {};
69
+ this.preferences = {
70
+ ...this.preferences,
71
+ ...loadedPrefs,
72
+ userSkills: Array.isArray(loadedPrefs.userSkills) ? loadedPrefs.userSkills : [],
73
+ topicsOfInterest: (loadedPrefs.topicsOfInterest || []).map(t => ({
74
+ ...t,
75
+ lastSeen: t.lastSeen ? new Date(t.lastSeen) : new Date()
76
+ }))
77
+ };
78
+
79
+ this.sessions = parsed.sessions || [];
80
+ this.summaries = parsed.summaries || [];
81
+ this.stats = { ...this.stats, ...(parsed.stats || {}) };
82
+ this._loaded = true;
83
+ return true;
84
+ } catch {
85
+ this._loaded = true;
86
+ return false;
87
+ }
88
+ }
89
+
90
+
91
+ async save() {
92
+ try {
93
+ if (!await this.init()) return false;
94
+
95
+ const serializablePrefs = {
96
+ ...this.preferences,
97
+ topicsOfInterest: (this.preferences.topicsOfInterest || []).map(t => ({
98
+ ...t,
99
+ lastSeen: t.lastSeen ? t.lastSeen.toISOString() : null
100
+ }))
101
+ };
102
+ const data = {
103
+ facts: this.facts.map(f => ({
104
+ ...f,
105
+ createdAt: f.createdAt ? f.createdAt.toISOString() : f.createdAt,
106
+ lastAccessedAt: f.lastAccessedAt ? f.lastAccessedAt.toISOString() : f.lastAccessedAt
107
+ })),
108
+ preferences: serializablePrefs,
109
+ sessions: this.sessions,
110
+ summaries: this.summaries,
111
+ stats: this.stats,
112
+ lastSavedAt: new Date().toISOString()
113
+ };
114
+ await fs.writeFile(MEMORY_FILE, JSON.stringify(data, null, 2), 'utf-8');
115
+ return true;
116
+ } catch (error) {
117
+ this._warnStorage(error);
118
+ return false;
119
+ }
120
+ }
121
+
122
+ addFact(fact, meta = {}) {
123
+ const entry = {
124
+ id: `fact_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
125
+ content: fact,
126
+ category: meta.category || 'general',
127
+ source: meta.source || 'conversation',
128
+ confidence: meta.confidence || 'high',
129
+ createdAt: new Date(),
130
+ lastAccessedAt: new Date(),
131
+ accessCount: 0,
132
+ tags: meta.tags || []
133
+ };
134
+ this.facts.push(entry);
135
+ this.stats.totalFacts = this.facts.length;
136
+ this.save().catch(() => {});
137
+ return entry;
138
+ }
139
+
140
+
141
+ scoreFactRelevance(fact, queryTerms = [], now = Date.now()) {
142
+ if (!fact) return 0;
143
+ const content = (fact.content || '').toLowerCase();
144
+ const tags = (fact.tags || []).map(t => String(t).toLowerCase());
145
+
146
+ let matchScore = 0;
147
+ if (queryTerms.length > 0) {
148
+ for (const term of queryTerms) {
149
+ if (!term || term.length < 2) continue;
150
+ if (tags.some(t => t.includes(term))) matchScore += 3.0;
151
+ if (content.includes(term)) matchScore += 2.0;
152
+ }
153
+ } else {
154
+ matchScore = 1.0;
155
+ }
156
+
157
+ const createdTime = fact.createdAt ? new Date(fact.createdAt).getTime() : now;
158
+ const ageInDays = Math.max(0, (now - createdTime) / (1000 * 60 * 60 * 24));
159
+ const recencyDecay = Math.exp(-ageInDays / 45);
160
+
161
+ const accessBoost = Math.log10(1 + (fact.accessCount || 0) * 2);
162
+
163
+ return (matchScore * (0.6 + 0.4 * recencyDecay)) + accessBoost;
164
+ }
165
+
166
+ getRelevantFacts(query = '', limit = 8) {
167
+ if (!this.facts || this.facts.length === 0) return [];
168
+ const q = String(query || '').toLowerCase().trim();
169
+ const terms = q ? q.split(/\s+/).filter(t => t.length >= 2) : [];
170
+ const now = Date.now();
171
+
172
+ const scored = this.facts.map(fact => ({
173
+ fact,
174
+ score: this.scoreFactRelevance(fact, terms, now)
175
+ }));
176
+
177
+ scored.sort((a, b) => b.score - a.score);
178
+ const top = scored.slice(0, limit).map(s => s.fact);
179
+
180
+ for (const f of top) {
181
+ f.lastAccessedAt = new Date();
182
+ f.accessCount = (f.accessCount || 0) + 1;
183
+ }
184
+ return top;
185
+ }
186
+
187
+ searchFacts(query, options = {}) {
188
+ const { category, limit = 10 } = options;
189
+ const q = (query || '').toLowerCase().trim();
190
+
191
+ let results = this.facts;
192
+
193
+ if (category) {
194
+ results = results.filter(f => f.category === category);
195
+ }
196
+
197
+ if (q) {
198
+ results = results.filter(f =>
199
+ f.content.toLowerCase().includes(q) ||
200
+ f.tags.some(t => t.toLowerCase().includes(q))
201
+ );
202
+ }
203
+
204
+ const terms = q ? q.split(/\s+/).filter(t => t.length >= 2) : [];
205
+ const now = Date.now();
206
+
207
+ results.sort((a, b) => {
208
+ const scoreA = this.scoreFactRelevance(a, terms, now);
209
+ const scoreB = this.scoreFactRelevance(b, terms, now);
210
+ return scoreB - scoreA;
211
+ });
212
+
213
+ for (const fact of results.slice(0, limit)) {
214
+ fact.lastAccessedAt = new Date();
215
+ fact.accessCount = (fact.accessCount || 0) + 1;
216
+ }
217
+
218
+ return results.slice(0, limit);
219
+ }
220
+
221
+ removeFact(factId) {
222
+ const idx = this.facts.findIndex(f => f.id === factId);
223
+ if (idx === -1) return false;
224
+ this.facts.splice(idx, 1);
225
+ this.stats.totalFacts = this.facts.length;
226
+ this.save().catch(() => {});
227
+ return true;
228
+ }
229
+
230
+
231
+ getFactsByCategory() {
232
+ const grouped = {};
233
+ for (const fact of this.facts) {
234
+ const cat = fact.category || 'general';
235
+ if (!grouped[cat]) grouped[cat] = [];
236
+ grouped[cat].push(fact);
237
+ }
238
+ return grouped;
239
+ }
240
+
241
+ setPreference(key, value) {
242
+ this.preferences[key] = value;
243
+ this.save().catch(() => {});
244
+ }
245
+
246
+ addUserSkill(instruction) {
247
+ const value = String(instruction || '').replace(/\s+/g, ' ').trim();
248
+ if (!value) return null;
249
+ if (value.length > 500) throw new Error('Skill instruction must be 500 characters or fewer.');
250
+ const duplicate = this.preferences.userSkills.find(skill => skill.instruction.toLowerCase() === value.toLowerCase());
251
+ if (duplicate) return duplicate;
252
+ const skill = { id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, instruction: value };
253
+ this.preferences.userSkills.push(skill);
254
+ this.preferences.userSkills = this.preferences.userSkills.slice(-20);
255
+ this.save().catch(() => {});
256
+ return skill;
257
+ }
258
+
259
+ removeUserSkill(id) {
260
+ const before = this.preferences.userSkills.length;
261
+ this.preferences.userSkills = this.preferences.userSkills.filter(skill => skill.id !== id);
262
+ if (this.preferences.userSkills.length === before) return false;
263
+ this.save().catch(() => {});
264
+ return true;
265
+ }
266
+
267
+ clearUserSkills() {
268
+ this.preferences.userSkills = [];
269
+ this.save().catch(() => {});
270
+ }
271
+
272
+ buildUserSkillContext() {
273
+ if (!this.preferences.skillsEnabled || this.preferences.userSkills.length === 0) return '';
274
+ return this.preferences.userSkills.map((skill, index) => `${index + 1}. ${skill.instruction}`).join('\n');
275
+ }
276
+
277
+
278
+ async savePreferences() {
279
+ await this.save();
280
+ }
281
+
282
+
283
+ trackTopicInterest(topic) {
284
+ const existing = this.preferences.topicsOfInterest.find(
285
+ t => t.topic.toLowerCase() === topic.toLowerCase()
286
+ );
287
+ if (existing) {
288
+ existing.count++;
289
+ existing.lastSeen = new Date();
290
+ } else {
291
+ this.preferences.topicsOfInterest.push({
292
+ topic,
293
+ count: 1,
294
+ lastSeen: new Date()
295
+ });
296
+ }
297
+
298
+ this.preferences.topicsOfInterest.sort((a, b) => b.count - a.count);
299
+ this.preferences.topicsOfInterest = this.preferences.topicsOfInterest.slice(0, 20);
300
+ this.save().catch(() => {});
301
+ }
302
+
303
+
304
+ getTopTopics(limit = 5) {
305
+ return this.preferences.topicsOfInterest.slice(0, limit);
306
+ }
307
+
308
+
309
+ async archiveSession(sessionData) {
310
+ const archive = {
311
+ id: sessionData.id,
312
+ createdAt: sessionData.createdAt,
313
+ endedAt: new Date(),
314
+ messageCount: sessionData.messages?.length || 0,
315
+ stats: { ...sessionData.stats },
316
+ thinkingMode: sessionData.thinkingMode,
317
+ summary: null
318
+ };
319
+
320
+
321
+ let archivedToDisk = false;
322
+ try {
323
+ if (!await this.init()) return null;
324
+ const sessionFile = path.join(SESSIONS_DIR, `${sessionData.id}.json`);
325
+ await fs.writeFile(sessionFile, JSON.stringify(sessionData, null, 2), 'utf-8');
326
+ archivedToDisk = true;
327
+ } catch (err) {
328
+ this._warnStorage(err);
329
+ }
330
+
331
+ if (!archivedToDisk) return null;
332
+
333
+ this.sessions.push(archive);
334
+ this.stats.totalSessions = this.sessions.length;
335
+ await this.save();
336
+ return archive;
337
+ }
338
+
339
+ getArchivedSessions(limit = 20) {
340
+ return this.sessions
341
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
342
+ .slice(0, limit);
343
+ }
344
+
345
+ async loadArchivedSession(sessionId) {
346
+ try {
347
+ const sessionFile = path.join(SESSIONS_DIR, `${sessionId}.json`);
348
+ const data = await fs.readFile(sessionFile, 'utf-8');
349
+ return JSON.parse(data);
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+
356
+ async consolidateSession(sessionData) {
357
+ if (!sessionData?.messages?.length) return null;
358
+
359
+ const userMessages = sessionData.messages
360
+ .filter(m => m.role === 'user')
361
+ .map(m => {
362
+ const textPart = m.parts?.find(p => p.type === 'text');
363
+ return textPart?.content || '';
364
+ })
365
+ .filter(Boolean);
366
+
367
+ const assistantMessages = sessionData.messages
368
+ .filter(m => m.role === 'assistant')
369
+ .map(m => {
370
+ const textPart = m.parts?.find(p => p.type === 'text');
371
+ return textPart?.content || '';
372
+ })
373
+ .filter(Boolean);
374
+
375
+ const topics = this._extractTopics(userMessages);
376
+
377
+ for (const topic of topics) {
378
+ this.trackTopicInterest(topic);
379
+ }
380
+
381
+ const summary = {
382
+ id: `sum_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
383
+ sessionId: sessionData.id,
384
+ createdAt: new Date(),
385
+ topics,
386
+ questionCount: userMessages.length,
387
+ keyExchanges: userMessages.slice(0, 5).map((q, i) => ({
388
+ question: q.slice(0, 200),
389
+ answerPreview: (assistantMessages[i] || '').slice(0, 200)
390
+ })),
391
+ durationMs: sessionData.stats?.totalDurationMs || 0,
392
+ toolCalls: sessionData.stats?.totalToolCalls || 0
393
+ };
394
+
395
+ this.summaries.push(summary);
396
+ this.stats.totalConsolidations = this.summaries.length;
397
+ this.stats.lastConsolidationAt = new Date();
398
+
399
+ const archived = this.sessions.find(s => s.id === sessionData.id);
400
+ if (archived) {
401
+ archived.summary = summary;
402
+ }
403
+
404
+ await this.save();
405
+ return summary;
406
+ }
407
+
408
+ _extractTopics(messages) {
409
+ const topicKeywords = {
410
+ 'drama': ['drama', 'series', 'ละคร', '剧', '电视', 'tv series', 'film', 'movie', 'work'],
411
+ 'music': ['song', 'music', 'mv', 'single', 'ost', 'เพลง', '歌', 'album', 'discography'],
412
+ 'brand': ['brand', 'ambassador', 'endorsement', 'แบรนด์', '品牌', 'commercial'],
413
+ 'awards': ['award', 'honor', 'ceremony', 'รางวัล', '奖', 'winner'],
414
+ 'profile': ['profile', 'bio', 'birthday', 'age', 'ประวัติ', '资料', '个人信息'],
415
+ 'timeline': ['career', 'timeline', 'history', 'era', 'เส้นทาง', '经历', 'ปี'],
416
+ 'news': ['news', 'latest', 'recent', 'update', 'ข่าว', '最新', 'ล่าสุด'],
417
+ 'video': ['video', 'stage', 'performance', 'live', 'คลิป', '视频', '舞台'],
418
+ 'social': ['weibo', 'douyin', 'instagram', 'social media', 'โซเชียล', '微博'],
419
+ 'gallery': ['photo', 'picture', 'gallery', 'fashion', 'photoshoot', 'ภาพ', '照片']
420
+ };
421
+
422
+ const topicCounts = {};
423
+ const allText = messages.join(' ').toLowerCase();
424
+
425
+ for (const [topic, keywords] of Object.entries(topicKeywords)) {
426
+ const count = keywords.reduce((acc, kw) => {
427
+ const regex = new RegExp(`\\b${kw}\\b`, 'gi');
428
+ return acc + (allText.match(regex) || []).length;
429
+ }, 0);
430
+ if (count > 0) topicCounts[topic] = count;
431
+ }
432
+
433
+ return Object.entries(topicCounts)
434
+ .sort(([, a], [, b]) => b - a)
435
+ .slice(0, 5)
436
+ .map(([topic]) => topic);
437
+ }
438
+
439
+
440
+ buildMemoryContext(currentQuery = '') {
441
+ const parts = [];
442
+
443
+ if (this.preferences.language) {
444
+ parts.push(`User's preferred language: ${this.preferences.language}`);
445
+ }
446
+
447
+ const userSkillContext = this.buildUserSkillContext();
448
+ if (userSkillContext) {
449
+ parts.push(`User-taught response skills (follow these preferences unless they conflict with safety or verified facts):\n${userSkillContext}`);
450
+ }
451
+
452
+ const topTopics = this.getTopTopics(5);
453
+ if (topTopics.length > 0) {
454
+ const topicList = topTopics.map(t => `${t.topic} (${t.count}x)`).join(', ');
455
+ parts.push(`User frequently asks about: ${topicList}`);
456
+ }
457
+
458
+ const relevantFacts = currentQuery ? this.getRelevantFacts(currentQuery, 8) : this.facts.slice(-8);
459
+ if (relevantFacts.length > 0) {
460
+ const factList = relevantFacts.map(f => `- ${f.content}`).join('\n');
461
+ parts.push(`Known facts from previous conversations:\n${factList}`);
462
+ }
463
+
464
+ const recentSummaries = this.summaries
465
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
466
+ .slice(0, 3);
467
+ if (recentSummaries.length > 0) {
468
+ const sumList = recentSummaries.map(s => {
469
+ const date = new Date(s.createdAt).toLocaleDateString();
470
+ const topics = s.topics.join(', ');
471
+ return `- [${date}] Topics: ${topics} (${s.questionCount} questions)`;
472
+ }).join('\n');
473
+ parts.push(`Recent conversation themes:\n${sumList}`);
474
+ }
475
+
476
+ if (parts.length === 0) return '';
477
+
478
+ return `\n\n=== LONG-TERM MEMORY CONTEXT ===\n${parts.join('\n\n')}\n=== END MEMORY CONTEXT ===`;
479
+ }
480
+
481
+ getStatus() {
482
+ return {
483
+ factsCount: this.facts.length,
484
+ factsByCategory: this.getFactsByCategory(),
485
+ preferences: this.preferences,
486
+ sessionsCount: this.sessions.length,
487
+ summariesCount: this.summaries.length,
488
+ topTopics: this.getTopTopics(10),
489
+ stats: this.stats,
490
+ lastConsolidationAt: this.stats.lastConsolidationAt,
491
+ memoryFileSize: null
492
+ };
493
+ }
494
+
495
+ async importFromJSON(filePath) {
496
+ try {
497
+ const raw = await fs.readFile(filePath, 'utf-8');
498
+ const data = JSON.parse(raw);
499
+
500
+ if (!data.facts && !data.preferences && !data.sessions) {
501
+ return { success: false, message: 'Invalid memory file format — missing facts, preferences, or sessions.' };
502
+ }
503
+
504
+ let factsImported = 0;
505
+ let factsSkipped = 0;
506
+ let topicsImported = 0;
507
+
508
+ if (Array.isArray(data.facts)) {
509
+ for (const fact of data.facts) {
510
+ const isDuplicate = this.facts.some(
511
+ existing => existing.content === fact.content
512
+ );
513
+ if (!isDuplicate && fact.content) {
514
+ this.facts.push({
515
+ id: fact.id || `fact_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
516
+ content: fact.content,
517
+ category: fact.category || 'imported',
518
+ source: fact.source || 'import',
519
+ confidence: fact.confidence || 'high',
520
+ createdAt: fact.createdAt ? new Date(fact.createdAt) : new Date(),
521
+ lastAccessedAt: new Date(),
522
+ accessCount: 0,
523
+ tags: fact.tags || []
524
+ });
525
+ factsImported++;
526
+ } else {
527
+ factsSkipped++;
528
+ }
529
+ }
530
+ }
531
+
532
+ if (data.preferences) {
533
+ if (data.preferences.language) {
534
+ this.preferences.language = data.preferences.language;
535
+ }
536
+ if (data.preferences.responseStyle) {
537
+ this.preferences.responseStyle = data.preferences.responseStyle;
538
+ }
539
+ if (Array.isArray(data.preferences.topicsOfInterest)) {
540
+ for (const topic of data.preferences.topicsOfInterest) {
541
+ const existing = this.preferences.topicsOfInterest.find(
542
+ t => t.topic.toLowerCase() === topic.topic.toLowerCase()
543
+ );
544
+ if (existing) {
545
+ existing.count += topic.count || 1;
546
+ existing.lastSeen = new Date();
547
+ } else {
548
+ this.preferences.topicsOfInterest.push({
549
+ topic: topic.topic,
550
+ count: topic.count || 1,
551
+ lastSeen: topic.lastSeen ? new Date(topic.lastSeen) : new Date()
552
+ });
553
+ topicsImported++;
554
+ }
555
+ }
556
+ this.preferences.topicsOfInterest.sort((a, b) => b.count - a.count);
557
+ this.preferences.topicsOfInterest = this.preferences.topicsOfInterest.slice(0, 20);
558
+ }
559
+ }
560
+
561
+ if (Array.isArray(data.sessions)) {
562
+ for (const session of data.sessions) {
563
+ const isDuplicate = this.sessions.some(s => s.id === session.id);
564
+ if (!isDuplicate) {
565
+ this.sessions.push(session);
566
+ }
567
+ }
568
+ }
569
+
570
+ if (Array.isArray(data.summaries)) {
571
+ for (const summary of data.summaries) {
572
+ const isDuplicate = this.summaries.some(s => s.id === summary.id);
573
+ if (!isDuplicate) {
574
+ this.summaries.push(summary);
575
+ }
576
+ }
577
+ }
578
+
579
+ this.stats.totalFacts = this.facts.length;
580
+ this.stats.totalSessions = this.sessions.length;
581
+ this.stats.totalConsolidations = this.summaries.length;
582
+
583
+ await this.save();
584
+
585
+ return {
586
+ success: true,
587
+ factsImported,
588
+ factsSkipped,
589
+ topicsImported,
590
+ totalFacts: this.facts.length,
591
+ totalSessions: this.sessions.length,
592
+ totalSummaries: this.summaries.length
593
+ };
594
+ } catch (error) {
595
+ return { success: false, message: error.message };
596
+ }
597
+ }
598
+
599
+ async exportToJSON() {
600
+ const exportDir = EXPORT_DIR;
601
+ try {
602
+ await fs.mkdir(exportDir, { recursive: true });
603
+ } catch {}
604
+
605
+ const now = new Date();
606
+ const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
607
+ const filename = `kiku-memory-${timestamp}.json`;
608
+ const filePath = path.join(exportDir, filename);
609
+
610
+ const exportData = {
611
+ exportedAt: now.toISOString(),
612
+ stats: this.stats,
613
+ preferences: {
614
+ ...this.preferences,
615
+ topicsOfInterest: (this.preferences.topicsOfInterest || []).map(t => ({
616
+ ...t,
617
+ lastSeen: t.lastSeen ? t.lastSeen.toISOString() : null
618
+ }))
619
+ },
620
+ facts: this.facts.map(f => ({
621
+ ...f,
622
+ createdAt: f.createdAt ? f.createdAt.toISOString() : f.createdAt,
623
+ lastAccessedAt: f.lastAccessedAt ? f.lastAccessedAt.toISOString() : f.lastAccessedAt
624
+ })),
625
+ factsByCategory: this.getFactsByCategory(),
626
+ sessions: this.sessions,
627
+ summaries: this.summaries
628
+ };
629
+
630
+ await fs.writeFile(filePath, JSON.stringify(exportData, null, 2), 'utf-8');
631
+ return { filePath, filename, size: (await fs.stat(filePath)).size };
632
+ }
633
+
634
+ async clearAll() {
635
+ this.facts = [];
636
+ this.preferences = {
637
+ language: null,
638
+ topicsOfInterest: [],
639
+ responseStyle: 'default'
640
+ };
641
+ this.sessions = [];
642
+ this.summaries = [];
643
+ this.stats = {
644
+ totalFacts: 0,
645
+ totalSessions: 0,
646
+ totalConsolidations: 0,
647
+ lastConsolidationAt: null,
648
+ createdAt: new Date()
649
+ };
650
+ await this.save();
651
+ return true;
652
+ }
653
+
654
+ async getStorageSize() {
655
+ try {
656
+ const stat = await fs.stat(MEMORY_FILE);
657
+ return stat.size;
658
+ } catch {
659
+ return 0;
660
+ }
661
+ }
662
+
663
+ static formatBytes(bytes) {
664
+ if (bytes === 0) return '0 B';
665
+ const k = 1024;
666
+ const sizes = ['B', 'KB', 'MB', 'GB'];
667
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
668
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
669
+ }
670
+ }