src / tools / memoryTools.ts
src / tools / memoryTools.ts
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { homedir } from 'os';
const MEMORY_FILE = '.timber_memory.md';
const COLD_MEMORY_FILE = '.timber_memory_cold.md';
const DEFAULT_GLOBAL_MEMORY_DIR = path.join(homedir(), '.timbers-toolbox');
interface MemoryEntry {
key: string;
value: string;
timestamp: string;
id: string;
}
const NEO4J_TIMEOUT_MS = 10000;
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
promise.then(
(val) => { clearTimeout(timer); resolve(val); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
class HybridMemoryService {
private config: any;
private memoryDir: string;
constructor(config: any) {
this.config = config;
if (config?.scope === "workspace") {
const workspacePath = (config?.workspacePath as string | undefined)?.trim();
if (!workspacePath) {
throw new Error("Workspace memory requires an active workspace path.");
}
this.memoryDir = workspacePath;
} else {
this.memoryDir = DEFAULT_GLOBAL_MEMORY_DIR;
}
}
private getHotThreshold(): number {
return (this.config?.hotThreshold as number) ?? 30;
}
private isNeo4jEnabled(): boolean {
const uri = (this.config?.neo4j?.uri as string | undefined)?.trim();
const password = this.config?.neo4j?.password;
return !!(uri && password);
}
/** Base URL without trailing /v1 — callers append /v1/embeddings once. */
private getEmbeddingBaseUrl(): string {
const configuredEndpoint =
(this.config?.lmStudioBaseUrl as string | undefined)?.trim() ||
process.env.LMS_PLUGIN_BASE_URL?.trim() ||
process.env.LM_STUDIO_ENDPOINT?.trim() ||
"http://localhost:1234";
let base = configuredEndpoint.replace(/\/+$/, "");
if (base.endsWith("/v1")) {
base = base.slice(0, -3).replace(/\/+$/, "");
}
return base;
}
private parseConnectionUri(connection_string: string): { host: string; port: number; username: string; password: string } {
let uri = connection_string.replace(/^(bolt|neo4j|neo4j+s|neo4j+ssc):\/\//i, '');
let username = 'neo4j';
let password = '';
const atIndex = uri.lastIndexOf('@');
if (atIndex !== -1) {
const credentials = uri.substring(0, atIndex);
const hostPort = uri.substring(atIndex + 1);
const colonIndex = credentials.lastIndexOf(':');
if (colonIndex !== -1) {
username = credentials.substring(0, colonIndex);
password = credentials.substring(colonIndex + 1);
}
uri = hostPort;
}
let host = uri;
let port = 7687;
const portMatch = uri.match(/:(\d+)$/);
if (portMatch) {
port = parseInt(portMatch[1], 10);
host = uri.substring(0, uri.lastIndexOf(':'));
}
return { host, port, username, password };
}
private async getCachedDriver(): Promise<any> {
if (!global.__neo4j_clients__) {
(global as any).__neo4j_clients__ = new Map();
}
const cacheKey = this.config.neo4j.uri;
let driver: any = global.__neo4j_clients__.get(cacheKey);
if (driver && driver.closed) {
global.__neo4j_clients__.delete(cacheKey);
driver = null;
}
if (!driver) {
try {
const neo4jTools = await import('./neo4jTools');
driver = neo4jTools.getNeo4jDriver(
this.config.neo4j.uri,
this.config.neo4j.username,
this.config.neo4j.password,
);
} catch {
const neo4jModule = await import('neo4j-driver');
const neo4j = (neo4jModule as any).default || neo4jModule;
const parsed = this.parseConnectionUri(this.config.neo4j.uri);
const schemeMatch = String(this.config.neo4j.uri).match(/^(bolt|neo4j|neo4j\+s|neo4j\+ssc):\/\//i);
const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : 'bolt';
driver = neo4j.driver(
`${scheme}://${parsed.host}:${parsed.port}`,
neo4j.auth.basic(this.config.neo4j.username, this.config.neo4j.password),
{ maxConnectionLifetime: 3 * 60 * 1000, maxConnectionPoolSize: 1 },
);
global.__neo4j_clients__.set(cacheKey, driver);
}
}
return driver;
}
private async syncToNeo4j(key: string, value: string): Promise<void> {
if (!this.isNeo4jEnabled()) return;
let driver: any;
let session: any;
try {
driver = await this.getCachedDriver();
session = driver.session();
await withTimeout(
session.run(
`MERGE (m:Memory {key: $key})
SET m.value = $value,
m.timestamp = datetime($timestamp),
m.source = 'text_file'`,
{ key, value, timestamp: new Date().toISOString() },
),
NEO4J_TIMEOUT_MS,
'Neo4j memory sync',
);
} catch (error) {
console.warn('Neo4j memory sync failed:', error);
} finally {
try { await session?.close(); } catch { /* ignore */ }
if (driver && driver.closed) {
try { global.__neo4j_clients__?.delete(this.config.neo4j.uri); } catch { /* ignore */ }
}
}
}
private async compressColdMemories(): Promise<void> {
try {
let content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8');
const entries = this.parseEntries(content);
if (entries.length <= this.getHotThreshold()) return;
const hotEntries = entries.slice(-this.getHotThreshold());
const coldEntries = entries.slice(0, -this.getHotThreshold());
let coldContent = '';
for (const entry of coldEntries) {
coldContent += `\n## ${entry.key} [${entry.timestamp}] (id: ${entry.id})\n${entry.value}\n`;
}
await fs.writeFile(path.join(this.memoryDir, COLD_MEMORY_FILE), coldContent);
let newContent = '';
for (const entry of hotEntries) {
newContent += `\n## ${entry.key} [${entry.timestamp}] (id: ${entry.id})\n${entry.value}\n`;
}
await fs.writeFile(path.join(this.memoryDir, MEMORY_FILE), newContent);
} catch (error) {
console.warn('Memory compression failed:', error);
}
}
private parseEntries(content: string): MemoryEntry[] {
const entries: MemoryEntry[] = [];
const regex = /^##\s*(.+?)\s*\[(.*?)\]\s*\(id:\s*([a-f0-9]+)\)\n([\s\S]*?)(?=^##|\Z)/gm;
let match;
while ((match = regex.exec(content)) !== null) {
entries.push({ key: match[1], timestamp: match[2], id: match[3], value: match[4].trim() });
}
return entries;
}
private async loadColdMemories(): Promise<MemoryEntry[]> {
try {
const content = await fs.readFile(path.join(this.memoryDir, COLD_MEMORY_FILE), 'utf-8');
return this.parseEntries(content);
} catch {
return [];
}
}
async search(query: string, embeddingModelName?: string): Promise<Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }>> {
const results = await this.semanticSearch(query, embeddingModelName);
if (results.length > 0) return results;
return this.stringSearch(query);
}
private async semanticSearch(query: string, embeddingModelName?: string): Promise<Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score: number }>> {
const results: Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score: number }> = [];
if (!embeddingModelName) return results;
try {
const endpoint = this.getEmbeddingBaseUrl();
const queryResponse = await fetch(`${endpoint}/v1/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: embeddingModelName, input: query })
});
if (!queryResponse.ok) return results;
const queryData = await queryResponse.json();
const queryEmbedding = queryData?.data?.[0]?.embedding as number[] | undefined;
if (!queryEmbedding) return results;
const snippets: Array<{ text: string; key: string; source: 'hot' | 'cold' }> = [];
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('##')) {
const start = Math.max(0, i - 2);
const end = Math.min(lines.length, i + 3);
snippets.push({ text: lines.slice(start, end).join('\n'), key: lines[i].replace(/^##\s*/, '').split(' [')[0], source: 'hot' });
}
}
for (const entry of await this.loadColdMemories()) {
snippets.push({ text: entry.value, key: entry.key, source: 'cold' });
}
if (snippets.length > 0) {
const batchResponse = await fetch(`${endpoint}/v1/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: embeddingModelName, input: snippets.map(s => s.text) })
});
if (batchResponse.ok) {
const batchData = await batchResponse.json();
const embeddings = batchData?.data as Array<{ embedding: number[] }> | undefined;
if (embeddings) {
for (let i = 0; i < snippets.length; i++) {
const chunkEmbedding = embeddings[i]?.embedding as number[] | undefined;
if (!chunkEmbedding) continue;
const score = this.cosineSimilarity(queryEmbedding, chunkEmbedding);
if (score > 0.4) {
results.push({ key: snippets[i].key, snippet: snippets[i].text, source: snippets[i].source, score });
}
}
}
}
}
results.sort((a, b) => b.score - a.score);
} catch (error) {
console.warn('Semantic memory search failed:', error);
}
return results;
}
private cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((acc, val, i) => acc + val * vecB[i], 0);
const magA = Math.sqrt(vecA.reduce((acc, val) => acc + val * val, 0));
const magB = Math.sqrt(vecB.reduce((acc, val) => acc + val * val, 0));
if (magA === 0 || magB === 0) return 0;
return dotProduct / (magA * magB);
}
private async stringSearch(query: string): Promise<Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }>> {
const results: Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }> = [];
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const lines = content.split('\n');
const lowerQuery = query.toLowerCase();
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(lowerQuery)) {
const start = Math.max(0, i - 2);
const end = Math.min(lines.length, i + 3);
results.push({ key: lines[i].replace(/^##\s*/, '').split(' [')[0], snippet: lines.slice(start, end).join('\n'), source: 'hot' as const });
}
}
for (const entry of await this.loadColdMemories()) {
if (entry.key.toLowerCase().includes(lowerQuery) || entry.value.toLowerCase().includes(lowerQuery)) {
results.push({ key: entry.key, snippet: `## ${entry.key} [${entry.timestamp}]\n${entry.value}`, source: 'cold' as const });
}
}
if (this.isNeo4jEnabled()) {
let driver: any;
let session: any;
try {
driver = await this.getCachedDriver();
session = driver.session();
const result: any = await withTimeout(
session.run(
`MATCH (m:Memory)
WHERE toLower(m.key) CONTAINS $query OR toLower(m.value) CONTAINS $query
RETURN m.key as key, m.value as value, m.timestamp as timestamp`,
{ query: lowerQuery },
),
NEO4J_TIMEOUT_MS,
'Neo4j memory search',
);
for (const record of result.records) {
results.push({
key: record.get('key'),
snippet: `## ${record.get('key')} [${record.get('timestamp')?.toString()}]\n${record.get('value')}`,
source: 'neo4j' as const
});
}
} catch (error) {
console.warn('Neo4j memory search failed:', error);
} finally {
try { await session?.close(); } catch { /* ignore */ }
if (driver && driver.closed) {
try { global.__neo4j_clients__?.delete(this.config.neo4j.uri); } catch { /* ignore */ }
}
}
}
return results;
}
async save(key: string, value: string): Promise<{ success: boolean; key: string }> {
await fs.mkdir(this.memoryDir, { recursive: true });
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const timestamp = new Date().toISOString();
const hash = crypto.createHash('sha256').update(key).digest('hex').substr(0, 12);
content += `\n## ${key} [${timestamp}] (id: ${hash})\n${value}\n`;
await fs.writeFile(path.join(this.memoryDir, MEMORY_FILE), content);
this.syncToNeo4j(key, value).catch(() => {});
this.compressColdMemories().catch(() => {});
return { success: true, key };
}
async list(): Promise<Array<{ key: string; timestamp: string; source: 'hot' | 'cold' | 'neo4j' }>> {
const entries: Array<{ key: string; timestamp: string; source: 'hot' | 'cold' | 'neo4j' }> = [];
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const hotRegex = /^##\s*(.+?)\s*\[(.*?)\]/gm;
let match;
while ((match = hotRegex.exec(content)) !== null) {
entries.push({ key: match[1], timestamp: match[2], source: 'hot' as const });
}
for (const entry of await this.loadColdMemories()) {
entries.push({ key: entry.key, timestamp: entry.timestamp, source: 'cold' as const });
}
return entries;
}
private removeEntryByKey(content: string, key: string): string {
const lines = content.split('\n');
const result: string[] = [];
let skipping = false;
for (const line of lines) {
if (line.startsWith(`## ${key} `)) { skipping = true; continue; }
if (skipping) {
if (line.startsWith('## ')) { skipping = false; result.push(line); }
continue;
}
result.push(line);
}
return result.join('\n');
}
async delete(key: string): Promise<{ success: boolean }> {
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); }
catch { return { success: false }; }
await fs.writeFile(path.join(this.memoryDir, MEMORY_FILE), this.removeEntryByKey(content, key));
try {
const coldContent = await fs.readFile(path.join(this.memoryDir, COLD_MEMORY_FILE), 'utf-8');
await fs.writeFile(path.join(this.memoryDir, COLD_MEMORY_FILE), this.removeEntryByKey(coldContent, key));
} catch {}
return { success: true };
}
async clear(): Promise<{ success: boolean }> {
await fs.unlink(path.join(this.memoryDir, MEMORY_FILE)).catch(() => {});
await fs.unlink(path.join(this.memoryDir, COLD_MEMORY_FILE)).catch(() => {});
return { success: true };
}
}
export function createMemoryService(config: any): HybridMemoryService {
return new HybridMemoryService(config);
}
export async function memory_save(key: string, value: string, config?: any): Promise<{ success: boolean; key: string }> {
return createMemoryService(config || {}).save(key, value);
}
export async function memory_search(query: string, config?: any): Promise<{ results: Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }> }> {
const embeddingModelName = config?.embeddingModel ?? 'nomic-ai/nomic-embed-text-v1.5-GGUF';
const results = await createMemoryService(config || {}).search(query, embeddingModelName);
return { results };
}
export async function memory_list(config?: any): Promise<{ entries: Array<{ key: string; timestamp: string; source: 'hot' | 'cold' | 'neo4j' }> }> {
return { entries: await createMemoryService(config || {}).list() };
}
export async function memory_delete(key: string, config?: any): Promise<{ success: boolean }> {
return createMemoryService(config || {}).delete(key);
}
export async function memory_clear(config?: any): Promise<{ success: boolean }> {
return createMemoryService(config || {}).clear();
}
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { homedir } from 'os';
const MEMORY_FILE = '.timber_memory.md';
const COLD_MEMORY_FILE = '.timber_memory_cold.md';
const DEFAULT_GLOBAL_MEMORY_DIR = path.join(homedir(), '.timbers-toolbox');
interface MemoryEntry {
key: string;
value: string;
timestamp: string;
id: string;
}
const NEO4J_TIMEOUT_MS = 10000;
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
promise.then(
(val) => { clearTimeout(timer); resolve(val); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
class HybridMemoryService {
private config: any;
private memoryDir: string;
constructor(config: any) {
this.config = config;
if (config?.scope === "workspace") {
const workspacePath = (config?.workspacePath as string | undefined)?.trim();
if (!workspacePath) {
throw new Error("Workspace memory requires an active workspace path.");
}
this.memoryDir = workspacePath;
} else {
this.memoryDir = DEFAULT_GLOBAL_MEMORY_DIR;
}
}
private getHotThreshold(): number {
return (this.config?.hotThreshold as number) ?? 30;
}
private isNeo4jEnabled(): boolean {
const uri = (this.config?.neo4j?.uri as string | undefined)?.trim();
const password = this.config?.neo4j?.password;
return !!(uri && password);
}
/** Base URL without trailing /v1 — callers append /v1/embeddings once. */
private getEmbeddingBaseUrl(): string {
const configuredEndpoint =
(this.config?.lmStudioBaseUrl as string | undefined)?.trim() ||
process.env.LMS_PLUGIN_BASE_URL?.trim() ||
process.env.LM_STUDIO_ENDPOINT?.trim() ||
"http://localhost:1234";
let base = configuredEndpoint.replace(/\/+$/, "");
if (base.endsWith("/v1")) {
base = base.slice(0, -3).replace(/\/+$/, "");
}
return base;
}
private parseConnectionUri(connection_string: string): { host: string; port: number; username: string; password: string } {
let uri = connection_string.replace(/^(bolt|neo4j|neo4j+s|neo4j+ssc):\/\//i, '');
let username = 'neo4j';
let password = '';
const atIndex = uri.lastIndexOf('@');
if (atIndex !== -1) {
const credentials = uri.substring(0, atIndex);
const hostPort = uri.substring(atIndex + 1);
const colonIndex = credentials.lastIndexOf(':');
if (colonIndex !== -1) {
username = credentials.substring(0, colonIndex);
password = credentials.substring(colonIndex + 1);
}
uri = hostPort;
}
let host = uri;
let port = 7687;
const portMatch = uri.match(/:(\d+)$/);
if (portMatch) {
port = parseInt(portMatch[1], 10);
host = uri.substring(0, uri.lastIndexOf(':'));
}
return { host, port, username, password };
}
private async getCachedDriver(): Promise<any> {
if (!global.__neo4j_clients__) {
(global as any).__neo4j_clients__ = new Map();
}
const cacheKey = this.config.neo4j.uri;
let driver: any = global.__neo4j_clients__.get(cacheKey);
if (driver && driver.closed) {
global.__neo4j_clients__.delete(cacheKey);
driver = null;
}
if (!driver) {
try {
const neo4jTools = await import('./neo4jTools');
driver = neo4jTools.getNeo4jDriver(
this.config.neo4j.uri,
this.config.neo4j.username,
this.config.neo4j.password,
);
} catch {
const neo4jModule = await import('neo4j-driver');
const neo4j = (neo4jModule as any).default || neo4jModule;
const parsed = this.parseConnectionUri(this.config.neo4j.uri);
const schemeMatch = String(this.config.neo4j.uri).match(/^(bolt|neo4j|neo4j\+s|neo4j\+ssc):\/\//i);
const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : 'bolt';
driver = neo4j.driver(
`${scheme}://${parsed.host}:${parsed.port}`,
neo4j.auth.basic(this.config.neo4j.username, this.config.neo4j.password),
{ maxConnectionLifetime: 3 * 60 * 1000, maxConnectionPoolSize: 1 },
);
global.__neo4j_clients__.set(cacheKey, driver);
}
}
return driver;
}
private async syncToNeo4j(key: string, value: string): Promise<void> {
if (!this.isNeo4jEnabled()) return;
let driver: any;
let session: any;
try {
driver = await this.getCachedDriver();
session = driver.session();
await withTimeout(
session.run(
`MERGE (m:Memory {key: $key})
SET m.value = $value,
m.timestamp = datetime($timestamp),
m.source = 'text_file'`,
{ key, value, timestamp: new Date().toISOString() },
),
NEO4J_TIMEOUT_MS,
'Neo4j memory sync',
);
} catch (error) {
console.warn('Neo4j memory sync failed:', error);
} finally {
try { await session?.close(); } catch { /* ignore */ }
if (driver && driver.closed) {
try { global.__neo4j_clients__?.delete(this.config.neo4j.uri); } catch { /* ignore */ }
}
}
}
private async compressColdMemories(): Promise<void> {
try {
let content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8');
const entries = this.parseEntries(content);
if (entries.length <= this.getHotThreshold()) return;
const hotEntries = entries.slice(-this.getHotThreshold());
const coldEntries = entries.slice(0, -this.getHotThreshold());
let coldContent = '';
for (const entry of coldEntries) {
coldContent += `\n## ${entry.key} [${entry.timestamp}] (id: ${entry.id})\n${entry.value}\n`;
}
await fs.writeFile(path.join(this.memoryDir, COLD_MEMORY_FILE), coldContent);
let newContent = '';
for (const entry of hotEntries) {
newContent += `\n## ${entry.key} [${entry.timestamp}] (id: ${entry.id})\n${entry.value}\n`;
}
await fs.writeFile(path.join(this.memoryDir, MEMORY_FILE), newContent);
} catch (error) {
console.warn('Memory compression failed:', error);
}
}
private parseEntries(content: string): MemoryEntry[] {
const entries: MemoryEntry[] = [];
const regex = /^##\s*(.+?)\s*\[(.*?)\]\s*\(id:\s*([a-f0-9]+)\)\n([\s\S]*?)(?=^##|\Z)/gm;
let match;
while ((match = regex.exec(content)) !== null) {
entries.push({ key: match[1], timestamp: match[2], id: match[3], value: match[4].trim() });
}
return entries;
}
private async loadColdMemories(): Promise<MemoryEntry[]> {
try {
const content = await fs.readFile(path.join(this.memoryDir, COLD_MEMORY_FILE), 'utf-8');
return this.parseEntries(content);
} catch {
return [];
}
}
async search(query: string, embeddingModelName?: string): Promise<Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }>> {
const results = await this.semanticSearch(query, embeddingModelName);
if (results.length > 0) return results;
return this.stringSearch(query);
}
private async semanticSearch(query: string, embeddingModelName?: string): Promise<Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score: number }>> {
const results: Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score: number }> = [];
if (!embeddingModelName) return results;
try {
const endpoint = this.getEmbeddingBaseUrl();
const queryResponse = await fetch(`${endpoint}/v1/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: embeddingModelName, input: query })
});
if (!queryResponse.ok) return results;
const queryData = await queryResponse.json();
const queryEmbedding = queryData?.data?.[0]?.embedding as number[] | undefined;
if (!queryEmbedding) return results;
const snippets: Array<{ text: string; key: string; source: 'hot' | 'cold' }> = [];
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('##')) {
const start = Math.max(0, i - 2);
const end = Math.min(lines.length, i + 3);
snippets.push({ text: lines.slice(start, end).join('\n'), key: lines[i].replace(/^##\s*/, '').split(' [')[0], source: 'hot' });
}
}
for (const entry of await this.loadColdMemories()) {
snippets.push({ text: entry.value, key: entry.key, source: 'cold' });
}
if (snippets.length > 0) {
const batchResponse = await fetch(`${endpoint}/v1/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: embeddingModelName, input: snippets.map(s => s.text) })
});
if (batchResponse.ok) {
const batchData = await batchResponse.json();
const embeddings = batchData?.data as Array<{ embedding: number[] }> | undefined;
if (embeddings) {
for (let i = 0; i < snippets.length; i++) {
const chunkEmbedding = embeddings[i]?.embedding as number[] | undefined;
if (!chunkEmbedding) continue;
const score = this.cosineSimilarity(queryEmbedding, chunkEmbedding);
if (score > 0.4) {
results.push({ key: snippets[i].key, snippet: snippets[i].text, source: snippets[i].source, score });
}
}
}
}
}
results.sort((a, b) => b.score - a.score);
} catch (error) {
console.warn('Semantic memory search failed:', error);
}
return results;
}
private cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((acc, val, i) => acc + val * vecB[i], 0);
const magA = Math.sqrt(vecA.reduce((acc, val) => acc + val * val, 0));
const magB = Math.sqrt(vecB.reduce((acc, val) => acc + val * val, 0));
if (magA === 0 || magB === 0) return 0;
return dotProduct / (magA * magB);
}
private async stringSearch(query: string): Promise<Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }>> {
const results: Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }> = [];
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const lines = content.split('\n');
const lowerQuery = query.toLowerCase();
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(lowerQuery)) {
const start = Math.max(0, i - 2);
const end = Math.min(lines.length, i + 3);
results.push({ key: lines[i].replace(/^##\s*/, '').split(' [')[0], snippet: lines.slice(start, end).join('\n'), source: 'hot' as const });
}
}
for (const entry of await this.loadColdMemories()) {
if (entry.key.toLowerCase().includes(lowerQuery) || entry.value.toLowerCase().includes(lowerQuery)) {
results.push({ key: entry.key, snippet: `## ${entry.key} [${entry.timestamp}]\n${entry.value}`, source: 'cold' as const });
}
}
if (this.isNeo4jEnabled()) {
let driver: any;
let session: any;
try {
driver = await this.getCachedDriver();
session = driver.session();
const result: any = await withTimeout(
session.run(
`MATCH (m:Memory)
WHERE toLower(m.key) CONTAINS $query OR toLower(m.value) CONTAINS $query
RETURN m.key as key, m.value as value, m.timestamp as timestamp`,
{ query: lowerQuery },
),
NEO4J_TIMEOUT_MS,
'Neo4j memory search',
);
for (const record of result.records) {
results.push({
key: record.get('key'),
snippet: `## ${record.get('key')} [${record.get('timestamp')?.toString()}]\n${record.get('value')}`,
source: 'neo4j' as const
});
}
} catch (error) {
console.warn('Neo4j memory search failed:', error);
} finally {
try { await session?.close(); } catch { /* ignore */ }
if (driver && driver.closed) {
try { global.__neo4j_clients__?.delete(this.config.neo4j.uri); } catch { /* ignore */ }
}
}
}
return results;
}
async save(key: string, value: string): Promise<{ success: boolean; key: string }> {
await fs.mkdir(this.memoryDir, { recursive: true });
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const timestamp = new Date().toISOString();
const hash = crypto.createHash('sha256').update(key).digest('hex').substr(0, 12);
content += `\n## ${key} [${timestamp}] (id: ${hash})\n${value}\n`;
await fs.writeFile(path.join(this.memoryDir, MEMORY_FILE), content);
this.syncToNeo4j(key, value).catch(() => {});
this.compressColdMemories().catch(() => {});
return { success: true, key };
}
async list(): Promise<Array<{ key: string; timestamp: string; source: 'hot' | 'cold' | 'neo4j' }>> {
const entries: Array<{ key: string; timestamp: string; source: 'hot' | 'cold' | 'neo4j' }> = [];
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); } catch {}
const hotRegex = /^##\s*(.+?)\s*\[(.*?)\]/gm;
let match;
while ((match = hotRegex.exec(content)) !== null) {
entries.push({ key: match[1], timestamp: match[2], source: 'hot' as const });
}
for (const entry of await this.loadColdMemories()) {
entries.push({ key: entry.key, timestamp: entry.timestamp, source: 'cold' as const });
}
return entries;
}
private removeEntryByKey(content: string, key: string): string {
const lines = content.split('\n');
const result: string[] = [];
let skipping = false;
for (const line of lines) {
if (line.startsWith(`## ${key} `)) { skipping = true; continue; }
if (skipping) {
if (line.startsWith('## ')) { skipping = false; result.push(line); }
continue;
}
result.push(line);
}
return result.join('\n');
}
async delete(key: string): Promise<{ success: boolean }> {
let content = '';
try { content = await fs.readFile(path.join(this.memoryDir, MEMORY_FILE), 'utf-8'); }
catch { return { success: false }; }
await fs.writeFile(path.join(this.memoryDir, MEMORY_FILE), this.removeEntryByKey(content, key));
try {
const coldContent = await fs.readFile(path.join(this.memoryDir, COLD_MEMORY_FILE), 'utf-8');
await fs.writeFile(path.join(this.memoryDir, COLD_MEMORY_FILE), this.removeEntryByKey(coldContent, key));
} catch {}
return { success: true };
}
async clear(): Promise<{ success: boolean }> {
await fs.unlink(path.join(this.memoryDir, MEMORY_FILE)).catch(() => {});
await fs.unlink(path.join(this.memoryDir, COLD_MEMORY_FILE)).catch(() => {});
return { success: true };
}
}
export function createMemoryService(config: any): HybridMemoryService {
return new HybridMemoryService(config);
}
export async function memory_save(key: string, value: string, config?: any): Promise<{ success: boolean; key: string }> {
return createMemoryService(config || {}).save(key, value);
}
export async function memory_search(query: string, config?: any): Promise<{ results: Array<{ key: string; snippet: string; source: 'hot' | 'cold' | 'neo4j'; score?: number }> }> {
const embeddingModelName = config?.embeddingModel ?? 'nomic-ai/nomic-embed-text-v1.5-GGUF';
const results = await createMemoryService(config || {}).search(query, embeddingModelName);
return { results };
}
export async function memory_list(config?: any): Promise<{ entries: Array<{ key: string; timestamp: string; source: 'hot' | 'cold' | 'neo4j' }> }> {
return { entries: await createMemoryService(config || {}).list() };
}
export async function memory_delete(key: string, config?: any): Promise<{ success: boolean }> {
return createMemoryService(config || {}).delete(key);
}
export async function memory_clear(config?: any): Promise<{ success: boolean }> {
return createMemoryService(config || {}).clear();
}