kernelbot 1.0.30 → 1.0.33

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 (63) hide show
  1. package/.env.example +0 -0
  2. package/README.md +0 -0
  3. package/bin/kernel.js +56 -2
  4. package/config.example.yaml +31 -0
  5. package/package.json +1 -1
  6. package/src/agent.js +200 -32
  7. package/src/automation/automation-manager.js +0 -0
  8. package/src/automation/automation.js +0 -0
  9. package/src/automation/index.js +0 -0
  10. package/src/automation/scheduler.js +0 -0
  11. package/src/bot.js +402 -6
  12. package/src/claude-auth.js +0 -0
  13. package/src/coder.js +0 -0
  14. package/src/conversation.js +51 -5
  15. package/src/intents/detector.js +0 -0
  16. package/src/intents/index.js +0 -0
  17. package/src/intents/planner.js +0 -0
  18. package/src/life/codebase.js +388 -0
  19. package/src/life/engine.js +1317 -0
  20. package/src/life/evolution.js +244 -0
  21. package/src/life/improvements.js +81 -0
  22. package/src/life/journal.js +109 -0
  23. package/src/life/memory.js +283 -0
  24. package/src/life/share-queue.js +136 -0
  25. package/src/persona.js +0 -0
  26. package/src/prompts/orchestrator.js +62 -2
  27. package/src/prompts/persona.md +7 -0
  28. package/src/prompts/system.js +0 -0
  29. package/src/prompts/workers.js +10 -9
  30. package/src/providers/anthropic.js +0 -0
  31. package/src/providers/base.js +0 -0
  32. package/src/providers/index.js +0 -0
  33. package/src/providers/models.js +8 -1
  34. package/src/providers/openai-compat.js +0 -0
  35. package/src/security/audit.js +0 -0
  36. package/src/security/auth.js +0 -0
  37. package/src/security/confirm.js +0 -0
  38. package/src/self.js +0 -0
  39. package/src/services/stt.js +0 -0
  40. package/src/services/tts.js +0 -0
  41. package/src/skills/catalog.js +0 -0
  42. package/src/skills/custom.js +0 -0
  43. package/src/swarm/job-manager.js +0 -0
  44. package/src/swarm/job.js +11 -0
  45. package/src/swarm/worker-registry.js +0 -0
  46. package/src/tools/browser.js +0 -0
  47. package/src/tools/categories.js +0 -0
  48. package/src/tools/coding.js +1 -1
  49. package/src/tools/docker.js +0 -0
  50. package/src/tools/git.js +0 -0
  51. package/src/tools/github.js +0 -0
  52. package/src/tools/index.js +0 -0
  53. package/src/tools/jira.js +0 -0
  54. package/src/tools/monitor.js +0 -0
  55. package/src/tools/network.js +0 -0
  56. package/src/tools/orchestrator-tools.js +60 -3
  57. package/src/tools/os.js +0 -0
  58. package/src/tools/persona.js +0 -0
  59. package/src/tools/process.js +0 -0
  60. package/src/utils/config.js +0 -0
  61. package/src/utils/display.js +0 -0
  62. package/src/utils/logger.js +0 -0
  63. package/src/worker.js +27 -8
@@ -0,0 +1,244 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { randomBytes } from 'crypto';
5
+ import { getLogger } from '../utils/logger.js';
6
+
7
+ const LIFE_DIR = join(homedir(), '.kernelbot', 'life');
8
+ const EVOLUTION_FILE = join(LIFE_DIR, 'evolution.json');
9
+
10
+ function genId(prefix = 'evo') {
11
+ return `${prefix}_${randomBytes(4).toString('hex')}`;
12
+ }
13
+
14
+ const VALID_STATUSES = ['research', 'planned', 'coding', 'pr_open', 'merged', 'rejected', 'failed'];
15
+ const TERMINAL_STATUSES = ['merged', 'rejected', 'failed'];
16
+
17
+ const DEFAULT_DATA = {
18
+ proposals: [],
19
+ lessons: [],
20
+ stats: { totalProposals: 0, merged: 0, rejected: 0, failed: 0, successRate: 0 },
21
+ };
22
+
23
+ export class EvolutionTracker {
24
+ constructor() {
25
+ mkdirSync(LIFE_DIR, { recursive: true });
26
+ this._data = this._load();
27
+ }
28
+
29
+ _load() {
30
+ if (existsSync(EVOLUTION_FILE)) {
31
+ try {
32
+ const raw = JSON.parse(readFileSync(EVOLUTION_FILE, 'utf-8'));
33
+ return {
34
+ proposals: raw.proposals || [],
35
+ lessons: raw.lessons || [],
36
+ stats: { ...DEFAULT_DATA.stats, ...raw.stats },
37
+ };
38
+ } catch {
39
+ return { ...DEFAULT_DATA, proposals: [], lessons: [] };
40
+ }
41
+ }
42
+ return { ...DEFAULT_DATA, proposals: [], lessons: [] };
43
+ }
44
+
45
+ _save() {
46
+ writeFileSync(EVOLUTION_FILE, JSON.stringify(this._data, null, 2), 'utf-8');
47
+ }
48
+
49
+ _recalcStats() {
50
+ const { proposals } = this._data;
51
+ const total = proposals.length;
52
+ const merged = proposals.filter(p => p.status === 'merged').length;
53
+ const rejected = proposals.filter(p => p.status === 'rejected').length;
54
+ const failed = proposals.filter(p => p.status === 'failed').length;
55
+ const resolved = merged + rejected + failed;
56
+ this._data.stats = {
57
+ totalProposals: total,
58
+ merged,
59
+ rejected,
60
+ failed,
61
+ successRate: resolved > 0 ? Math.round((merged / resolved) * 100) : 0,
62
+ };
63
+ }
64
+
65
+ // ── Proposals ─────────────────────────────────────────────────
66
+
67
+ addProposal(trigger, context) {
68
+ const logger = getLogger();
69
+ const now = Date.now();
70
+ const proposal = {
71
+ id: genId('evo'),
72
+ createdAt: now,
73
+ status: 'research',
74
+ trigger,
75
+ triggerContext: context,
76
+ research: { findings: null, sources: [], completedAt: 0 },
77
+ plan: { description: null, filesToModify: [], risks: null, testStrategy: null, completedAt: 0 },
78
+ branch: null,
79
+ commits: [],
80
+ filesChanged: [],
81
+ prNumber: null,
82
+ prUrl: null,
83
+ outcome: { merged: false, feedback: null, lessonsLearned: null },
84
+ updatedAt: now,
85
+ };
86
+
87
+ this._data.proposals.push(proposal);
88
+ this._recalcStats();
89
+ this._save();
90
+ logger.info(`[Evolution] New proposal: ${proposal.id} (trigger: ${trigger})`);
91
+ return proposal;
92
+ }
93
+
94
+ updateResearch(id, findings, sources = []) {
95
+ const proposal = this._findProposal(id);
96
+ if (!proposal) return null;
97
+
98
+ proposal.research = {
99
+ findings,
100
+ sources,
101
+ completedAt: Date.now(),
102
+ };
103
+ proposal.status = 'planned';
104
+ proposal.updatedAt = Date.now();
105
+ this._save();
106
+ return proposal;
107
+ }
108
+
109
+ updatePlan(id, plan) {
110
+ const proposal = this._findProposal(id);
111
+ if (!proposal) return null;
112
+
113
+ proposal.plan = {
114
+ description: plan.description,
115
+ filesToModify: plan.filesToModify || [],
116
+ risks: plan.risks || null,
117
+ testStrategy: plan.testStrategy || null,
118
+ completedAt: Date.now(),
119
+ };
120
+ proposal.status = 'planned';
121
+ proposal.updatedAt = Date.now();
122
+ this._save();
123
+ return proposal;
124
+ }
125
+
126
+ updateCoding(id, branch, commits = [], files = []) {
127
+ const proposal = this._findProposal(id);
128
+ if (!proposal) return null;
129
+
130
+ proposal.branch = branch;
131
+ proposal.commits = commits;
132
+ proposal.filesChanged = files;
133
+ proposal.status = 'coding';
134
+ proposal.updatedAt = Date.now();
135
+ this._save();
136
+ return proposal;
137
+ }
138
+
139
+ updatePR(id, prNumber, prUrl) {
140
+ const proposal = this._findProposal(id);
141
+ if (!proposal) return null;
142
+
143
+ proposal.prNumber = prNumber;
144
+ proposal.prUrl = prUrl;
145
+ proposal.status = 'pr_open';
146
+ proposal.updatedAt = Date.now();
147
+ this._save();
148
+ return proposal;
149
+ }
150
+
151
+ resolvePR(id, merged, feedback = null) {
152
+ const proposal = this._findProposal(id);
153
+ if (!proposal) return null;
154
+
155
+ proposal.status = merged ? 'merged' : 'rejected';
156
+ proposal.outcome = {
157
+ merged,
158
+ feedback,
159
+ lessonsLearned: null,
160
+ };
161
+ proposal.updatedAt = Date.now();
162
+ this._recalcStats();
163
+ this._save();
164
+ return proposal;
165
+ }
166
+
167
+ failProposal(id, reason) {
168
+ const logger = getLogger();
169
+ const proposal = this._findProposal(id);
170
+ if (!proposal) return null;
171
+
172
+ proposal.status = 'failed';
173
+ proposal.outcome = {
174
+ merged: false,
175
+ feedback: reason,
176
+ lessonsLearned: null,
177
+ };
178
+ proposal.updatedAt = Date.now();
179
+ this._recalcStats();
180
+ this._save();
181
+ logger.warn(`[Evolution] Proposal ${id} failed: ${reason}`);
182
+ return proposal;
183
+ }
184
+
185
+ // ── Lessons ───────────────────────────────────────────────────
186
+
187
+ addLesson(category, lesson, fromProposal = null, importance = 5) {
188
+ const logger = getLogger();
189
+ const entry = {
190
+ id: genId('les'),
191
+ category,
192
+ lesson,
193
+ fromProposal,
194
+ importance,
195
+ createdAt: Date.now(),
196
+ };
197
+ this._data.lessons.push(entry);
198
+ // Cap lessons at 200
199
+ if (this._data.lessons.length > 200) {
200
+ this._data.lessons = this._data.lessons.slice(-200);
201
+ }
202
+ this._save();
203
+ logger.info(`[Evolution] Lesson added: "${lesson.slice(0, 80)}" (${category})`);
204
+ return entry;
205
+ }
206
+
207
+ // ── Queries ───────────────────────────────────────────────────
208
+
209
+ getActiveProposal() {
210
+ return this._data.proposals.find(p => !TERMINAL_STATUSES.includes(p.status)) || null;
211
+ }
212
+
213
+ getRecentProposals(limit = 10) {
214
+ return this._data.proposals.slice(-limit);
215
+ }
216
+
217
+ getRecentLessons(limit = 10) {
218
+ return this._data.lessons.slice(-limit);
219
+ }
220
+
221
+ getLessonsByCategory(category) {
222
+ return this._data.lessons.filter(l => l.category === category);
223
+ }
224
+
225
+ getStats() {
226
+ return { ...this._data.stats };
227
+ }
228
+
229
+ getPRsToCheck() {
230
+ return this._data.proposals.filter(p => p.status === 'pr_open');
231
+ }
232
+
233
+ getProposalsToday() {
234
+ const startOfDay = new Date();
235
+ startOfDay.setHours(0, 0, 0, 0);
236
+ return this._data.proposals.filter(p => p.createdAt >= startOfDay.getTime());
237
+ }
238
+
239
+ // ── Internal ──────────────────────────────────────────────────
240
+
241
+ _findProposal(id) {
242
+ return this._data.proposals.find(p => p.id === id) || null;
243
+ }
244
+ }
@@ -0,0 +1,81 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { randomBytes } from 'crypto';
5
+ import { getLogger } from '../utils/logger.js';
6
+
7
+ const LIFE_DIR = join(homedir(), '.kernelbot', 'life');
8
+ const IMPROVEMENTS_FILE = join(LIFE_DIR, 'improvements.json');
9
+
10
+ function genId() {
11
+ return `imp_${randomBytes(4).toString('hex')}`;
12
+ }
13
+
14
+ export class ImprovementTracker {
15
+ constructor() {
16
+ mkdirSync(LIFE_DIR, { recursive: true });
17
+ this._data = this._load();
18
+ }
19
+
20
+ _load() {
21
+ if (existsSync(IMPROVEMENTS_FILE)) {
22
+ try {
23
+ return JSON.parse(readFileSync(IMPROVEMENTS_FILE, 'utf-8'));
24
+ } catch {
25
+ return { proposals: [] };
26
+ }
27
+ }
28
+ return { proposals: [] };
29
+ }
30
+
31
+ _save() {
32
+ writeFileSync(IMPROVEMENTS_FILE, JSON.stringify(this._data, null, 2), 'utf-8');
33
+ }
34
+
35
+ /**
36
+ * Add a self-improvement proposal.
37
+ * @param {{ description: string, branch: string, scope: string, files?: string[] }} proposal
38
+ */
39
+ addProposal(proposal) {
40
+ const logger = getLogger();
41
+ const entry = {
42
+ id: genId(),
43
+ createdAt: Date.now(),
44
+ status: 'pending', // pending, approved, rejected
45
+ description: proposal.description,
46
+ branch: proposal.branch,
47
+ scope: proposal.scope || 'prompts',
48
+ files: proposal.files || [],
49
+ };
50
+ this._data.proposals.push(entry);
51
+ this._save();
52
+ logger.info(`[Improvements] New proposal: "${entry.description.slice(0, 80)}" (${entry.id})`);
53
+ return entry;
54
+ }
55
+
56
+ /**
57
+ * Get pending proposals.
58
+ */
59
+ getPending() {
60
+ return this._data.proposals.filter(p => p.status === 'pending');
61
+ }
62
+
63
+ /**
64
+ * Get all proposals (for display).
65
+ */
66
+ getAll(limit = 20) {
67
+ return this._data.proposals.slice(-limit);
68
+ }
69
+
70
+ /**
71
+ * Update proposal status.
72
+ */
73
+ updateStatus(id, status) {
74
+ const proposal = this._data.proposals.find(p => p.id === id);
75
+ if (!proposal) return null;
76
+ proposal.status = status;
77
+ proposal.updatedAt = Date.now();
78
+ this._save();
79
+ return proposal;
80
+ }
81
+ }
@@ -0,0 +1,109 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { getLogger } from '../utils/logger.js';
5
+
6
+ const JOURNAL_DIR = join(homedir(), '.kernelbot', 'life', 'journals');
7
+
8
+ function todayDate() {
9
+ return new Date().toISOString().slice(0, 10);
10
+ }
11
+
12
+ function formatDate(date) {
13
+ return new Date(date + 'T00:00:00').toLocaleDateString('en-US', {
14
+ weekday: 'long',
15
+ year: 'numeric',
16
+ month: 'long',
17
+ day: 'numeric',
18
+ });
19
+ }
20
+
21
+ function timeNow() {
22
+ return new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
23
+ }
24
+
25
+ export class JournalManager {
26
+ constructor() {
27
+ mkdirSync(JOURNAL_DIR, { recursive: true });
28
+ }
29
+
30
+ _journalPath(date) {
31
+ return join(JOURNAL_DIR, `${date}.md`);
32
+ }
33
+
34
+ /**
35
+ * Write a new entry to today's journal.
36
+ * @param {string} title - Section title (e.g. "Morning Thoughts")
37
+ * @param {string} content - Entry content
38
+ */
39
+ writeEntry(title, content) {
40
+ const logger = getLogger();
41
+ const date = todayDate();
42
+ const filePath = this._journalPath(date);
43
+ const time = timeNow();
44
+
45
+ let existing = '';
46
+ if (existsSync(filePath)) {
47
+ existing = readFileSync(filePath, 'utf-8');
48
+ } else {
49
+ existing = `# Journal — ${formatDate(date)}\n`;
50
+ }
51
+
52
+ const entry = `\n## ${title} (${time})\n${content}\n`;
53
+ writeFileSync(filePath, existing + entry, 'utf-8');
54
+ logger.info(`[Journal] Wrote entry: "${title}" for ${date}`);
55
+ }
56
+
57
+ /**
58
+ * Get today's journal content.
59
+ */
60
+ getToday() {
61
+ const filePath = this._journalPath(todayDate());
62
+ if (!existsSync(filePath)) return null;
63
+ return readFileSync(filePath, 'utf-8');
64
+ }
65
+
66
+ /**
67
+ * Get journal entries for the last N days.
68
+ * Returns array of { date, content }.
69
+ */
70
+ getRecent(days = 7) {
71
+ const results = [];
72
+ const now = new Date();
73
+ for (let i = 0; i < days; i++) {
74
+ const d = new Date(now);
75
+ d.setDate(d.getDate() - i);
76
+ const date = d.toISOString().slice(0, 10);
77
+ const filePath = this._journalPath(date);
78
+ if (existsSync(filePath)) {
79
+ results.push({ date, content: readFileSync(filePath, 'utf-8') });
80
+ }
81
+ }
82
+ return results;
83
+ }
84
+
85
+ /**
86
+ * Get journal for a specific date.
87
+ */
88
+ getEntry(date) {
89
+ const filePath = this._journalPath(date);
90
+ if (!existsSync(filePath)) return null;
91
+ return readFileSync(filePath, 'utf-8');
92
+ }
93
+
94
+ /**
95
+ * List available journal dates (most recent first).
96
+ */
97
+ list(limit = 30) {
98
+ try {
99
+ const files = readdirSync(JOURNAL_DIR)
100
+ .filter(f => f.endsWith('.md'))
101
+ .map(f => f.replace('.md', ''))
102
+ .sort()
103
+ .reverse();
104
+ return files.slice(0, limit);
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,283 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { randomBytes } from 'crypto';
5
+ import { getLogger } from '../utils/logger.js';
6
+
7
+ const LIFE_DIR = join(homedir(), '.kernelbot', 'life');
8
+ const EPISODIC_DIR = join(LIFE_DIR, 'memories', 'episodic');
9
+ const SEMANTIC_FILE = join(LIFE_DIR, 'memories', 'semantic', 'topics.json');
10
+
11
+ function today() {
12
+ return new Date().toISOString().slice(0, 10);
13
+ }
14
+
15
+ function genId(prefix = 'ep') {
16
+ return `${prefix}_${randomBytes(4).toString('hex')}`;
17
+ }
18
+
19
+ export class MemoryManager {
20
+ constructor() {
21
+ this._episodicCache = new Map(); // date -> array
22
+ this._semanticCache = null;
23
+ mkdirSync(EPISODIC_DIR, { recursive: true });
24
+ mkdirSync(join(LIFE_DIR, 'memories', 'semantic'), { recursive: true });
25
+ }
26
+
27
+ // ── Episodic Memories ──────────────────────────────────────────
28
+
29
+ _episodicPath(date) {
30
+ return join(EPISODIC_DIR, `${date}.json`);
31
+ }
32
+
33
+ _loadEpisodicDay(date) {
34
+ if (this._episodicCache.has(date)) return this._episodicCache.get(date);
35
+ const filePath = this._episodicPath(date);
36
+ let entries = [];
37
+ if (existsSync(filePath)) {
38
+ try {
39
+ entries = JSON.parse(readFileSync(filePath, 'utf-8'));
40
+ } catch {
41
+ entries = [];
42
+ }
43
+ }
44
+ this._episodicCache.set(date, entries);
45
+ return entries;
46
+ }
47
+
48
+ _saveEpisodicDay(date, entries) {
49
+ this._episodicCache.set(date, entries);
50
+ writeFileSync(this._episodicPath(date), JSON.stringify(entries, null, 2), 'utf-8');
51
+ }
52
+
53
+ /**
54
+ * Add an episodic memory.
55
+ * @param {{ type: string, source: string, summary: string, tags?: string[], importance?: number, userId?: string }} memory
56
+ */
57
+ addEpisodic(memory) {
58
+ const logger = getLogger();
59
+ const date = today();
60
+ const entries = this._loadEpisodicDay(date);
61
+ const entry = {
62
+ id: genId('ep'),
63
+ timestamp: Date.now(),
64
+ type: memory.type || 'interaction',
65
+ source: memory.source || 'user_chat',
66
+ userId: memory.userId || null,
67
+ summary: memory.summary,
68
+ tags: memory.tags || [],
69
+ importance: memory.importance || 5,
70
+ };
71
+ entries.push(entry);
72
+ this._saveEpisodicDay(date, entries);
73
+ logger.debug(`[Memory] Added episodic: "${entry.summary.slice(0, 80)}" (${entry.id})`);
74
+ return entry;
75
+ }
76
+
77
+ /**
78
+ * Get recent episodic memories within the last N hours.
79
+ */
80
+ getRecentEpisodic(hours = 24, limit = 20) {
81
+ const cutoff = Date.now() - hours * 3600_000;
82
+ const results = [];
83
+
84
+ // Check today and recent days
85
+ const daysToCheck = Math.ceil(hours / 24) + 1;
86
+ const dates = this._getRecentDates(daysToCheck);
87
+
88
+ for (const date of dates) {
89
+ const entries = this._loadEpisodicDay(date);
90
+ for (const entry of entries) {
91
+ if (entry.timestamp >= cutoff) results.push(entry);
92
+ }
93
+ }
94
+
95
+ results.sort((a, b) => b.timestamp - a.timestamp);
96
+ return results.slice(0, limit);
97
+ }
98
+
99
+ /**
100
+ * Get memories about a specific user.
101
+ */
102
+ getMemoriesAboutUser(userId, limit = 10) {
103
+ const results = [];
104
+ const dates = this._getRecentDates(90);
105
+
106
+ for (const date of dates) {
107
+ const entries = this._loadEpisodicDay(date);
108
+ for (const entry of entries) {
109
+ if (entry.userId === String(userId)) results.push(entry);
110
+ }
111
+ }
112
+
113
+ results.sort((a, b) => b.timestamp - a.timestamp);
114
+ return results.slice(0, limit);
115
+ }
116
+
117
+ /**
118
+ * Search episodic memories by keyword (simple substring match on summary + tags).
119
+ */
120
+ searchEpisodic(query, limit = 10) {
121
+ const q = query.toLowerCase();
122
+ const results = [];
123
+ const dates = this._getRecentDates(90);
124
+
125
+ for (const date of dates) {
126
+ const entries = this._loadEpisodicDay(date);
127
+ for (const entry of entries) {
128
+ const haystack = `${entry.summary} ${entry.tags.join(' ')}`.toLowerCase();
129
+ if (haystack.includes(q)) results.push(entry);
130
+ }
131
+ }
132
+
133
+ results.sort((a, b) => b.importance - a.importance || b.timestamp - a.timestamp);
134
+ return results.slice(0, limit);
135
+ }
136
+
137
+ /**
138
+ * Prune episodic memories older than N days.
139
+ */
140
+ pruneOld(daysToKeep = 90) {
141
+ const logger = getLogger();
142
+ const cutoffDate = new Date(Date.now() - daysToKeep * 86400_000).toISOString().slice(0, 10);
143
+ let pruned = 0;
144
+
145
+ try {
146
+ const files = readdirSync(EPISODIC_DIR).filter(f => f.endsWith('.json'));
147
+ for (const file of files) {
148
+ const date = file.replace('.json', '');
149
+ if (date < cutoffDate) {
150
+ unlinkSync(join(EPISODIC_DIR, file));
151
+ this._episodicCache.delete(date);
152
+ pruned++;
153
+ }
154
+ }
155
+ } catch (err) {
156
+ logger.warn(`[Memory] Prune error: ${err.message}`);
157
+ }
158
+
159
+ if (pruned > 0) logger.info(`[Memory] Pruned ${pruned} old episodic files`);
160
+ return pruned;
161
+ }
162
+
163
+ _getRecentDates(days) {
164
+ const dates = [];
165
+ const now = new Date();
166
+ for (let i = 0; i < days; i++) {
167
+ const d = new Date(now);
168
+ d.setDate(d.getDate() - i);
169
+ dates.push(d.toISOString().slice(0, 10));
170
+ }
171
+ return dates;
172
+ }
173
+
174
+ // ── Semantic Knowledge ─────────────────────────────────────────
175
+
176
+ _loadSemantic() {
177
+ if (this._semanticCache) return this._semanticCache;
178
+ if (existsSync(SEMANTIC_FILE)) {
179
+ try {
180
+ this._semanticCache = JSON.parse(readFileSync(SEMANTIC_FILE, 'utf-8'));
181
+ } catch {
182
+ this._semanticCache = {};
183
+ }
184
+ } else {
185
+ this._semanticCache = {};
186
+ }
187
+ return this._semanticCache;
188
+ }
189
+
190
+ _saveSemantic() {
191
+ writeFileSync(SEMANTIC_FILE, JSON.stringify(this._semanticCache || {}, null, 2), 'utf-8');
192
+ }
193
+
194
+ /**
195
+ * Add or update semantic knowledge.
196
+ * @param {string} topic - Topic key (e.g. "rust_ownership")
197
+ * @param {{ summary: string, sources?: string[], relatedTopics?: string[] }} knowledge
198
+ */
199
+ addSemantic(topic, knowledge) {
200
+ const logger = getLogger();
201
+ const data = this._loadSemantic();
202
+ const key = topic.toLowerCase().replace(/\s+/g, '_');
203
+ const existing = data[key];
204
+
205
+ data[key] = {
206
+ summary: knowledge.summary,
207
+ sources: [...new Set([...(existing?.sources || []), ...(knowledge.sources || [])])],
208
+ learnedAt: Date.now(),
209
+ relatedTopics: [...new Set([...(existing?.relatedTopics || []), ...(knowledge.relatedTopics || [])])],
210
+ };
211
+
212
+ this._semanticCache = data;
213
+ this._saveSemantic();
214
+ logger.debug(`[Memory] Updated semantic topic: ${key}`);
215
+ return data[key];
216
+ }
217
+
218
+ /**
219
+ * Search semantic knowledge by keyword.
220
+ */
221
+ searchSemantic(query, limit = 5) {
222
+ const q = query.toLowerCase();
223
+ const data = this._loadSemantic();
224
+ const results = [];
225
+
226
+ for (const [key, val] of Object.entries(data)) {
227
+ const haystack = `${key} ${val.summary} ${val.relatedTopics.join(' ')}`.toLowerCase();
228
+ if (haystack.includes(q)) {
229
+ results.push({ topic: key, ...val });
230
+ }
231
+ }
232
+
233
+ results.sort((a, b) => b.learnedAt - a.learnedAt);
234
+ return results.slice(0, limit);
235
+ }
236
+
237
+ // ── Prompt Builder ─────────────────────────────────────────────
238
+
239
+ /**
240
+ * Build a context block of relevant memories for the orchestrator prompt.
241
+ * Pulls recent episodic + user-specific + semantic topics.
242
+ * Capped to ~1500 chars.
243
+ */
244
+ buildContextBlock(userId = null) {
245
+ const sections = [];
246
+
247
+ // Recent general memories (last 24h, top 5)
248
+ const recent = this.getRecentEpisodic(24, 5);
249
+ if (recent.length > 0) {
250
+ const lines = recent.map(m => {
251
+ const ago = Math.round((Date.now() - m.timestamp) / 60000);
252
+ const timeLabel = ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`;
253
+ return `- ${m.summary} (${timeLabel})`;
254
+ });
255
+ sections.push(`Recent:\n${lines.join('\n')}`);
256
+ }
257
+
258
+ // User-specific memories (top 3)
259
+ if (userId) {
260
+ const userMems = this.getMemoriesAboutUser(userId, 3);
261
+ if (userMems.length > 0) {
262
+ const lines = userMems.map(m => `- ${m.summary}`);
263
+ sections.push(`About this user:\n${lines.join('\n')}`);
264
+ }
265
+ }
266
+
267
+ // Semantic knowledge (last 3 learned)
268
+ const data = this._loadSemantic();
269
+ const semanticEntries = Object.entries(data)
270
+ .sort((a, b) => b[1].learnedAt - a[1].learnedAt)
271
+ .slice(0, 3);
272
+ if (semanticEntries.length > 0) {
273
+ const lines = semanticEntries.map(([key, val]) => `- **${key}**: ${val.summary.slice(0, 100)}`);
274
+ sections.push(`Knowledge:\n${lines.join('\n')}`);
275
+ }
276
+
277
+ if (sections.length === 0) return null;
278
+
279
+ let block = sections.join('\n\n');
280
+ if (block.length > 1500) block = block.slice(0, 1500) + '\n...';
281
+ return block;
282
+ }
283
+ }