aegiscode 3.1.8 → 3.1.10

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 (206) hide show
  1. package/README.md +23 -15
  2. package/dist/main.js +549 -552
  3. package/package.json +4 -2
  4. package/src/agent/Agent.ts +903 -0
  5. package/src/agent/SimpleAgent.ts +48 -0
  6. package/src/agent/index.ts +54 -0
  7. package/src/agent/orchestrator/AppBuilder.ts +443 -0
  8. package/src/agent/orchestrator/CouncilAgent.ts +310 -0
  9. package/src/agent/orchestrator/DiscussionRoom.ts +462 -0
  10. package/src/agent/orchestrator/OrchestratorAgent.ts +637 -0
  11. package/src/agent/orchestrator/index.ts +38 -0
  12. package/src/agent/orchestrator/utils.ts +397 -0
  13. package/src/agent/pricing.ts +115 -0
  14. package/src/agent/router.ts +74 -0
  15. package/src/agent/routerStats.ts +121 -0
  16. package/src/agent/types.ts +318 -0
  17. package/src/auth/login.ts +383 -0
  18. package/src/cli/config.ts +189 -0
  19. package/src/cli/index.ts +17 -0
  20. package/src/cli/middleware.ts +119 -0
  21. package/src/cli/types.ts +75 -0
  22. package/src/config/ConfigManager.ts +587 -0
  23. package/src/config/index.ts +7 -0
  24. package/src/config/types.ts +584 -0
  25. package/src/context/CompactionService.ts +300 -0
  26. package/src/context/ContextManager.ts +450 -0
  27. package/src/context/FileAnalyzer.ts +267 -0
  28. package/src/context/TokenCounter.ts +265 -0
  29. package/src/context/index.ts +27 -0
  30. package/src/context/storage/CacheStore.ts +176 -0
  31. package/src/context/storage/JSONLStore.ts +201 -0
  32. package/src/context/storage/MemoryStore.ts +205 -0
  33. package/src/context/storage/PersistentStore.ts +327 -0
  34. package/src/context/storage/index.ts +9 -0
  35. package/src/context/storage/pathUtils.ts +114 -0
  36. package/src/context/test.ts +309 -0
  37. package/src/context/types.ts +268 -0
  38. package/src/hooks/HookExecutor.ts +434 -0
  39. package/src/hooks/HookManager.ts +596 -0
  40. package/src/hooks/HookService.ts +269 -0
  41. package/src/hooks/Matcher.ts +157 -0
  42. package/src/hooks/index.ts +63 -0
  43. package/src/hooks/types.ts +424 -0
  44. package/src/main.tsx +596 -0
  45. package/src/mcp/HealthMonitor.ts +150 -0
  46. package/src/mcp/McpClient.ts +491 -0
  47. package/src/mcp/McpRegistry.ts +321 -0
  48. package/src/mcp/createMcpTool.ts +251 -0
  49. package/src/mcp/index.ts +15 -0
  50. package/src/mcp/server.ts +334 -0
  51. package/src/mcp/test-server.ts +88 -0
  52. package/src/mcp/test.ts +372 -0
  53. package/src/mcp/types.ts +247 -0
  54. package/src/memory/AgentMemoryBus.ts +432 -0
  55. package/src/memory/CloudSync.ts +99 -0
  56. package/src/memory/DriveSync.ts +106 -0
  57. package/src/memory/SharedMemory.ts +951 -0
  58. package/src/memory/index.ts +14 -0
  59. package/src/memory/machineFingerprint.ts +40 -0
  60. package/src/orchestrator/SubAgentMetadata.ts +136 -0
  61. package/src/prompts/builder.ts +213 -0
  62. package/src/prompts/default.ts +144 -0
  63. package/src/prompts/index.ts +16 -0
  64. package/src/prompts/plan.ts +64 -0
  65. package/src/prompts/test.ts +78 -0
  66. package/src/services/AnthropicChatService.ts +341 -0
  67. package/src/services/ChatService.ts +347 -0
  68. package/src/services/ClaudeCliChatService.ts +256 -0
  69. package/src/services/CloudSync.ts +168 -0
  70. package/src/services/CostLedger.ts +211 -0
  71. package/src/services/Heartbeat.ts +135 -0
  72. package/src/services/LearningCollector.ts +291 -0
  73. package/src/services/OllamaInstaller.ts +342 -0
  74. package/src/services/VersionChecker.ts +445 -0
  75. package/src/services/index.ts +57 -0
  76. package/src/services/streaming/OpenAIEventAdapter.ts +126 -0
  77. package/src/services/streaming/RenderingProfile.ts +90 -0
  78. package/src/services/streaming/StreamEventParser.ts +181 -0
  79. package/src/services/streaming/ThrottledRenderer.ts +139 -0
  80. package/src/services/streaming/TranscriptBuffer.ts +574 -0
  81. package/src/services/streaming/eventStatusMap.ts +52 -0
  82. package/src/services/streaming/index.ts +46 -0
  83. package/src/services/streaming/renderFormatting.ts +79 -0
  84. package/src/services/streaming/types.ts +234 -0
  85. package/src/skills/SkillLoader.ts +126 -0
  86. package/src/skills/SkillRegistry.ts +366 -0
  87. package/src/skills/index.ts +48 -0
  88. package/src/skills/types.ts +146 -0
  89. package/src/slash-commands/billing.ts +70 -0
  90. package/src/slash-commands/build.ts +413 -0
  91. package/src/slash-commands/builtinCommands.ts +2733 -0
  92. package/src/slash-commands/clone.ts +242 -0
  93. package/src/slash-commands/council.ts +125 -0
  94. package/src/slash-commands/custom/CustomCommandExecutor.ts +143 -0
  95. package/src/slash-commands/custom/CustomCommandLoader.ts +251 -0
  96. package/src/slash-commands/custom/CustomCommandRegistry.ts +144 -0
  97. package/src/slash-commands/custom/index.ts +7 -0
  98. package/src/slash-commands/debate.ts +254 -0
  99. package/src/slash-commands/gmail.ts +105 -0
  100. package/src/slash-commands/index.ts +388 -0
  101. package/src/slash-commands/mcpCommand.ts +205 -0
  102. package/src/slash-commands/types.ts +201 -0
  103. package/src/store/index.ts +76 -0
  104. package/src/store/selectors.ts +246 -0
  105. package/src/store/slices/appSlice.ts +205 -0
  106. package/src/store/slices/commandSlice.ts +115 -0
  107. package/src/store/slices/configSlice.ts +46 -0
  108. package/src/store/slices/focusSlice.ts +64 -0
  109. package/src/store/slices/index.ts +9 -0
  110. package/src/store/slices/sessionSlice.ts +424 -0
  111. package/src/store/streaming-buffer.ts +425 -0
  112. package/src/store/test.ts +296 -0
  113. package/src/store/types.ts +274 -0
  114. package/src/store/vanilla.ts +186 -0
  115. package/src/tools/builtin/bash.ts +236 -0
  116. package/src/tools/builtin/council.ts +105 -0
  117. package/src/tools/builtin/edit.ts +213 -0
  118. package/src/tools/builtin/glob.ts +136 -0
  119. package/src/tools/builtin/grep.ts +263 -0
  120. package/src/tools/builtin/index.ts +61 -0
  121. package/src/tools/builtin/memory.ts +66 -0
  122. package/src/tools/builtin/read.ts +168 -0
  123. package/src/tools/builtin/skill.ts +97 -0
  124. package/src/tools/builtin/snapshot.ts +40 -0
  125. package/src/tools/builtin/task.ts +106 -0
  126. package/src/tools/builtin/write.ts +134 -0
  127. package/src/tools/createTool.ts +221 -0
  128. package/src/tools/execution/ExecutionPipeline.ts +263 -0
  129. package/src/tools/execution/index.ts +40 -0
  130. package/src/tools/execution/stages/CacheStage.ts +131 -0
  131. package/src/tools/execution/stages/ConfirmationStage.ts +203 -0
  132. package/src/tools/execution/stages/DiscoveryStage.ts +46 -0
  133. package/src/tools/execution/stages/ExecutionStage.ts +48 -0
  134. package/src/tools/execution/stages/FormattingStage.ts +44 -0
  135. package/src/tools/execution/stages/HookStage.ts +72 -0
  136. package/src/tools/execution/stages/PermissionStage.ts +287 -0
  137. package/src/tools/execution/stages/PostHookStage.ts +71 -0
  138. package/src/tools/execution/stages/index.ts +12 -0
  139. package/src/tools/execution/test.ts +266 -0
  140. package/src/tools/execution/types.ts +273 -0
  141. package/src/tools/index.ts +81 -0
  142. package/src/tools/registry.ts +304 -0
  143. package/src/tools/schemas.ts +109 -0
  144. package/src/tools/test.ts +220 -0
  145. package/src/tools/types.ts +175 -0
  146. package/src/tools/validation/PermissionChecker.ts +242 -0
  147. package/src/tools/validation/SensitiveFileDetector.ts +210 -0
  148. package/src/tools/validation/index.ts +11 -0
  149. package/src/ui/App.tsx +166 -0
  150. package/src/ui/components/AegisInterface.tsx +484 -0
  151. package/src/ui/components/common/ChatSearch.tsx +150 -0
  152. package/src/ui/components/common/ErrorBoundary.tsx +82 -0
  153. package/src/ui/components/common/ExitMessage.tsx +120 -0
  154. package/src/ui/components/common/LoadingIndicator.tsx +49 -0
  155. package/src/ui/components/common/index.ts +6 -0
  156. package/src/ui/components/dialog/ConfirmationPrompt.tsx +208 -0
  157. package/src/ui/components/dialog/InteractiveSelector.tsx +149 -0
  158. package/src/ui/components/dialog/SetupWizard.tsx +297 -0
  159. package/src/ui/components/dialog/UpdatePrompt.tsx +155 -0
  160. package/src/ui/components/dialog/index.ts +8 -0
  161. package/src/ui/components/index.ts +28 -0
  162. package/src/ui/components/input/CommandSuggestions.tsx +139 -0
  163. package/src/ui/components/input/CustomTextInput.tsx +220 -0
  164. package/src/ui/components/input/InputArea.tsx +361 -0
  165. package/src/ui/components/input/PromptSuggestions.tsx +66 -0
  166. package/src/ui/components/input/index.ts +6 -0
  167. package/src/ui/components/layout/ChatStatusBar.tsx +90 -0
  168. package/src/ui/components/layout/ContextBar.tsx +79 -0
  169. package/src/ui/components/layout/MessageArea.tsx +96 -0
  170. package/src/ui/components/layout/MessageList.tsx +647 -0
  171. package/src/ui/components/layout/MessageSeparator.tsx +26 -0
  172. package/src/ui/components/layout/WelcomeMessage.tsx +93 -0
  173. package/src/ui/components/layout/index.ts +7 -0
  174. package/src/ui/components/markdown/CodeHighlighter.tsx +292 -0
  175. package/src/ui/components/markdown/MessageRenderer.tsx +1211 -0
  176. package/src/ui/components/markdown/index.ts +8 -0
  177. package/src/ui/components/markdown/parser.ts +336 -0
  178. package/src/ui/components/markdown/types.ts +66 -0
  179. package/src/ui/focus/FocusManager.ts +137 -0
  180. package/src/ui/focus/index.ts +13 -0
  181. package/src/ui/focus/types.ts +54 -0
  182. package/src/ui/focus/useFocus.ts +75 -0
  183. package/src/ui/hooks/index.ts +11 -0
  184. package/src/ui/hooks/useAgent.ts +284 -0
  185. package/src/ui/hooks/useCommandHistory.ts +87 -0
  186. package/src/ui/hooks/useCommandProcessor.ts +443 -0
  187. package/src/ui/hooks/useConfirmation.ts +99 -0
  188. package/src/ui/hooks/useCtrlCHandler.ts +100 -0
  189. package/src/ui/hooks/useInputBuffer.ts +122 -0
  190. package/src/ui/hooks/useTerminalSize.ts +68 -0
  191. package/src/ui/hooks/useTerminalWidth.ts +5 -0
  192. package/src/ui/hooks/useWindowedList.ts +118 -0
  193. package/src/ui/render-debugger.ts +621 -0
  194. package/src/ui/test.ts +189 -0
  195. package/src/ui/themes/ThemeManager.ts +332 -0
  196. package/src/ui/themes/aegisTheme.ts +87 -0
  197. package/src/ui/themes/darkTheme.ts +87 -0
  198. package/src/ui/themes/defaultTheme.ts +85 -0
  199. package/src/ui/themes/index.ts +10 -0
  200. package/src/ui/themes/lightTheme.ts +89 -0
  201. package/src/ui/themes/popularThemes.ts +187 -0
  202. package/src/ui/themes/types.ts +130 -0
  203. package/src/utils/clipboard.ts +48 -0
  204. package/src/utils/debug.ts +43 -0
  205. package/src/utils/environment.ts +68 -0
  206. package/src/utils/index.ts +10 -0
@@ -0,0 +1,951 @@
1
+ /**
2
+ * AEGIS Cross-Session Semantic Memory — v2
3
+ *
4
+ * Upgrades:
5
+ * 1. Vector/embedding-based search via @xenova/transformers
6
+ * 2. SQLite backend (sql.js WASM) for structured + vector storage
7
+ * 3. Richer metadata: topics, entities, sentiment, token count
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+ import { v4 as uuid } from 'uuid';
13
+ import initSqlJs, { Database as SqlJsDb } from 'sql.js';
14
+ import { pushEntries, pushBatch, pullSince, searchCloud } from './CloudSync.js';
15
+
16
+ // ── Paths ────────────────────────────────────────────────────────────────────
17
+ const MEMORY_DIR = path.join(os.homedir(), '.aegiscode', 'memory');
18
+ const DB_PATH = path.join(MEMORY_DIR, 'memory.db');
19
+ const CONFIG_FILE = path.join(os.homedir(), '.aegiscode', 'config.json');
20
+ const SESSION_FILE = path.join(MEMORY_DIR, 'last-session.txt');
21
+ const LAST_CLOUD_SYNC_FILE = path.join(MEMORY_DIR, '.last-cloud-sync');
22
+
23
+ // ── Feature flag — disable embeddings for testing / low-resource ────────────
24
+ const EMBEDDINGS_ENABLED = !process.env.AEGIS_MEMORY_NO_EMBED;
25
+ const OLLAMA_EMBED_URL = process.env.AEGIS_OLLAMA_EMBED_URL || 'http://localhost:11434/api/embed';
26
+
27
+ // ── Constants ────────────────────────────────────────────────────────────────
28
+ const VECTOR_DIM_XENOVA = 384; // all-MiniLM-L6-v2 output dimension
29
+ const VECTOR_DIM_OLLAMA = 768; // nomic-embed-text output dimension
30
+ let ACTUAL_VECTOR_DIM = VECTOR_DIM_XENOVA; // auto-detected at runtime
31
+ const MAX_ENTRIES = 5000;
32
+ const MAX_CONTENT_LEN = 1000;
33
+
34
+ // ── Types ────────────────────────────────────────────────────────────────────
35
+ export interface MemoryEntry {
36
+ id: string;
37
+ timestamp: string;
38
+ source: string;
39
+ role: 'user' | 'assistant';
40
+ tags: string[];
41
+ content: string;
42
+ session: string;
43
+ importance?: number; // 0-1
44
+ summary?: boolean;
45
+
46
+ // v2 rich metadata ──────────────────────────────────
47
+ topics?: string[]; // extracted conversation topics
48
+ entities?: string[]; // named entities (people, tools, code, etc.)
49
+ sentiment?: 'positive' | 'negative' | 'neutral' | 'mixed';
50
+ tokenCount?: number;
51
+ embedding?: number[] | null; // vector embedding (384-dim), null if disabled
52
+ }
53
+
54
+ export interface MemoryConfig {
55
+ ttlDays?: number;
56
+ maxEntries?: number;
57
+ summaryEnabled?: boolean;
58
+ embeddingModel?: string; // override default embedding model
59
+ }
60
+
61
+ // ── Junk filter (unchanged) ─────────────────────────────────────────────────
62
+ const JUNK_PATTERNS = [
63
+ /^Error:/i, /^model ->/i, /^⬡ AEGIS/i, /^## ⬡/i,
64
+ /^## Models/i, /^## status/i, /^## Commands/i,
65
+ /^\[STARTUP/i, /^Starting Container/i, /^WARNING:/i,
66
+ /LLM API Error/i, /^\/model /i, /^\/status/i, /^\/memory/i,
67
+ /^\/billing/i, /^\/council/i, /^\/help/i, /^\/theme/i,
68
+ /^\/clear/i, /^\| sid/i, /^\|──/i, /^\| tok/i, /^\s*$/,
69
+ ];
70
+
71
+ function isJunk(content: string): boolean {
72
+ if (content.length < 8) return true;
73
+ return JUNK_PATTERNS.some(p => p.test(content.trim()));
74
+ }
75
+
76
+ function stripInvalidChars(s: string): string {
77
+ return s
78
+ .replace(/[\uD800-\uDFFF]/g, '')
79
+ .replace(/[\u{1F000}-\u{1FFFF}]/gu, '')
80
+ .replace(/[\u2600-\u27FF]/g, '')
81
+ .trim();
82
+ }
83
+
84
+ function extractTags(content: string): string[] {
85
+ const tags: string[] = [];
86
+ ['docker','railway','flask','python','typescript','react','aegis','joke',
87
+ 'music','sedur','frequ','codex','trading','stripe','memory','cloud',
88
+ 'bug','fix','deploy','error','api','database','security']
89
+ .forEach(t => { if (content.toLowerCase().includes(t)) tags.push(t); });
90
+ return tags;
91
+ }
92
+
93
+ // ── v2: Topic / entity / sentiment extraction ──────────────────────────────
94
+ function extractTopics(content: string): string[] {
95
+ // Simple keyword-based topic extraction
96
+ const topicMap: Record<string, RegExp> = {
97
+ 'coding': /```|function|class|const|let|import|export|interface/i,
98
+ 'debugging': /bug|error|fix|issue|crash|broken|not working/i,
99
+ 'deployment': /deploy|railway|docker|container|cloud/i,
100
+ 'architecture': /architecture|design pattern|scalab|refactor/i,
101
+ 'database': /sqlite|postgres|sql|query|mongo/i,
102
+ 'frontend': /react|vue|css|html|component|ui|interface/i,
103
+ 'backend': /api|endpoint|server|flask|express|route/i,
104
+ 'security': /auth|token|password|encrypt|security|vulnerab/i,
105
+ 'devops': /ci|cd|github|action|pipeline|monitoring/i,
106
+ 'discussion': /\?|what|how|why|should|could|maybe|think/i,
107
+ };
108
+ return Object.entries(topicMap)
109
+ .filter(([_, re]) => re.test(content))
110
+ .map(([topic]) => topic);
111
+ }
112
+
113
+ function extractEntities(content: string): string[] {
114
+ const entities: string[] = [];
115
+ // Code patterns
116
+ const funcMatches = content.match(/(?:function|class|def)\s+(\w+)/g);
117
+ if (funcMatches) entities.push(...funcMatches.map(m => m.split(/\s+/)[1]));
118
+ // File paths
119
+ const fileMatches = content.match(/(?:`[^`]+`)/g);
120
+ if (fileMatches) entities.push(...fileMatches.map(m => m.replace(/`/g, '')));
121
+ // Package/module names
122
+ const pkgMatches = content.match(/(?:from\s+|require\s*\(\s*['"])['"]([^'"]+)['"]/g);
123
+ if (pkgMatches) entities.push(...pkgMatches.map(m => {
124
+ const inner = m.match(/['"]([^'"]+)['"]/);
125
+ return inner ? inner[1] : '';
126
+ }));
127
+ return [...new Set(entities)].filter(Boolean).slice(0, 10);
128
+ }
129
+
130
+ function detectSentiment(content: string): 'positive' | 'negative' | 'neutral' | 'mixed' {
131
+ const positiveWords = ['great','awesome','perfect','fixed','solved','works','love','excellent','good','thanks'];
132
+ const negativeWords = ['broken','bug','error','crash','fails','terrible','bad','stupid','wrong','issue'];
133
+ const lower = content.toLowerCase();
134
+ let posScore = 0, negScore = 0;
135
+ for (const w of positiveWords) { if (lower.includes(w)) posScore++; }
136
+ for (const w of negativeWords) { if (lower.includes(w)) negScore++; }
137
+ if (posScore > 0 && negScore > 0) return 'mixed';
138
+ if (posScore > 0) return 'positive';
139
+ if (negScore > 0) return 'negative';
140
+ return 'neutral';
141
+ }
142
+
143
+ function estimateTokens(text: string): number {
144
+ // rough estimate: ~4 chars per token
145
+ return Math.ceil(text.length / 4);
146
+ }
147
+
148
+ // ── Importance scoring (enhanced) ───────────────────────────────────────────
149
+ function scoreImportance(content: string, role: 'user' | 'assistant'): number {
150
+ let score = role === 'user' ? 0.7 : 0.4;
151
+ if (/\?/.test(content)) score += 0.1;
152
+ if (/```|function|class|import|export/.test(content)) score += 0.15;
153
+ if (/decided|conclusion|solution|fixed|resolved|summary|key/i.test(content)) score += 0.15;
154
+ if (content.length > 200) score += 0.1;
155
+ if (content.length < 30) score -= 0.2;
156
+ // Boost for explicit decisions
157
+ if (/^decision:|^conclusion:|^important:/i.test(content.trim())) score += 0.2;
158
+ return Math.max(0, Math.min(1, score));
159
+ }
160
+
161
+ // ── Config reader ───────────────────────────────────────────────────────────
162
+ function getMemoryConfig(): MemoryConfig {
163
+ try {
164
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
165
+ return cfg?.memoryConfig ?? {};
166
+ } catch { return {}; }
167
+ }
168
+
169
+ // ── Ollama embedding (fallback for local models) ────────────────────────────
170
+ let ollamaBaseUrl = '';
171
+
172
+ export function setOllamaBaseUrl(url?: string) {
173
+ if (url && url.includes('11434')) {
174
+ ollamaBaseUrl = url.replace(/\/+$/, '');
175
+ }
176
+ }
177
+
178
+ // ── Embedding pipeline (singleton) ──────────────────────────────────────────
179
+ let embedPipeline: ((texts: string[]) => Promise<number[][]>) | null = null;
180
+
181
+ // Ollama embedding via its embed API (returns 768-dim for nomic-embed-text, 384 for all-minilm)
182
+ async function ollamaEmbed(texts: string[]): Promise<number[][]> {
183
+ try {
184
+ const res = await fetch(OLLAMA_EMBED_URL, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ model: 'nomic-embed-text', input: texts.length === 1 ? texts[0] : texts }),
188
+ });
189
+ if (!res.ok) throw new Error(`Ollama embed HTTP ${res.status}`);
190
+ const data = await res.json() as any;
191
+ // Ollama returns { embeddings: number[][] } or { embedding: number[] } for single input
192
+ if (data.embeddings) return data.embeddings as number[][];
193
+ if (data.embedding) return [data.embedding as number[]];
194
+ throw new Error('Unexpected Ollama response format');
195
+ } catch (e) {
196
+ console.warn('[Memory] Ollama embed failed:', e);
197
+ throw e;
198
+ }
199
+ }
200
+
201
+ async function getEmbedder(): Promise<((texts: string[]) => Promise<number[][]>) | null> {
202
+ if (!EMBEDDINGS_ENABLED) return null;
203
+ if (embedPipeline) return embedPipeline;
204
+
205
+ // 1. Try Ollama first if configured
206
+ if (process.env.AEGIS_OLLAMA_EMBED_URL || process.env.OLLAMA_HOST || ollamaBaseUrl) {
207
+ try {
208
+ const testRes = await fetch(OLLAMA_EMBED_URL, {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ model: 'nomic-embed-text', input: 'test' }),
212
+ });
213
+ if (testRes.ok) {
214
+ embedPipeline = async (texts: string[]) => {
215
+ const result = await ollamaEmbed(texts);
216
+ return result ?? texts.map(() => new Array(ACTUAL_VECTOR_DIM).fill(0));
217
+ };
218
+ console.warn('[Memory] Using Ollama embeddings (nomic-embed-text)');
219
+ return embedPipeline;
220
+ }
221
+ } catch {
222
+ console.warn('[Memory] Ollama not available, falling back to Xenova');
223
+ }
224
+ }
225
+
226
+ // 2. Fallback to Xenova transformers (local)
227
+ try {
228
+ const { pipeline } = await import('@xenova/transformers');
229
+ const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
230
+ embedPipeline = async (texts: string[]) => {
231
+ const results = await Promise.all(
232
+ texts.map(t => extractor(t, { pooling: 'mean', normalize: true }))
233
+ );
234
+ return results.map(r => Array.from(r.data as Float32Array));
235
+ };
236
+ console.warn('[Memory] Using Xenova local embeddings (all-MiniLM-L6-v2)');
237
+ return embedPipeline;
238
+ } catch (e) {
239
+ console.warn('[Memory] Embedding model unavailable, falling back to keyword search');
240
+ return null;
241
+ }
242
+ }
243
+
244
+ // ── SQLite schema ───────────────────────────────────────────────────────────
245
+ async function initDb(): Promise<SqlJsDb> {
246
+ const SQL = await initSqlJs();
247
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
248
+
249
+ let db: SqlJsDb;
250
+ if (fs.existsSync(DB_PATH)) {
251
+ const buf = fs.readFileSync(DB_PATH);
252
+ db = new SQL.Database(buf);
253
+ } else {
254
+ db = new SQL.Database();
255
+ }
256
+
257
+ db.run(`
258
+ CREATE TABLE IF NOT EXISTS memories (
259
+ id TEXT PRIMARY KEY,
260
+ timestamp TEXT NOT NULL,
261
+ source TEXT NOT NULL,
262
+ role TEXT NOT NULL CHECK(role IN ('user','assistant')),
263
+ tags TEXT NOT NULL DEFAULT '[]',
264
+ content TEXT NOT NULL,
265
+ session TEXT NOT NULL,
266
+ importance REAL NOT NULL DEFAULT 0.5,
267
+ summary INTEGER NOT NULL DEFAULT 0,
268
+ topics TEXT NOT NULL DEFAULT '[]',
269
+ entities TEXT NOT NULL DEFAULT '[]',
270
+ sentiment TEXT NOT NULL DEFAULT 'neutral',
271
+ token_count INTEGER NOT NULL DEFAULT 0,
272
+ embedding BLOB
273
+ );
274
+ `);
275
+
276
+ db.run(`
277
+ CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session);
278
+ `);
279
+ db.run(`
280
+ CREATE INDEX IF NOT EXISTS idx_memories_timestamp ON memories(timestamp DESC);
281
+ `);
282
+ db.run(`
283
+ CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC);
284
+ `);
285
+
286
+ return db;
287
+ }
288
+
289
+ // ── Helper: serialize JSON array to TEXT ────────────────────────────────────
290
+ function jsonArr(arr: string[]): string {
291
+ return JSON.stringify(arr);
292
+ }
293
+ function parseJsonArr(s: string): string[] {
294
+ try { return JSON.parse(s); } catch { return []; }
295
+ }
296
+
297
+ // ── Cosine similarity between two vectors ───────────────────────────────────
298
+ function cosineSimilarity(a: number[], b: number[]): number {
299
+ // Guard: mismatched dimensions (e.g. Xenova 384 vs Ollama 768) produce NaN
300
+ if (a.length !== b.length || a.length === 0) return 0;
301
+ let dot = 0, na = 0, nb = 0;
302
+ for (let i = 0; i < a.length; i++) {
303
+ dot += a[i] * b[i];
304
+ na += a[i] * a[i];
305
+ nb += b[i] * b[i];
306
+ }
307
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
308
+ return denom === 0 ? 0 : dot / denom;
309
+ }
310
+
311
+ // ══════════════════════════════════════════════════════════════════════════
312
+ // SharedMemory class
313
+ // ══════════════════════════════════════════════════════════════════════════
314
+ export class SharedMemory {
315
+ private db!: SqlJsDb;
316
+ public readonly userId: string;
317
+ private embedder: ((texts: string[]) => Promise<number[][]>) | null = null;
318
+ private ready: Promise<void>;
319
+
320
+ constructor() {
321
+ this.userId = this.loadUserId();
322
+ this.ready = this.init();
323
+ }
324
+
325
+ private async init() {
326
+ this.db = await initDb();
327
+
328
+ // Read-only one-shot callers (e.g. aegiscode-gui's stats/search introspection)
329
+ // must NOT trigger TTL eviction or a cloud sync/import — both commit() the db,
330
+ // and a short-lived process racing a live interactive session's in-memory state
331
+ // is exactly how a fresh restore got silently clobbered. Skip mutating side
332
+ // effects entirely when read-only.
333
+ if (process.env.AEGIS_MEMORY_READONLY === '1') return;
334
+
335
+ this.embedder = await getEmbedder();
336
+ this.applyTTL();
337
+ if (this.hasApiKey()) void this.syncFromCloud();
338
+ }
339
+
340
+ // ── Cloud sync (all logged-in users) ────────────────────────────────────
341
+ private getApiKeyFromConfig(): string | null {
342
+ try {
343
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
344
+ return cfg?.aegiscloud?.api_key ?? null;
345
+ } catch { return null; }
346
+ }
347
+
348
+ /** Pull entries newer than the last sync marker and merge them in. Never blocks startup. */
349
+ private async syncFromCloud(): Promise<void> {
350
+ const apiKey = this.getApiKeyFromConfig();
351
+ if (!apiKey) return;
352
+
353
+ let since: string | null = null;
354
+ try {
355
+ if (fs.existsSync(LAST_CLOUD_SYNC_FILE)) {
356
+ since = fs.readFileSync(LAST_CLOUD_SYNC_FILE, 'utf8').trim() || null;
357
+ }
358
+ } catch {}
359
+
360
+ const pulled = await pullSince(since, apiKey);
361
+ if (pulled.length === 0) return;
362
+
363
+ this.import(pulled, true);
364
+
365
+ const newest = pulled.reduce((max, e) => (e.timestamp > max ? e.timestamp : max), since ?? '');
366
+ try {
367
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
368
+ fs.writeFileSync(LAST_CLOUD_SYNC_FILE, newest);
369
+ } catch {}
370
+ }
371
+
372
+ /**
373
+ * One-shot full download for external callers (e.g. aegiscode-gui's "Download
374
+ * from Cloud" button) — pulls every entry the account has on aegiscloud.org,
375
+ * not just what's newer than the last sync marker, and merges it into the
376
+ * local db. Unlike syncFromCloud() this is safe to call in AEGIS_MEMORY_READONLY
377
+ * mode: it's an explicit one-shot the user asked for, not the automatic
378
+ * startup sync that mode exists to suppress.
379
+ */
380
+ async pullAll(): Promise<{ total: number; pulled: number }> {
381
+ await this.ensureReady();
382
+ const apiKey = this.getApiKeyFromConfig();
383
+ if (!apiKey) return { total: 0, pulled: 0 };
384
+
385
+ const pulled = await pullSince(null, apiKey);
386
+ if (pulled.length === 0) return { total: 0, pulled: 0 };
387
+
388
+ this.import(pulled, true);
389
+
390
+ const newest = pulled.reduce((max, e) => (e.timestamp > max ? e.timestamp : max), '');
391
+ try {
392
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
393
+ fs.writeFileSync(LAST_CLOUD_SYNC_FILE, newest);
394
+ } catch {}
395
+
396
+ return { total: pulled.length, pulled: pulled.length };
397
+ }
398
+
399
+ /** Await readiness before any operation */
400
+ private async ensureReady() {
401
+ await this.ready;
402
+ }
403
+
404
+ /** Public wrapper for callers (e.g. one-shot CLI flags) that need the db loaded before calling sync methods like getStats()/recent()/clear(). */
405
+ async whenReady() {
406
+ await this.ensureReady();
407
+ }
408
+
409
+ // ── User ID ─────────────────────────────────────────────────────────────
410
+ private loadUserId(): string {
411
+ try {
412
+ if (fs.existsSync(SESSION_FILE)) {
413
+ const stored = fs.readFileSync(SESSION_FILE, 'utf8').trim();
414
+ if (stored) return stored;
415
+ }
416
+ } catch {}
417
+ const id = 'user_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
418
+ try {
419
+ fs.mkdirSync(MEMORY_DIR, { recursive: true });
420
+ fs.writeFileSync(SESSION_FILE, id);
421
+ } catch {}
422
+ return id;
423
+ }
424
+
425
+ // ── TTL ─────────────────────────────────────────────────────────────────
426
+ private applyTTL() {
427
+ const cfg = getMemoryConfig();
428
+ if (!cfg.ttlDays) return;
429
+ const cutoff = Date.now() - cfg.ttlDays * 24 * 60 * 60 * 1000;
430
+ const isoCutoff = new Date(cutoff).toISOString();
431
+ this.db.run(`DELETE FROM memories WHERE timestamp < ? AND summary = 0`, [isoCutoff]);
432
+ this.commit();
433
+ }
434
+
435
+ // ── Add entry ───────────────────────────────────────────────────────────
436
+ async add(
437
+ content: string,
438
+ source: string,
439
+ session: string,
440
+ tags: string[] = [],
441
+ role: 'user' | 'assistant' = 'assistant',
442
+ immediate = false,
443
+ ): Promise<MemoryEntry | null> {
444
+ await this.ensureReady();
445
+
446
+ if (!this.isWriteAllowed(session)) return null;
447
+
448
+ const cleaned = stripInvalidChars(content);
449
+ if (isJunk(cleaned)) return null;
450
+
451
+ const cfg = getMemoryConfig();
452
+ const max = cfg.maxEntries ?? MAX_ENTRIES;
453
+
454
+ const id = uuid();
455
+ const timestamp = new Date().toISOString();
456
+ const importance = scoreImportance(cleaned, role);
457
+ const allTags = [...new Set([...tags, ...extractTags(cleaned)])];
458
+ const truncated = cleaned.slice(0, MAX_CONTENT_LEN);
459
+ const topics = extractTopics(truncated);
460
+ const entities = extractEntities(truncated);
461
+ const sentiment = detectSentiment(truncated);
462
+ const tokenCount = estimateTokens(truncated);
463
+
464
+ // Generate embedding (async, can fail gracefully)
465
+ let embeddingBuf: Buffer | null = null;
466
+ if (this.embedder) {
467
+ try {
468
+ const vecs = await this.embedder([truncated]);
469
+ if (vecs[0]) {
470
+ embeddingBuf = Buffer.from(new Float32Array(vecs[0]).buffer);
471
+ }
472
+ } catch {
473
+ // embedding failed — proceed without it
474
+ }
475
+ }
476
+
477
+ this.db.run(`
478
+ INSERT INTO memories (id, timestamp, source, role, tags, content, session,
479
+ importance, summary, topics, entities, sentiment, token_count, embedding)
480
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
481
+ `, [
482
+ id, timestamp, source, role, jsonArr(allTags), truncated, session,
483
+ importance, 0, jsonArr(topics), jsonArr(entities), sentiment, tokenCount, embeddingBuf,
484
+ ]);
485
+
486
+ // Enforce max entries
487
+ const count = (this.db.exec(`SELECT COUNT(*) AS c FROM memories`)[0]?.values[0][0] as number) ?? 0;
488
+ if (count > max) {
489
+ this.db.run(`
490
+ DELETE FROM memories WHERE id IN (
491
+ SELECT id FROM memories ORDER BY importance DESC, timestamp DESC
492
+ LIMIT -1 OFFSET ?
493
+ )
494
+ `, [max]);
495
+ }
496
+
497
+ this.commit();
498
+
499
+ const entry: MemoryEntry = {
500
+ id, timestamp, source, role, tags: allTags, content: truncated,
501
+ session, importance, summary: false,
502
+ topics, entities, sentiment, tokenCount,
503
+ embedding: embeddingBuf ? Array.from(new Float32Array(embeddingBuf.buffer, embeddingBuf.byteOffset, embeddingBuf.byteLength / 4)) : null,
504
+ };
505
+
506
+ const apiKey = this.getApiKeyFromConfig();
507
+ if (apiKey) void pushEntries([entry], apiKey);
508
+
509
+ return entry;
510
+ }
511
+
512
+ // ── Search (hybrid: keyword + vector) ───────────────────────────────────
513
+ async search(query: string, limit = 6): Promise<MemoryEntry[]> {
514
+ await this.ensureReady();
515
+
516
+ if (!this.hasApiKey()) return [];
517
+
518
+ const q = query.toLowerCase();
519
+ const words = q.split(/\s+/).filter(w =>
520
+ w.length > 3 && !['what','that','this','with','have','from','your','just','been','were'].includes(w)
521
+ );
522
+
523
+ if (words.length === 0 && !this.embedder) {
524
+ return this.mergeCloudResults(this.recent(limit), query, limit);
525
+ }
526
+
527
+ // ── Keyword score (ALL entries) ──
528
+ const allRows = this.db.exec(`SELECT * FROM memories ORDER BY timestamp DESC LIMIT 1000`);
529
+ const allEntries = this.rowsToEntries(allRows);
530
+
531
+ const keywordScored = allEntries.map(e => {
532
+ const text = (e.content + ' ' + e.tags.join(' ')).toLowerCase();
533
+ const keywordScore = words.reduce((s, w) => {
534
+ const safe = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
535
+ const count = (text.match(new RegExp(safe, 'g')) || []).length;
536
+ return s + count;
537
+ }, 0);
538
+ const importBoost = e.session === 'aegiscloud-import' ? 1.5 : 1;
539
+ const total = keywordScore * (1 + (e.importance ?? 0.5)) * importBoost;
540
+ return { entry: e, score: total };
541
+ }).filter(({ score }) => score > 0);
542
+
543
+ // Sort by keyword score
544
+ keywordScored.sort((a, b) => b.score - a.score);
545
+
546
+ // ── Vector score (embedding query) ──
547
+ if (this.embedder && words.length > 0) {
548
+ try {
549
+ const [queryVec] = await this.embedder([q]);
550
+ if (queryVec) {
551
+ // Score top-50 keyword results with vector similarity
552
+ const top50 = keywordScored.slice(0, 50);
553
+ const vectorScored = top50.map(({ entry, score }) => {
554
+ let vecSim = 0;
555
+ if (entry.embedding && entry.embedding.length > 0) {
556
+ vecSim = cosineSimilarity(queryVec, entry.embedding);
557
+ }
558
+ // Hybrid score: 40% keyword, 60% vector (if embedding available)
559
+ const hybrid = score * 0.4 + vecSim * 6;
560
+ return { entry, score: hybrid, vecSim };
561
+ });
562
+ vectorScored.sort((a, b) => b.score - a.score);
563
+ const vectorResults = vectorScored.slice(0, limit).map(({ entry }) => entry);
564
+ return this.mergeCloudResults(vectorResults, query, limit);
565
+ }
566
+ } catch {
567
+ // fall through to keyword-only
568
+ }
569
+ }
570
+
571
+ const localResults = keywordScored.slice(0, limit).map(({ entry }) => entry);
572
+ return this.mergeCloudResults(localResults, query, limit);
573
+ }
574
+
575
+ /**
576
+ * Top up local results with a cloud keyword search — catches entries written on
577
+ * another device since the last `pullSince`. Capped at 1.5s so an offline or slow
578
+ * connection never meaningfully delays a search; on any failure, local results stand.
579
+ */
580
+ private async mergeCloudResults(localResults: MemoryEntry[], query: string, limit: number): Promise<MemoryEntry[]> {
581
+ if (localResults.length >= limit) return localResults;
582
+ const apiKey = this.getApiKeyFromConfig();
583
+ if (!apiKey) return localResults;
584
+
585
+ const timeout = new Promise<MemoryEntry[]>(resolve => setTimeout(() => resolve([]), 1500));
586
+ const cloudResults = await Promise.race([searchCloud(query, limit, apiKey), timeout]);
587
+ if (cloudResults.length === 0) return localResults;
588
+
589
+ const seenIds = new Set(localResults.map(e => e.id));
590
+ const fresh = cloudResults.filter(e => !seenIds.has(e.id));
591
+ return [...localResults, ...fresh].slice(0, limit);
592
+ }
593
+
594
+ recent(limit = 6): MemoryEntry[] {
595
+ const rows = this.db.exec(`SELECT * FROM memories ORDER BY timestamp DESC LIMIT ?`, [limit]);
596
+ return this.rowsToEntries(rows);
597
+ }
598
+
599
+ // ── Episodic summarization ───────────────────────────────────────────────
600
+ async summarizeAndStoreSession(
601
+ sessionId: string,
602
+ apiKey?: string,
603
+ baseURL?: string,
604
+ model?: string,
605
+ ): Promise<boolean> {
606
+ await this.ensureReady();
607
+
608
+ const cfg = getMemoryConfig();
609
+ if (cfg.summaryEnabled === false) return false;
610
+
611
+ const rows = this.db.exec(
612
+ `SELECT * FROM memories WHERE session = ? AND summary = 0 ORDER BY timestamp DESC LIMIT 100`,
613
+ [sessionId]
614
+ );
615
+ const sessionEntries = this.rowsToEntries(rows);
616
+ if (sessionEntries.length < 3) return false;
617
+
618
+ const existing = this.db.exec(
619
+ `SELECT id FROM memories WHERE session = ? AND summary = 1 LIMIT 1`,
620
+ [sessionId]
621
+ );
622
+ if (existing.length > 0) return false;
623
+
624
+ const summary = await this.summarizeSession(sessionId, sessionEntries, apiKey, baseURL, model);
625
+ if (!summary) return false;
626
+
627
+ const id = 'sum_' + sessionId;
628
+ const allTags = [...new Set(sessionEntries.flatMap(e => e.tags))];
629
+ const summaryContent = `[Session Summary] ${stripInvalidChars(summary)}`;
630
+
631
+ this.db.run(`
632
+ INSERT INTO memories (id, timestamp, source, role, tags, content, session,
633
+ importance, summary, topics, entities, sentiment, token_count)
634
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
635
+ `, [
636
+ id, new Date().toISOString(), 'aegis-cli', 'assistant',
637
+ jsonArr(allTags), summaryContent, sessionId,
638
+ 0.9, 1, '["summary"]', '[]', 'neutral', estimateTokens(summaryContent),
639
+ ]);
640
+
641
+ this.commit();
642
+ return true;
643
+ }
644
+
645
+ private async summarizeSession(
646
+ sessionId: string,
647
+ entries: MemoryEntry[],
648
+ apiKey?: string,
649
+ baseURL?: string,
650
+ model?: string,
651
+ ): Promise<string | null> {
652
+ if (!apiKey || entries.length < 3) return null;
653
+ // Claude Code Pro/Max OAuth tokens (sk-ant-oat...) only work through the
654
+ // official Claude Code client, not direct API calls — skip rather than
655
+ // make a request that's guaranteed to be rejected.
656
+ if (apiKey.startsWith('sk-ant-oat')) return null;
657
+
658
+ const conversation = entries
659
+ .slice(0, 20)
660
+ .map(e => `${e.role.toUpperCase()}: ${e.content.slice(0, 200)}`)
661
+ .join('\n');
662
+
663
+ try {
664
+ const isAnthropic = (baseURL || '').includes('anthropic.com');
665
+ const headers: Record<string, string> = {
666
+ 'Content-Type': 'application/json',
667
+ ...(isAnthropic
668
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
669
+ : { 'Authorization': `Bearer ${apiKey}` }),
670
+ };
671
+
672
+ const body = isAnthropic ? {
673
+ model: model || 'claude-sonnet-4-6',
674
+ max_tokens: 150,
675
+ messages: [{ role: 'user', content: `Summarize this conversation in 2-3 sentences. Focus on key decisions, problems solved, and important context:\n\n${conversation}` }],
676
+ } : {
677
+ model: model || 'gpt-4o-mini',
678
+ max_tokens: 150,
679
+ messages: [
680
+ { role: 'system', content: 'Summarize conversations in 2-3 sentences. Focus on key decisions, problems solved, and important context.' },
681
+ { role: 'user', content: conversation },
682
+ ],
683
+ };
684
+
685
+ const res = await fetch(
686
+ isAnthropic ? 'https://api.anthropic.com/v1/messages' : `${baseURL}/chat/completions`,
687
+ { method: 'POST', headers, body: JSON.stringify(body) }
688
+ );
689
+ const data = await res.json() as any;
690
+ return isAnthropic
691
+ ? data?.content?.[0]?.text
692
+ : data?.choices?.[0]?.message?.content;
693
+ } catch {
694
+ return null;
695
+ }
696
+ }
697
+
698
+ // ── buildContext ─────────────────────────────────────────────────────────
699
+ async buildContext(query: string, maxEntries = 4, currentSession?: string): Promise<string> {
700
+ await this.ensureReady();
701
+
702
+ const apiKey = this.getApiKeyFromConfig();
703
+ const seen = new Set<string>();
704
+ const combined: MemoryEntry[] = [];
705
+
706
+ if (apiKey) {
707
+ // Logged-in user: full cross-session memory with search, summaries, recent
708
+ const relevant = await this.search(query, Math.min(4, maxEntries));
709
+ const recent = this.recent(Math.min(2, maxEntries));
710
+ const summaryRows = this.db.exec(
711
+ `SELECT * FROM memories WHERE summary = 1 ORDER BY timestamp DESC LIMIT 3`
712
+ );
713
+ const summaries = this.rowsToEntries(summaryRows);
714
+
715
+ for (const e of summaries) {
716
+ if (!seen.has(e.id)) { seen.add(e.id); combined.push(e); }
717
+ }
718
+ for (const e of relevant) {
719
+ if (!seen.has(e.id)) { seen.add(e.id); combined.push(e); }
720
+ }
721
+ for (const e of recent) {
722
+ if (!seen.has(e.id)) { seen.add(e.id); combined.push(e); }
723
+ }
724
+ }
725
+
726
+ // All users always get current session context
727
+ if (currentSession) {
728
+ const sessionRows = this.db.exec(
729
+ `SELECT * FROM memories WHERE session = ? AND summary = 0 ORDER BY timestamp DESC LIMIT 4`,
730
+ [currentSession]
731
+ );
732
+ const sessionContext = this.rowsToEntries(sessionRows);
733
+ for (const e of sessionContext) {
734
+ if (!seen.has(e.id)) { seen.add(e.id); combined.push(e); }
735
+ }
736
+ }
737
+
738
+ if (combined.length === 0) return '';
739
+
740
+ const bySession: Record<string, MemoryEntry[]> = {};
741
+ for (const e of combined) {
742
+ if (!bySession[e.session]) bySession[e.session] = [];
743
+ bySession[e.session].push(e);
744
+ }
745
+
746
+ const lines: string[] = [
747
+ '--- PREVIOUS CONVERSATIONS (for context) ---',
748
+ 'Use this context to answer questions about previous interactions.',
749
+ '',
750
+ ];
751
+
752
+ for (const [session, entries] of Object.entries(bySession)) {
753
+ const date = entries[0]?.timestamp?.slice(0, 10) ?? '';
754
+ const isSummary = entries.some(e => e.summary);
755
+ const isCurrentSession = session === currentSession;
756
+ const isImport = session === 'aegiscloud-import' || session === 'imported';
757
+ const label = isCurrentSession ? 'Current Session' : isSummary ? 'Summary' : isImport ? 'Imported' : 'Session';
758
+ lines.push(`[${label} ${session.slice(0, 8)} · ${date}]`);
759
+ const sorted = [...entries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
760
+ for (const e of sorted) {
761
+ const prefix = e.summary ? 'SUMMARY:' : e.role === 'user' ? 'User:' : 'AEGIS:';
762
+ lines.push(` ${prefix} ${e.content.slice(0, 200)}`);
763
+ }
764
+ lines.push('');
765
+ }
766
+
767
+ lines.push('--- END MEMORIES ---');
768
+ return lines.join('\n');
769
+ }
770
+
771
+ // ── Utility ──────────────────────────────────────────────────────────────
772
+ private rowsToEntries(execResult: any): MemoryEntry[] {
773
+ const cols = execResult[0]?.columns ?? [];
774
+ const rows = execResult[0]?.values ?? [];
775
+ return rows.map((row: any[]) => {
776
+ const obj: Record<string, any> = {};
777
+ cols.forEach((c: string, i: number) => { obj[c] = row[i]; });
778
+ const entry: MemoryEntry = {
779
+ id: obj.id,
780
+ timestamp: obj.timestamp,
781
+ source: obj.source,
782
+ role: obj.role,
783
+ tags: parseJsonArr(obj.tags),
784
+ content: obj.content,
785
+ session: obj.session,
786
+ importance: obj.importance,
787
+ summary: obj.summary === 1,
788
+ topics: parseJsonArr(obj.topics),
789
+ entities: parseJsonArr(obj.entities),
790
+ sentiment: obj.sentiment,
791
+ tokenCount: obj.token_count,
792
+ embedding: null,
793
+ };
794
+ // Deserialize embedding blob if present — auto-detect dimension from byte length
795
+ if (obj.embedding instanceof Uint8Array || obj.embedding instanceof Buffer) {
796
+ const buf = obj.embedding as Buffer;
797
+ const dim = buf.length / 4;
798
+ if (Number.isInteger(dim) && (dim === VECTOR_DIM_XENOVA || dim === VECTOR_DIM_OLLAMA)) {
799
+ entry.embedding = Array.from(new Float32Array(buf.buffer, buf.byteOffset, dim));
800
+ }
801
+ }
802
+ return entry;
803
+ });
804
+ }
805
+
806
+ private commit() {
807
+ try {
808
+ const data = this.db.export();
809
+ fs.writeFileSync(DB_PATH, Buffer.from(data));
810
+ } catch {}
811
+ }
812
+
813
+ // ── Simple logged-in check ─────────────────────────────────────────────
814
+
815
+ /** True if user has a stored API key (paid through Aegis, logged in). */
816
+ hasApiKey(): boolean {
817
+ return this.getApiKeyFromConfig() !== null;
818
+ }
819
+
820
+ /** Write permission: any logged-in user can write memory. */
821
+ isWriteAllowed(_sessionId: string): boolean {
822
+ return this.hasApiKey();
823
+ }
824
+
825
+ /** Read permission: any logged-in user can read memory. */
826
+ isEnabled(): boolean {
827
+ return this.hasApiKey();
828
+ }
829
+
830
+ size(): number {
831
+ const res = this.db.exec(`SELECT COUNT(*) AS c FROM memories`);
832
+ return (res[0]?.values[0][0] as number) ?? 0;
833
+ }
834
+
835
+ clear() {
836
+ this.db.run(`DELETE FROM memories`);
837
+ this.commit();
838
+ }
839
+
840
+ export(): MemoryEntry[] {
841
+ const rows = this.db.exec(`SELECT * FROM memories ORDER BY timestamp DESC`);
842
+ return this.rowsToEntries(rows);
843
+ }
844
+
845
+ /** Push every local entry to the cloud in batches, regardless of last-sync state. */
846
+ async pushAll(batchSize = 500): Promise<{ total: number; pushed: number }> {
847
+ await this.ensureReady();
848
+ const apiKey = this.getApiKeyFromConfig();
849
+ const entries = this.export();
850
+ if (!apiKey || entries.length === 0) return { total: entries.length, pushed: 0 };
851
+
852
+ let pushed = 0;
853
+ for (let i = 0; i < entries.length; i += batchSize) {
854
+ pushed += await pushBatch(entries.slice(i, i + batchSize), apiKey);
855
+ }
856
+ return { total: entries.length, pushed };
857
+ }
858
+
859
+ import(entries: MemoryEntry[], merge = true) {
860
+ const existingRows = this.db.exec(`SELECT id FROM memories`);
861
+ const existingIds = new Set(
862
+ existingRows[0]?.values.map((r: any) => r[0] as string) ?? []
863
+ );
864
+
865
+ for (const e of entries) {
866
+ if (!e.id || !e.content) continue;
867
+ if (existingIds.has(e.id) && !merge) continue;
868
+
869
+ let embeddingBuf: Buffer | null = null;
870
+ if (e.embedding && e.embedding.length > 0) {
871
+ embeddingBuf = Buffer.from(new Float32Array(e.embedding).buffer);
872
+ }
873
+
874
+ this.db.run(`
875
+ INSERT OR REPLACE INTO memories
876
+ (id, timestamp, source, role, tags, content, session, importance, summary,
877
+ topics, entities, sentiment, token_count, embedding)
878
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
879
+ `, [
880
+ e.id, e.timestamp ?? new Date().toISOString(), e.source ?? 'cloud',
881
+ e.role ?? 'assistant', jsonArr(e.tags), e.content, e.session ?? 'cloud-import',
882
+ e.importance ?? 0.5, e.summary ? 1 : 0,
883
+ jsonArr(e.topics ?? []), jsonArr(e.entities ?? []), e.sentiment ?? 'neutral',
884
+ e.tokenCount ?? estimateTokens(e.content), embeddingBuf,
885
+ ]);
886
+ }
887
+ this.commit();
888
+ }
889
+
890
+ getSessionEntries(sessionId: string): MemoryEntry[] {
891
+ const rows = this.db.exec(
892
+ `SELECT * FROM memories WHERE session = ? ORDER BY timestamp DESC`,
893
+ [sessionId]
894
+ );
895
+ return this.rowsToEntries(rows);
896
+ }
897
+
898
+ getStats() {
899
+ const total = this.size();
900
+ const sessionRes = this.db.exec(`SELECT COUNT(DISTINCT session) AS c FROM memories`);
901
+ const sessions = (sessionRes[0]?.values[0][0] as number) ?? 0;
902
+ const summaryRes = this.db.exec(`SELECT COUNT(*) AS c FROM memories WHERE summary = 1`);
903
+ const summaries = (summaryRes[0]?.values[0][0] as number) ?? 0;
904
+ const avgRes = this.db.exec(`SELECT AVG(importance) AS a FROM memories`);
905
+ const avgImportance = (avgRes[0]?.values[0][0] as number) ?? 0;
906
+
907
+ // Embedding stats
908
+ const embedRes = this.db.exec(`SELECT COUNT(*) AS c FROM memories WHERE embedding IS NOT NULL`);
909
+ const withEmbeddings = (embedRes[0]?.values[0][0] as number) ?? 0;
910
+
911
+ const roleRes = this.db.exec(`SELECT role, COUNT(*) AS c FROM memories GROUP BY role`);
912
+ const byRole = { user: 0, assistant: 0, other: 0 };
913
+ for (const row of roleRes[0]?.values ?? []) {
914
+ const role = row[0] as string;
915
+ const count = row[1] as number;
916
+ if (role === 'user' || role === 'assistant') byRole[role] = count;
917
+ else byRole.other += count;
918
+ }
919
+
920
+ return {
921
+ total,
922
+ sessions,
923
+ summaries,
924
+ byRole,
925
+ avgImportance: avgImportance.toFixed(2),
926
+ enabled: this.hasApiKey(),
927
+ withEmbeddings,
928
+ embeddingsEnabled: EMBEDDINGS_ENABLED,
929
+ };
930
+ }
931
+
932
+ /** Search by topic */
933
+ searchByTopic(topic: string, limit = 10): MemoryEntry[] {
934
+ const rows = this.db.exec(
935
+ `SELECT * FROM memories WHERE topics LIKE ? ORDER BY importance DESC, timestamp DESC LIMIT ?`,
936
+ [`%"${topic}"%`, limit]
937
+ );
938
+ return this.rowsToEntries(rows);
939
+ }
940
+
941
+ /** Search by entity */
942
+ searchByEntity(entity: string, limit = 10): MemoryEntry[] {
943
+ const rows = this.db.exec(
944
+ `SELECT * FROM memories WHERE entities LIKE ? ORDER BY importance DESC, timestamp DESC LIMIT ?`,
945
+ [`%"${entity}"%`, limit]
946
+ );
947
+ return this.rowsToEntries(rows);
948
+ }
949
+ }
950
+
951
+ export const sharedMemory = new SharedMemory();