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.
- package/README.md +23 -15
- package/dist/main.js +549 -552
- package/package.json +4 -2
- package/src/agent/Agent.ts +903 -0
- package/src/agent/SimpleAgent.ts +48 -0
- package/src/agent/index.ts +54 -0
- package/src/agent/orchestrator/AppBuilder.ts +443 -0
- package/src/agent/orchestrator/CouncilAgent.ts +310 -0
- package/src/agent/orchestrator/DiscussionRoom.ts +462 -0
- package/src/agent/orchestrator/OrchestratorAgent.ts +637 -0
- package/src/agent/orchestrator/index.ts +38 -0
- package/src/agent/orchestrator/utils.ts +397 -0
- package/src/agent/pricing.ts +115 -0
- package/src/agent/router.ts +74 -0
- package/src/agent/routerStats.ts +121 -0
- package/src/agent/types.ts +318 -0
- package/src/auth/login.ts +383 -0
- package/src/cli/config.ts +189 -0
- package/src/cli/index.ts +17 -0
- package/src/cli/middleware.ts +119 -0
- package/src/cli/types.ts +75 -0
- package/src/config/ConfigManager.ts +587 -0
- package/src/config/index.ts +7 -0
- package/src/config/types.ts +584 -0
- package/src/context/CompactionService.ts +300 -0
- package/src/context/ContextManager.ts +450 -0
- package/src/context/FileAnalyzer.ts +267 -0
- package/src/context/TokenCounter.ts +265 -0
- package/src/context/index.ts +27 -0
- package/src/context/storage/CacheStore.ts +176 -0
- package/src/context/storage/JSONLStore.ts +201 -0
- package/src/context/storage/MemoryStore.ts +205 -0
- package/src/context/storage/PersistentStore.ts +327 -0
- package/src/context/storage/index.ts +9 -0
- package/src/context/storage/pathUtils.ts +114 -0
- package/src/context/test.ts +309 -0
- package/src/context/types.ts +268 -0
- package/src/hooks/HookExecutor.ts +434 -0
- package/src/hooks/HookManager.ts +596 -0
- package/src/hooks/HookService.ts +269 -0
- package/src/hooks/Matcher.ts +157 -0
- package/src/hooks/index.ts +63 -0
- package/src/hooks/types.ts +424 -0
- package/src/main.tsx +596 -0
- package/src/mcp/HealthMonitor.ts +150 -0
- package/src/mcp/McpClient.ts +491 -0
- package/src/mcp/McpRegistry.ts +321 -0
- package/src/mcp/createMcpTool.ts +251 -0
- package/src/mcp/index.ts +15 -0
- package/src/mcp/server.ts +334 -0
- package/src/mcp/test-server.ts +88 -0
- package/src/mcp/test.ts +372 -0
- package/src/mcp/types.ts +247 -0
- package/src/memory/AgentMemoryBus.ts +432 -0
- package/src/memory/CloudSync.ts +99 -0
- package/src/memory/DriveSync.ts +106 -0
- package/src/memory/SharedMemory.ts +951 -0
- package/src/memory/index.ts +14 -0
- package/src/memory/machineFingerprint.ts +40 -0
- package/src/orchestrator/SubAgentMetadata.ts +136 -0
- package/src/prompts/builder.ts +213 -0
- package/src/prompts/default.ts +144 -0
- package/src/prompts/index.ts +16 -0
- package/src/prompts/plan.ts +64 -0
- package/src/prompts/test.ts +78 -0
- package/src/services/AnthropicChatService.ts +341 -0
- package/src/services/ChatService.ts +347 -0
- package/src/services/ClaudeCliChatService.ts +256 -0
- package/src/services/CloudSync.ts +168 -0
- package/src/services/CostLedger.ts +211 -0
- package/src/services/Heartbeat.ts +135 -0
- package/src/services/LearningCollector.ts +291 -0
- package/src/services/OllamaInstaller.ts +342 -0
- package/src/services/VersionChecker.ts +445 -0
- package/src/services/index.ts +57 -0
- package/src/services/streaming/OpenAIEventAdapter.ts +126 -0
- package/src/services/streaming/RenderingProfile.ts +90 -0
- package/src/services/streaming/StreamEventParser.ts +181 -0
- package/src/services/streaming/ThrottledRenderer.ts +139 -0
- package/src/services/streaming/TranscriptBuffer.ts +574 -0
- package/src/services/streaming/eventStatusMap.ts +52 -0
- package/src/services/streaming/index.ts +46 -0
- package/src/services/streaming/renderFormatting.ts +79 -0
- package/src/services/streaming/types.ts +234 -0
- package/src/skills/SkillLoader.ts +126 -0
- package/src/skills/SkillRegistry.ts +366 -0
- package/src/skills/index.ts +48 -0
- package/src/skills/types.ts +146 -0
- package/src/slash-commands/billing.ts +70 -0
- package/src/slash-commands/build.ts +413 -0
- package/src/slash-commands/builtinCommands.ts +2733 -0
- package/src/slash-commands/clone.ts +242 -0
- package/src/slash-commands/council.ts +125 -0
- package/src/slash-commands/custom/CustomCommandExecutor.ts +143 -0
- package/src/slash-commands/custom/CustomCommandLoader.ts +251 -0
- package/src/slash-commands/custom/CustomCommandRegistry.ts +144 -0
- package/src/slash-commands/custom/index.ts +7 -0
- package/src/slash-commands/debate.ts +254 -0
- package/src/slash-commands/gmail.ts +105 -0
- package/src/slash-commands/index.ts +388 -0
- package/src/slash-commands/mcpCommand.ts +205 -0
- package/src/slash-commands/types.ts +201 -0
- package/src/store/index.ts +76 -0
- package/src/store/selectors.ts +246 -0
- package/src/store/slices/appSlice.ts +205 -0
- package/src/store/slices/commandSlice.ts +115 -0
- package/src/store/slices/configSlice.ts +46 -0
- package/src/store/slices/focusSlice.ts +64 -0
- package/src/store/slices/index.ts +9 -0
- package/src/store/slices/sessionSlice.ts +424 -0
- package/src/store/streaming-buffer.ts +425 -0
- package/src/store/test.ts +296 -0
- package/src/store/types.ts +274 -0
- package/src/store/vanilla.ts +186 -0
- package/src/tools/builtin/bash.ts +236 -0
- package/src/tools/builtin/council.ts +105 -0
- package/src/tools/builtin/edit.ts +213 -0
- package/src/tools/builtin/glob.ts +136 -0
- package/src/tools/builtin/grep.ts +263 -0
- package/src/tools/builtin/index.ts +61 -0
- package/src/tools/builtin/memory.ts +66 -0
- package/src/tools/builtin/read.ts +168 -0
- package/src/tools/builtin/skill.ts +97 -0
- package/src/tools/builtin/snapshot.ts +40 -0
- package/src/tools/builtin/task.ts +106 -0
- package/src/tools/builtin/write.ts +134 -0
- package/src/tools/createTool.ts +221 -0
- package/src/tools/execution/ExecutionPipeline.ts +263 -0
- package/src/tools/execution/index.ts +40 -0
- package/src/tools/execution/stages/CacheStage.ts +131 -0
- package/src/tools/execution/stages/ConfirmationStage.ts +203 -0
- package/src/tools/execution/stages/DiscoveryStage.ts +46 -0
- package/src/tools/execution/stages/ExecutionStage.ts +48 -0
- package/src/tools/execution/stages/FormattingStage.ts +44 -0
- package/src/tools/execution/stages/HookStage.ts +72 -0
- package/src/tools/execution/stages/PermissionStage.ts +287 -0
- package/src/tools/execution/stages/PostHookStage.ts +71 -0
- package/src/tools/execution/stages/index.ts +12 -0
- package/src/tools/execution/test.ts +266 -0
- package/src/tools/execution/types.ts +273 -0
- package/src/tools/index.ts +81 -0
- package/src/tools/registry.ts +304 -0
- package/src/tools/schemas.ts +109 -0
- package/src/tools/test.ts +220 -0
- package/src/tools/types.ts +175 -0
- package/src/tools/validation/PermissionChecker.ts +242 -0
- package/src/tools/validation/SensitiveFileDetector.ts +210 -0
- package/src/tools/validation/index.ts +11 -0
- package/src/ui/App.tsx +166 -0
- package/src/ui/components/AegisInterface.tsx +484 -0
- package/src/ui/components/common/ChatSearch.tsx +150 -0
- package/src/ui/components/common/ErrorBoundary.tsx +82 -0
- package/src/ui/components/common/ExitMessage.tsx +120 -0
- package/src/ui/components/common/LoadingIndicator.tsx +49 -0
- package/src/ui/components/common/index.ts +6 -0
- package/src/ui/components/dialog/ConfirmationPrompt.tsx +208 -0
- package/src/ui/components/dialog/InteractiveSelector.tsx +149 -0
- package/src/ui/components/dialog/SetupWizard.tsx +297 -0
- package/src/ui/components/dialog/UpdatePrompt.tsx +155 -0
- package/src/ui/components/dialog/index.ts +8 -0
- package/src/ui/components/index.ts +28 -0
- package/src/ui/components/input/CommandSuggestions.tsx +139 -0
- package/src/ui/components/input/CustomTextInput.tsx +220 -0
- package/src/ui/components/input/InputArea.tsx +361 -0
- package/src/ui/components/input/PromptSuggestions.tsx +66 -0
- package/src/ui/components/input/index.ts +6 -0
- package/src/ui/components/layout/ChatStatusBar.tsx +90 -0
- package/src/ui/components/layout/ContextBar.tsx +79 -0
- package/src/ui/components/layout/MessageArea.tsx +96 -0
- package/src/ui/components/layout/MessageList.tsx +647 -0
- package/src/ui/components/layout/MessageSeparator.tsx +26 -0
- package/src/ui/components/layout/WelcomeMessage.tsx +93 -0
- package/src/ui/components/layout/index.ts +7 -0
- package/src/ui/components/markdown/CodeHighlighter.tsx +292 -0
- package/src/ui/components/markdown/MessageRenderer.tsx +1211 -0
- package/src/ui/components/markdown/index.ts +8 -0
- package/src/ui/components/markdown/parser.ts +336 -0
- package/src/ui/components/markdown/types.ts +66 -0
- package/src/ui/focus/FocusManager.ts +137 -0
- package/src/ui/focus/index.ts +13 -0
- package/src/ui/focus/types.ts +54 -0
- package/src/ui/focus/useFocus.ts +75 -0
- package/src/ui/hooks/index.ts +11 -0
- package/src/ui/hooks/useAgent.ts +284 -0
- package/src/ui/hooks/useCommandHistory.ts +87 -0
- package/src/ui/hooks/useCommandProcessor.ts +443 -0
- package/src/ui/hooks/useConfirmation.ts +99 -0
- package/src/ui/hooks/useCtrlCHandler.ts +100 -0
- package/src/ui/hooks/useInputBuffer.ts +122 -0
- package/src/ui/hooks/useTerminalSize.ts +68 -0
- package/src/ui/hooks/useTerminalWidth.ts +5 -0
- package/src/ui/hooks/useWindowedList.ts +118 -0
- package/src/ui/render-debugger.ts +621 -0
- package/src/ui/test.ts +189 -0
- package/src/ui/themes/ThemeManager.ts +332 -0
- package/src/ui/themes/aegisTheme.ts +87 -0
- package/src/ui/themes/darkTheme.ts +87 -0
- package/src/ui/themes/defaultTheme.ts +85 -0
- package/src/ui/themes/index.ts +10 -0
- package/src/ui/themes/lightTheme.ts +89 -0
- package/src/ui/themes/popularThemes.ts +187 -0
- package/src/ui/themes/types.ts +130 -0
- package/src/utils/clipboard.ts +48 -0
- package/src/utils/debug.ts +43 -0
- package/src/utils/environment.ts +68 -0
- 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();
|