feat(memorycore): add tags system, session summaries, hook injection, tag filtering, and solidify compress mode

Implement 5 interconnected memorycore enhancements:

1. Tags backend: add tags TEXT column to memories table with migration,
   JSON array storage, full CRUD support via upsertMemory/getMemory/getMemories
2. LLM auto-tag extraction: extend extraction prompt to produce tags,
   parse and validate in pipeline, create CMEM from extraction results
3. Session summary API: expose rollout_summary via new REST endpoints
   GET /api/core-memory/sessions/summaries and sessions/:id/summary
4. Hook injection: increase SESSION_START_LIMIT to 1500, add Component 5
   (Recent Sessions) to UnifiedContextBuilder with 300-char budget
5. Tag filtering: add getMemoriesByTags() with json_each() for safe
   SQL matching, wire through MCP tool, CLI --tags flag, REST ?tags= param
6. Solidify compress mode: add --type compress to solidify.md with
   getRecentMemories(), archiveMemories(), buildCompressionMetadata()

Security fixes: safeParseTags() for corrupt DB data, json_each() instead
of LIKE injection, ESCAPE clause for searchSessionsByKeyword, singleton
store in unified-context-builder.
This commit is contained in:
catlog22
2026-02-23 22:56:25 +08:00
parent ab0e25895c
commit 5cae3cb3c8
10 changed files with 582 additions and 62 deletions

View File

@@ -38,10 +38,21 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
const archived = archivedParam === null ? undefined : archivedParam === 'true';
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const tagsParam = url.searchParams.get('tags');
try {
const store = getCoreMemoryStore(projectPath);
const memories = store.getMemories({ archived, limit, offset });
// Use tag filter if tags query parameter is provided
let memories;
if (tagsParam) {
const tags = tagsParam.split(',').map(t => t.trim()).filter(Boolean);
memories = tags.length > 0
? store.getMemoriesByTags(tags, { archived, limit, offset })
: store.getMemories({ archived, limit, offset });
} else {
memories = store.getMemories({ archived, limit, offset });
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, memories }));
@@ -78,7 +89,7 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
// API: Core Memory - Create or update memory
if (pathname === '/api/core-memory/memories' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { content, summary, raw_output, id, archived, metadata, path: projectPath } = body;
const { content, summary, raw_output, id, archived, metadata, tags, path: projectPath } = body;
if (!content) {
return { error: 'content is required', status: 400 };
@@ -94,7 +105,8 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
summary,
raw_output,
archived,
metadata: metadata ? JSON.stringify(metadata) : undefined
metadata: metadata ? JSON.stringify(metadata) : undefined,
tags
});
// Broadcast update event
@@ -828,5 +840,48 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// API: Get session summaries (list)
if (pathname === '/api/core-memory/sessions/summaries' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
try {
const store = getCoreMemoryStore(projectPath);
const summaries = store.getSessionSummaries(limit);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, summaries }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Get single session summary by thread ID
if (pathname.match(/^\/api\/core-memory\/sessions\/[^\/]+\/summary$/) && req.method === 'GET') {
const parts = pathname.split('/');
const threadId = parts[4]; // /api/core-memory/sessions/:id/summary
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
const summary = store.getSessionSummary(threadId);
if (!summary) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session summary not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, ...summary }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
return false;
}

View File

@@ -91,10 +91,21 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
// API: Memory Module - Get all memories (core memory list)
if (pathname === '/api/memory' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const tagsParam = url.searchParams.get('tags');
try {
const store = getCoreMemoryStore(projectPath);
const memories = store.getMemories({ archived: false, limit: 100 });
// Use tag filter if tags query parameter is provided
let memories;
if (tagsParam) {
const tags = tagsParam.split(',').map(t => t.trim()).filter(Boolean);
memories = tags.length > 0
? store.getMemoriesByTags(tags, { archived: false, limit: 100 })
: store.getMemories({ archived: false, limit: 100 });
} else {
memories = store.getMemories({ archived: false, limit: 100 });
}
// Calculate total size
const totalSize = memories.reduce((sum, m) => sum + (m.content?.length || 0), 0);
@@ -109,7 +120,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
createdAt: m.created_at,
updatedAt: m.updated_at,
source: m.metadata || undefined,
tags: [], // TODO: Extract tags from metadata if available
tags: m.tags || [],
size: m.content?.length || 0
}));
@@ -139,7 +150,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
try {
const store = getCoreMemoryStore(basePath);
const memory = store.upsertMemory({ content });
const memory = store.upsertMemory({ content, tags });
// Broadcast update event
broadcastToClients({
@@ -156,7 +167,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
createdAt: memory.created_at,
updatedAt: memory.updated_at,
source: memory.metadata || undefined,
tags: tags || [],
tags: memory.tags || [],
size: memory.content?.length || 0
};
} catch (error: unknown) {
@@ -175,7 +186,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
try {
const store = getCoreMemoryStore(basePath);
const memory = store.upsertMemory({ id: memoryId, content });
const memory = store.upsertMemory({ id: memoryId, content, tags });
// Broadcast update event
broadcastToClients({
@@ -192,7 +203,7 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
createdAt: memory.created_at,
updatedAt: memory.updated_at,
source: memory.metadata || undefined,
tags: tags || [],
tags: memory.tags || [],
size: memory.content?.length || 0
};
} catch (error: unknown) {