aegiscode 3.1.10 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/bin/aegis-cli-linux-x64 +0 -0
  2. package/bin/cli.js +68 -0
  3. package/package.json +11 -48
  4. package/scripts/download-binary.mjs +155 -0
  5. package/dist/main.js +0 -2171
  6. package/gui/run-gui.cjs +0 -13
  7. package/src/agent/Agent.ts +0 -903
  8. package/src/agent/SimpleAgent.ts +0 -48
  9. package/src/agent/index.ts +0 -54
  10. package/src/agent/orchestrator/AppBuilder.ts +0 -443
  11. package/src/agent/orchestrator/CouncilAgent.ts +0 -310
  12. package/src/agent/orchestrator/DiscussionRoom.ts +0 -462
  13. package/src/agent/orchestrator/OrchestratorAgent.ts +0 -637
  14. package/src/agent/orchestrator/index.ts +0 -38
  15. package/src/agent/orchestrator/utils.ts +0 -397
  16. package/src/agent/pricing.ts +0 -115
  17. package/src/agent/router.ts +0 -74
  18. package/src/agent/routerStats.ts +0 -121
  19. package/src/agent/types.ts +0 -318
  20. package/src/auth/login.ts +0 -383
  21. package/src/cli/config.ts +0 -189
  22. package/src/cli/index.ts +0 -17
  23. package/src/cli/middleware.ts +0 -119
  24. package/src/cli/types.ts +0 -75
  25. package/src/config/ConfigManager.ts +0 -587
  26. package/src/config/index.ts +0 -7
  27. package/src/config/types.ts +0 -584
  28. package/src/context/CompactionService.ts +0 -300
  29. package/src/context/ContextManager.ts +0 -450
  30. package/src/context/FileAnalyzer.ts +0 -267
  31. package/src/context/TokenCounter.ts +0 -265
  32. package/src/context/index.ts +0 -27
  33. package/src/context/storage/CacheStore.ts +0 -176
  34. package/src/context/storage/JSONLStore.ts +0 -201
  35. package/src/context/storage/MemoryStore.ts +0 -205
  36. package/src/context/storage/PersistentStore.ts +0 -327
  37. package/src/context/storage/index.ts +0 -9
  38. package/src/context/storage/pathUtils.ts +0 -114
  39. package/src/context/test.ts +0 -309
  40. package/src/context/types.ts +0 -268
  41. package/src/hooks/HookExecutor.ts +0 -434
  42. package/src/hooks/HookManager.ts +0 -596
  43. package/src/hooks/HookService.ts +0 -269
  44. package/src/hooks/Matcher.ts +0 -157
  45. package/src/hooks/index.ts +0 -63
  46. package/src/hooks/types.ts +0 -424
  47. package/src/main.tsx +0 -596
  48. package/src/mcp/HealthMonitor.ts +0 -150
  49. package/src/mcp/McpClient.ts +0 -491
  50. package/src/mcp/McpRegistry.ts +0 -321
  51. package/src/mcp/createMcpTool.ts +0 -251
  52. package/src/mcp/index.ts +0 -15
  53. package/src/mcp/server.ts +0 -334
  54. package/src/mcp/test-server.ts +0 -88
  55. package/src/mcp/test.ts +0 -372
  56. package/src/mcp/types.ts +0 -247
  57. package/src/memory/AgentMemoryBus.ts +0 -432
  58. package/src/memory/CloudSync.ts +0 -99
  59. package/src/memory/DriveSync.ts +0 -106
  60. package/src/memory/SharedMemory.ts +0 -951
  61. package/src/memory/index.ts +0 -14
  62. package/src/memory/machineFingerprint.ts +0 -40
  63. package/src/orchestrator/SubAgentMetadata.ts +0 -136
  64. package/src/prompts/builder.ts +0 -213
  65. package/src/prompts/default.ts +0 -144
  66. package/src/prompts/index.ts +0 -16
  67. package/src/prompts/plan.ts +0 -64
  68. package/src/prompts/test.ts +0 -78
  69. package/src/services/AnthropicChatService.ts +0 -341
  70. package/src/services/ChatService.ts +0 -347
  71. package/src/services/ClaudeCliChatService.ts +0 -256
  72. package/src/services/CloudSync.ts +0 -168
  73. package/src/services/CostLedger.ts +0 -211
  74. package/src/services/Heartbeat.ts +0 -135
  75. package/src/services/LearningCollector.ts +0 -291
  76. package/src/services/OllamaInstaller.ts +0 -342
  77. package/src/services/VersionChecker.ts +0 -445
  78. package/src/services/index.ts +0 -57
  79. package/src/services/streaming/OpenAIEventAdapter.ts +0 -126
  80. package/src/services/streaming/RenderingProfile.ts +0 -90
  81. package/src/services/streaming/StreamEventParser.ts +0 -181
  82. package/src/services/streaming/ThrottledRenderer.ts +0 -139
  83. package/src/services/streaming/TranscriptBuffer.ts +0 -574
  84. package/src/services/streaming/eventStatusMap.ts +0 -52
  85. package/src/services/streaming/index.ts +0 -46
  86. package/src/services/streaming/renderFormatting.ts +0 -79
  87. package/src/services/streaming/types.ts +0 -234
  88. package/src/skills/SkillLoader.ts +0 -126
  89. package/src/skills/SkillRegistry.ts +0 -366
  90. package/src/skills/index.ts +0 -48
  91. package/src/skills/types.ts +0 -146
  92. package/src/slash-commands/billing.ts +0 -70
  93. package/src/slash-commands/build.ts +0 -413
  94. package/src/slash-commands/builtinCommands.ts +0 -2733
  95. package/src/slash-commands/clone.ts +0 -242
  96. package/src/slash-commands/council.ts +0 -125
  97. package/src/slash-commands/custom/CustomCommandExecutor.ts +0 -143
  98. package/src/slash-commands/custom/CustomCommandLoader.ts +0 -251
  99. package/src/slash-commands/custom/CustomCommandRegistry.ts +0 -144
  100. package/src/slash-commands/custom/index.ts +0 -7
  101. package/src/slash-commands/debate.ts +0 -254
  102. package/src/slash-commands/gmail.ts +0 -105
  103. package/src/slash-commands/index.ts +0 -388
  104. package/src/slash-commands/mcpCommand.ts +0 -205
  105. package/src/slash-commands/types.ts +0 -201
  106. package/src/store/index.ts +0 -76
  107. package/src/store/selectors.ts +0 -246
  108. package/src/store/slices/appSlice.ts +0 -205
  109. package/src/store/slices/commandSlice.ts +0 -115
  110. package/src/store/slices/configSlice.ts +0 -46
  111. package/src/store/slices/focusSlice.ts +0 -64
  112. package/src/store/slices/index.ts +0 -9
  113. package/src/store/slices/sessionSlice.ts +0 -424
  114. package/src/store/streaming-buffer.ts +0 -425
  115. package/src/store/test.ts +0 -296
  116. package/src/store/types.ts +0 -274
  117. package/src/store/vanilla.ts +0 -186
  118. package/src/tools/builtin/bash.ts +0 -236
  119. package/src/tools/builtin/council.ts +0 -105
  120. package/src/tools/builtin/edit.ts +0 -213
  121. package/src/tools/builtin/glob.ts +0 -136
  122. package/src/tools/builtin/grep.ts +0 -263
  123. package/src/tools/builtin/index.ts +0 -61
  124. package/src/tools/builtin/memory.ts +0 -66
  125. package/src/tools/builtin/read.ts +0 -168
  126. package/src/tools/builtin/skill.ts +0 -97
  127. package/src/tools/builtin/snapshot.ts +0 -40
  128. package/src/tools/builtin/task.ts +0 -106
  129. package/src/tools/builtin/write.ts +0 -134
  130. package/src/tools/createTool.ts +0 -221
  131. package/src/tools/execution/ExecutionPipeline.ts +0 -263
  132. package/src/tools/execution/index.ts +0 -40
  133. package/src/tools/execution/stages/CacheStage.ts +0 -131
  134. package/src/tools/execution/stages/ConfirmationStage.ts +0 -203
  135. package/src/tools/execution/stages/DiscoveryStage.ts +0 -46
  136. package/src/tools/execution/stages/ExecutionStage.ts +0 -48
  137. package/src/tools/execution/stages/FormattingStage.ts +0 -44
  138. package/src/tools/execution/stages/HookStage.ts +0 -72
  139. package/src/tools/execution/stages/PermissionStage.ts +0 -287
  140. package/src/tools/execution/stages/PostHookStage.ts +0 -71
  141. package/src/tools/execution/stages/index.ts +0 -12
  142. package/src/tools/execution/test.ts +0 -266
  143. package/src/tools/execution/types.ts +0 -273
  144. package/src/tools/index.ts +0 -81
  145. package/src/tools/registry.ts +0 -304
  146. package/src/tools/schemas.ts +0 -109
  147. package/src/tools/test.ts +0 -220
  148. package/src/tools/types.ts +0 -175
  149. package/src/tools/validation/PermissionChecker.ts +0 -242
  150. package/src/tools/validation/SensitiveFileDetector.ts +0 -210
  151. package/src/tools/validation/index.ts +0 -11
  152. package/src/ui/App.tsx +0 -166
  153. package/src/ui/components/AegisInterface.tsx +0 -484
  154. package/src/ui/components/common/ChatSearch.tsx +0 -150
  155. package/src/ui/components/common/ErrorBoundary.tsx +0 -82
  156. package/src/ui/components/common/ExitMessage.tsx +0 -120
  157. package/src/ui/components/common/LoadingIndicator.tsx +0 -49
  158. package/src/ui/components/common/index.ts +0 -6
  159. package/src/ui/components/dialog/ConfirmationPrompt.tsx +0 -208
  160. package/src/ui/components/dialog/InteractiveSelector.tsx +0 -149
  161. package/src/ui/components/dialog/SetupWizard.tsx +0 -297
  162. package/src/ui/components/dialog/UpdatePrompt.tsx +0 -155
  163. package/src/ui/components/dialog/index.ts +0 -8
  164. package/src/ui/components/index.ts +0 -28
  165. package/src/ui/components/input/CommandSuggestions.tsx +0 -139
  166. package/src/ui/components/input/CustomTextInput.tsx +0 -220
  167. package/src/ui/components/input/InputArea.tsx +0 -361
  168. package/src/ui/components/input/PromptSuggestions.tsx +0 -66
  169. package/src/ui/components/input/index.ts +0 -6
  170. package/src/ui/components/layout/ChatStatusBar.tsx +0 -90
  171. package/src/ui/components/layout/ContextBar.tsx +0 -79
  172. package/src/ui/components/layout/MessageArea.tsx +0 -96
  173. package/src/ui/components/layout/MessageList.tsx +0 -647
  174. package/src/ui/components/layout/MessageSeparator.tsx +0 -26
  175. package/src/ui/components/layout/WelcomeMessage.tsx +0 -93
  176. package/src/ui/components/layout/index.ts +0 -7
  177. package/src/ui/components/markdown/CodeHighlighter.tsx +0 -292
  178. package/src/ui/components/markdown/MessageRenderer.tsx +0 -1211
  179. package/src/ui/components/markdown/index.ts +0 -8
  180. package/src/ui/components/markdown/parser.ts +0 -336
  181. package/src/ui/components/markdown/types.ts +0 -66
  182. package/src/ui/focus/FocusManager.ts +0 -137
  183. package/src/ui/focus/index.ts +0 -13
  184. package/src/ui/focus/types.ts +0 -54
  185. package/src/ui/focus/useFocus.ts +0 -75
  186. package/src/ui/hooks/index.ts +0 -11
  187. package/src/ui/hooks/useAgent.ts +0 -284
  188. package/src/ui/hooks/useCommandHistory.ts +0 -87
  189. package/src/ui/hooks/useCommandProcessor.ts +0 -443
  190. package/src/ui/hooks/useConfirmation.ts +0 -99
  191. package/src/ui/hooks/useCtrlCHandler.ts +0 -100
  192. package/src/ui/hooks/useInputBuffer.ts +0 -122
  193. package/src/ui/hooks/useTerminalSize.ts +0 -68
  194. package/src/ui/hooks/useTerminalWidth.ts +0 -5
  195. package/src/ui/hooks/useWindowedList.ts +0 -118
  196. package/src/ui/render-debugger.ts +0 -621
  197. package/src/ui/test.ts +0 -189
  198. package/src/ui/themes/ThemeManager.ts +0 -332
  199. package/src/ui/themes/aegisTheme.ts +0 -87
  200. package/src/ui/themes/darkTheme.ts +0 -87
  201. package/src/ui/themes/defaultTheme.ts +0 -85
  202. package/src/ui/themes/index.ts +0 -10
  203. package/src/ui/themes/lightTheme.ts +0 -89
  204. package/src/ui/themes/popularThemes.ts +0 -187
  205. package/src/ui/themes/types.ts +0 -130
  206. package/src/utils/clipboard.ts +0 -48
  207. package/src/utils/debug.ts +0 -43
  208. package/src/utils/environment.ts +0 -68
  209. package/src/utils/index.ts +0 -10
@@ -1,951 +0,0 @@
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();